Skip to content

Commit

Permalink
Implemented threading strategy to prevent UI updates outside the main…
Browse files Browse the repository at this point in the history
… queue using the Decorator pattern. This way, the UI is agnostic of threading logic/details.

Threading is dealt with in the Composition layer.
  • Loading branch information
ispiryan committed Jun 13, 2024
1 parent 9ccfada commit a5a4b3d
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 111 deletions.
20 changes: 20 additions & 0 deletions FeedApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
9728EAA22C18D58800F28C4A /* Feed.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9728EAA12C18D58800F28C4A /* Feed.xcstrings */; };
9728EAA72C18D72E00F28C4A /* FeedLocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAA62C18D72E00F28C4A /* FeedLocalizationTests.swift */; };
9728EAAA2C18D87000F28C4A /* FeedUIIntegrationTests+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAA82C18D85800F28C4A /* FeedUIIntegrationTests+Localization.swift */; };
9728EAAC2C1B6DBB00F28C4A /* FeedImageDataLoaderPresentationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAAB2C1B6DBB00F28C4A /* FeedImageDataLoaderPresentationAdapter.swift */; };
9728EAAE2C1B6E4C00F28C4A /* FeedLoaderPresentationAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAAD2C1B6E4C00F28C4A /* FeedLoaderPresentationAdapter.swift */; };
9728EAB02C1B6EF300F28C4A /* FeedViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAAF2C1B6EF300F28C4A /* FeedViewAdapter.swift */; };
9728EAB22C1B6F1600F28C4A /* MainQueueDispatchDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAB12C1B6F1600F28C4A /* MainQueueDispatchDecorator.swift */; };
9728EAB42C1B6F3F00F28C4A /* WeakRefVirtualProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728EAB32C1B6F3F00F28C4A /* WeakRefVirtualProxy.swift */; };
973BA9CE2BC5478C00013B53 /* FeedApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 973BA9C52BC5478900013B53 /* FeedApp.framework */; };
973BA9D32BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973BA9D22BC5478C00013B53 /* LoadFeedFromRemoteUseCaseTests.swift */; };
973BA9D42BC5478C00013B53 /* FeedApp.h in Headers */ = {isa = PBXBuildFile; fileRef = 973BA9C82BC5478900013B53 /* FeedApp.h */; settings = {ATTRIBUTES = (Public, ); }; };
Expand Down Expand Up @@ -140,6 +145,11 @@
9728EAA12C18D58800F28C4A /* Feed.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feed.xcstrings; sourceTree = "<group>"; };
9728EAA62C18D72E00F28C4A /* FeedLocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLocalizationTests.swift; sourceTree = "<group>"; };
9728EAA82C18D85800F28C4A /* FeedUIIntegrationTests+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeedUIIntegrationTests+Localization.swift"; sourceTree = "<group>"; };
9728EAAB2C1B6DBB00F28C4A /* FeedImageDataLoaderPresentationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataLoaderPresentationAdapter.swift; sourceTree = "<group>"; };
9728EAAD2C1B6E4C00F28C4A /* FeedLoaderPresentationAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedLoaderPresentationAdapter.swift; sourceTree = "<group>"; };
9728EAAF2C1B6EF300F28C4A /* FeedViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewAdapter.swift; sourceTree = "<group>"; };
9728EAB12C1B6F1600F28C4A /* MainQueueDispatchDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainQueueDispatchDecorator.swift; sourceTree = "<group>"; };
9728EAB32C1B6F3F00F28C4A /* WeakRefVirtualProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakRefVirtualProxy.swift; sourceTree = "<group>"; };
973BA9C52BC5478900013B53 /* FeedApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FeedApp.framework; sourceTree = BUILT_PRODUCTS_DIR; };
973BA9C82BC5478900013B53 /* FeedApp.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedApp.h; sourceTree = "<group>"; };
973BA9CD2BC5478C00013B53 /* FeedAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -454,6 +464,11 @@
isa = PBXGroup;
children = (
974D6F612BF4EF5C00F7211C /* FeedUIComposer.swift */,
9728EAAD2C1B6E4C00F28C4A /* FeedLoaderPresentationAdapter.swift */,
9728EAAB2C1B6DBB00F28C4A /* FeedImageDataLoaderPresentationAdapter.swift */,
9728EAAF2C1B6EF300F28C4A /* FeedViewAdapter.swift */,
9728EAB12C1B6F1600F28C4A /* MainQueueDispatchDecorator.swift */,
9728EAB32C1B6F3F00F28C4A /* WeakRefVirtualProxy.swift */,
);
path = Composers;
sourceTree = "<group>";
Expand Down Expand Up @@ -842,18 +857,23 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9728EAB02C1B6EF300F28C4A /* FeedViewAdapter.swift in Sources */,
974D6F6B2BF78D2800F7211C /* FeedPresenter.swift in Sources */,
9762D4972BF8A60D00FCCC7E /* FeedViewModel.swift in Sources */,
9728EAAC2C1B6DBB00F28C4A /* FeedImageDataLoaderPresentationAdapter.swift in Sources */,
974D6F472BF4DB9900F7211C /* FeedImageDataLoader.swift in Sources */,
9728EAB22C1B6F1600F28C4A /* MainQueueDispatchDecorator.swift in Sources */,
9728EA9E2C13238200F28C4A /* UITableView+Dequeueing.swift in Sources */,
9762D4952BF8A5D900FCCC7E /* FeedLoadingViewModel.swift in Sources */,
9728EAAE2C1B6E4C00F28C4A /* FeedLoaderPresentationAdapter.swift in Sources */,
9728EAA02C13240E00F28C4A /* UIImage+Animation.swift in Sources */,
974D6F412BF3A07B00F7211C /* UIView+Shimmering.swift in Sources */,
9762D4932BF8A4B200FCCC7E /* FeedImagePresenter.swift in Sources */,
9708B1C82BF28E9800D170EA /* FeedViewController.swift in Sources */,
974D6F3F2BF366A700F7211C /* FeedImageCell.swift in Sources */,
974D6F682BF6575400F7211C /* FeedImageViewModel.swift in Sources */,
974D6F602BF4EC9700F7211C /* FeedImageCellController.swift in Sources */,
9728EAB42C1B6F3F00F28C4A /* WeakRefVirtualProxy.swift in Sources */,
974D6F622BF4EF5C00F7211C /* FeedUIComposer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,36 @@
// Created by Aram Ispiryan on 13.06.24.
//

import Foundation
import FeedApp

final class FeedImageDataLoaderPresentationAdapter<View: FeedImageView, Image>: FeedImageCellControllerDelegate where View.Image == Image {
private let model: FeedImage
private let imageLoader: FeedImageDataLoader
private var task: FeedImageDataLoaderTask?

var presenter: FeedImagePresenter<View, Image>?

init(model: FeedImage, imageLoader: FeedImageDataLoader) {
self.model = model
self.imageLoader = imageLoader
}

func didRequestImage() {
presenter?.didStartLoadingImageData(for: model)

let model = self.model
task = imageLoader.loadImageData(from: model.url) { [weak self] result in
switch result {
case let .success(data):
self?.presenter?.didFinishLoadingImageData(with: data, for: model)

case let .failure(error):
self?.presenter?.didFinishLoadingImageData(with: error, for: model)
}
}
}

func didCancelImageRequest() {
task?.cancel()
}
}
25 changes: 24 additions & 1 deletion FeedAppiOS/Feed UI/Composers/FeedLoaderPresentationAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,27 @@
// Created by Aram Ispiryan on 13.06.24.
//

import Foundation
import FeedApp

final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate {
private let feedLoader: FeedLoader
var presenter: FeedPresenter?

init(feedLoader: FeedLoader) {
self.feedLoader = feedLoader
}

func didRequestFeedRefresh() {
presenter?.didStartLoadingFeed()

feedLoader.load { [weak self] result in
switch result {
case let .success(feed):
self?.presenter?.didFinishLoadingFeed(with: feed)

case let .failure(error):
self?.presenter?.didFinishLoadingFeed(with: error)
}
}
}
}
117 changes: 10 additions & 107 deletions FeedAppiOS/Feed UI/Composers/FeedUIComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@ public final class FeedUIComposer {
private init() {}

public static func feedComposedWith(feedLoader: FeedLoader, imageLoader: FeedImageDataLoader) -> FeedViewController {
let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: feedLoader)

let feedController = FeedViewController.makeWith(
delegate: presentationAdapter,
title: FeedPresenter.title)
let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader:
MainQueueDispatchDecorator(decoratee: feedLoader))

let feedController = makeFeedViewController(
delegate: presentationAdapter,
title: FeedPresenter.title)

presentationAdapter.presenter = FeedPresenter(
feedView: FeedViewAdapter(controller: feedController, imageLoader: imageLoader),
feedView: FeedViewAdapter(
controller: feedController,
imageLoader: MainQueueDispatchDecorator(decoratee: imageLoader)),
loadingView: WeakRefVirtualProxy(feedController))

return feedController
}
}

private extension FeedViewController {
static func makeWith(delegate: FeedViewControllerDelegate, title: String) -> FeedViewController {
private static func makeFeedViewController(delegate: FeedViewControllerDelegate, title: String) -> FeedViewController {
let bundle = Bundle(for: FeedViewController.self)
let storyboard = UIStoryboard(name: "Feed", bundle: bundle)
let feedController = storyboard.instantiateInitialViewController() as! FeedViewController
Expand All @@ -36,101 +37,3 @@ private extension FeedViewController {
return feedController
}
}

private final class WeakRefVirtualProxy<T: AnyObject> {
private weak var object: T?

init(_ object: T) {
self.object = object
}
}

extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView {
func display(_ viewModel: FeedLoadingViewModel) {
object?.display(viewModel)
}
}

extension WeakRefVirtualProxy: FeedImageView where T: FeedImageView, T.Image == UIImage {
func display(_ model: FeedImageViewModel<UIImage>) {
object?.display(model)
}
}

private final class FeedViewAdapter: FeedView {
private weak var controller: FeedViewController?
private let imageLoader: FeedImageDataLoader

init(controller: FeedViewController, imageLoader: FeedImageDataLoader) {
self.controller = controller
self.imageLoader = imageLoader
}

func display(_ viewModel: FeedViewModel) {
controller?.tableModel = viewModel.feed.map { model in
let adapter = FeedImageDataLoaderPresentationAdapter<WeakRefVirtualProxy<FeedImageCellController>, UIImage>(model: model, imageLoader: imageLoader)
let view = FeedImageCellController(delegate: adapter)

adapter.presenter = FeedImagePresenter(
view: WeakRefVirtualProxy(view),
imageTransformer: UIImage.init)

return view
}
}
}

private final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate {
private let feedLoader: FeedLoader
var presenter: FeedPresenter?

init(feedLoader: FeedLoader) {
self.feedLoader = feedLoader
}

func didRequestFeedRefresh() {
presenter?.didStartLoadingFeed()

feedLoader.load { [weak self] result in
switch result {
case let .success(feed):
self?.presenter?.didFinishLoadingFeed(with: feed)

case let .failure(error):
self?.presenter?.didFinishLoadingFeed(with: error)
}
}
}
}

private final class FeedImageDataLoaderPresentationAdapter<View: FeedImageView, Image>: FeedImageCellControllerDelegate where View.Image == Image {
private let model: FeedImage
private let imageLoader: FeedImageDataLoader
private var task: FeedImageDataLoaderTask?

var presenter: FeedImagePresenter<View, Image>?

init(model: FeedImage, imageLoader: FeedImageDataLoader) {
self.model = model
self.imageLoader = imageLoader
}

func didRequestImage() {
presenter?.didStartLoadingImageData(for: model)

let model = self.model
task = imageLoader.loadImageData(from: model.url) { [weak self] result in
switch result {
case let .success(data):
self?.presenter?.didFinishLoadingImageData(with: data, for: model)

case let .failure(error):
self?.presenter?.didFinishLoadingImageData(with: error, for: model)
}
}
}

func didCancelImageRequest() {
task?.cancel()
}
}
26 changes: 25 additions & 1 deletion FeedAppiOS/Feed UI/Composers/FeedViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,28 @@
// Created by Aram Ispiryan on 13.06.24.
//

import Foundation
import UIKit
import FeedApp

final class FeedViewAdapter: FeedView {
private weak var controller: FeedViewController?
private let imageLoader: FeedImageDataLoader

init(controller: FeedViewController, imageLoader: FeedImageDataLoader) {
self.controller = controller
self.imageLoader = imageLoader
}

func display(_ viewModel: FeedViewModel) {
controller?.tableModel = viewModel.feed.map { model in
let adapter = FeedImageDataLoaderPresentationAdapter<WeakRefVirtualProxy<FeedImageCellController>, UIImage>(model: model, imageLoader: imageLoader)
let view = FeedImageCellController(delegate: adapter)

adapter.presenter = FeedImagePresenter(
view: WeakRefVirtualProxy(view),
imageTransformer: UIImage.init)

return view
}
}
}
33 changes: 33 additions & 0 deletions FeedAppiOS/Feed UI/Composers/MainQueueDispatchDecorator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,36 @@
//

import Foundation
import FeedApp

final class MainQueueDispatchDecorator<T> {
private let decoratee: T

init(decoratee: T) {
self.decoratee = decoratee
}

func dispatch(completion: @escaping () -> Void) {
guard Thread.isMainThread else {
return DispatchQueue.main.async(execute: completion)
}

completion()
}
}

extension MainQueueDispatchDecorator: FeedLoader where T == FeedLoader {
func load(completion: @escaping (FeedLoader.Result) -> Void) {
decoratee.load { [weak self] result in
self?.dispatch { completion(result) }
}
}
}

extension MainQueueDispatchDecorator: FeedImageDataLoader where T == FeedImageDataLoader {
func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> FeedImageDataLoaderTask {
return decoratee.loadImageData(from: url) { [weak self] result in
self?.dispatch { completion(result) }
}
}
}
22 changes: 21 additions & 1 deletion FeedAppiOS/Feed UI/Composers/WeakRefVirtualProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,24 @@
// Created by Aram Ispiryan on 13.06.24.
//

import Foundation
import UIKit

final class WeakRefVirtualProxy<T: AnyObject> {
private weak var object: T?

init(_ object: T) {
self.object = object
}
}

extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView {
func display(_ viewModel: FeedLoadingViewModel) {
object?.display(viewModel)
}
}

extension WeakRefVirtualProxy: FeedImageView where T: FeedImageView, T.Image == UIImage {
func display(_ model: FeedImageViewModel<UIImage>) {
object?.display(model)
}
}
27 changes: 27 additions & 0 deletions FeedAppiOSTest/Feed UI/Controllers/FeedUIIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,33 @@ final class FeedUIIntegrationTests: XCTestCase {

XCTAssertEqual(newView.renderedImage, imageData)
}

func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() {
let (sut, loader) = makeSUT()
sut.loadViewIfNeeded()

let exp = expectation(description: "Wait for background queue")
DispatchQueue.global().async {
loader.completeFeedLoading(at: 0)
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}

func test_loadImageDataCompletion_dispatchesFromBackgroundToMainThread() {
let (sut, loader) = makeSUT()

sut.loadViewIfNeeded()
loader.completeFeedLoading(with: [makeImage()])
_ = sut.simulateFeedImageViewVisible(at: 0)

let exp = expectation(description: "Wait for background queue")
DispatchQueue.global().async {
loader.completeImageLoading(with: self.anyImageData(), at: 0)
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
}

// MARK: - Helpers

Expand Down

0 comments on commit a5a4b3d

Please sign in to comment.