Medium - Clean Architecture and MVVM on iOS

원문 -

Trend 파악을 Medium 기고문 요약 포스팅 - iOS에서의 MVVM과 깔끔한 구조

Photo by Joel Filipe on Unsplash

Data Flow

Domain Layer

protocol SearchMoviesUseCase {
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

final class DefaultSearchMoviesUseCase: SearchMoviesUseCase {

    private let moviesRepository: MoviesRepository
    private let moviesQueriesRepository: MoviesQueriesRepository

    init(moviesRepository: MoviesRepository, moviesQueriesRepository: MoviesQueriesRepository) {
        self.moviesRepository = moviesRepository
        self.moviesQueriesRepository = moviesQueriesRepository
    }

    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        return moviesRepository.moviesList(query: requestValue.query, page: requestValue.page) { [weak self] result in
            guard let strongSelf = self else { return }

            switch result {
            case .success:
                strongSelf.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
                completion(result)
            case .failure:
                completion(result)
            }
        }
    }
}

// Repository Interfaces
protocol MoviesRepository {
    func moviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

protocol MoviesQueriesRepository {
    func recentsQueries(number: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void)
    func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void)
}

Presentation Layer

// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here.
protocol MoviesListViewModelInput {
    func didSearch(query: String)
}

protocol MoviesListViewModelOutput {
    var items: Observable<[MoviesListViewItemModel]> { get }
    var route: Observable<MoviesListViewModelRoute> { get }
    var error: Observable<String> { get }
}

protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }

enum MoviesListViewModelRoute {
    case initial
    case showMovieDetail(movieId: MovieId)
}

final class DefaultMoviesListViewModel: MoviesListViewModel {

    // MARK: - OUTPUT
    let items: Observable<[MoviesListViewItemModel]> = Observable([MoviesListViewItemModel]())
    let route: Observable<MoviesListViewModelRoute> = Observable(.initial)
    let error: Observable<String> = Observable("")

    private func load(movieQuery: MovieQuery) {

        searchMoviesUseCase.execute(movieQuery: movieQuery) { [weak self] result in
            guard let strongSelf = self else { return }
            switch result {
            case .success(let moviesPage):
                // Note: We must map here from Domain Entities to View Items Models. Separation of Domain and View
                strongSelf.items.value = strongSelf.mapToItems(moviesPage.movies)
            case .failure:
                strongSelf.error.value = NSLocalizedString("Failed loading movies", comment: "")
            }
        }
    }
}

// MARK: - INPUT. View event methods
extension MoviesListViewModel {

    func didSearch(query: String) {
        guard !query.isEmpty else { return }
        load(movieQuery: MovieQuery(query: query))
    }
}

struct MoviesListViewItemModel: Equatable {
    let title: String
}
import UIKit

final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {

    private(set) var viewModel: MoviesListViewModel!

    final class func create(with viewModel: MoviesListViewModel) -> MoviesListViewController {
        let vc = MoviesListViewController.instantiateViewController()
        vc.viewModel = viewModel
        return vc
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        bind(to: viewModel)
    }

    func bind(to viewModel: MoviesListViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            self?.moviesTableViewController?.items = items
        }
        viewModel.route.observe(on: self) { [weak self] route in
            self?.handle(route)
        }
        viewModel.error.observe(on: self) { [weak self] error in
            self?.showError(error)
        }
    }

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let searchText = searchBar.text, !searchText.isEmpty else { return }
        viewModel.didSearch(query: searchText)
    }
}

// MARK: - Handle Routing
extension MoviesListViewController {
    func handle(_ route: MoviesListViewModelRoute) {
        switch route {
        case .initial: break
        case .showMovieDetail(let movieId):
            let vc = moviesListViewControllersFactory.makeMoviesDetailsViewController(movieId: MovieId)
            navigationController?.pushViewController(vc, animated: true)
    }
}
class MoviesSceneDIContainer {
    // MARK: - Movies List
    func makeMoviesListViewController() -> UIViewController {
        if #available(iOS 13.0, *)  { // SwiftUI
            return UIHostingController(rootView: MoviesListView.create(with: makeMoviesListViewModel(),
                                                                       moviesListViewControllersFactory: self))
        } else { // UIKit
            return MoviesListViewController.create(with: makeMoviesListViewModel(), moviesListViewControllersFactory: self)
        }
    }
}

Data Layer

final class DefaultMoviesRepository {

    private let dataTransferService: DataTransfer

    init(dataTransferService: DataTransfer) {
        self.dataTransferService = dataTransferService
    }
}

extension DefaultMoviesRepository: MoviesRepository {

    public func moviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {

        let endpoint = APIEndpoints.movies(query: query.query, page: page)
        return self.dataTransferService.request(with: endpoint) { (response: Result<MoviesPage, Error>) in
            switch response {
            case .success(let moviesPage):
                completion(.success(moviesPage))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

// It is needed to map from JSON data to Decodable MoviesPage inside DataTransferService
extension MoviesPage: Decodable {

    private enum CodingKeys: String, CodingKey {
        case page
        case totalPages = "total_pages"
        case movies = "results"
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.page = try container.decode(Int.self, forKey: .page)
        self.totalPages = try container.decode(Int.self, forKey: .totalPages)
        self.movies = try container.decode([Movie].self, forKey: .movies)
    }
}

Infrastructure Layer(Network)

struct APIEndpoints {
    static func movies(query: String, page: Int) -> DataEndpoint<MoviesPage> {

        return DataEndpoint(path: "3/search/movie/",
                            queryParameters: ["query": query,
                                              "page": "\(page)"])
    }
}

let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.apiBaseURL)!,
                                  queryParameters: ["api_key": appConfigurations.apiKey])
let apiDataNetwork = DefaultNetworkService(session: URLSession.shared,
                                           config: config)

let endpoint = APIEndpoints.movies(query: query.query, page: page)
dataTransferService.request(with: endpoint) { (response: Result<MoviesPage, Error>) in
            let moviesPage = try? response.get()
}

MVVM

class Observable<Value> {

    private var closure: ((Value) -> ())?

    public var value : Value {
        didSet { DispatchQueue.main.async { self.closure?(self.value) } }
    }

    public init(_ value: Value) {
        self.value = value
    }

    func observe(_ closure: @escaping (Value) -> Void) {
        self.closure = closure
        DispatchQueue.main.async { closure(self.value) }
    }
}

MVVMs Communication

// Step 1: Define delegate and add it to first ViewModel as weak property
protocol MoviesQueryListViewModelDelegate: class {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}

class MoviesQueryListViewModel {
    private weak var delegate: MoviesQueryListViewModelDelegate?

    func didSelect(item: MoviesQueryListViewItemModel) {
        // Note: We have to map here from View Item Model to Domain Enity
        delegate?.moviesQueriesListDidSelect(movieQuery: MovieQuery(query: item.query))
    }
}

// Step 2:  Make second ViewModel to conform to this delegate
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
        update(movieQuery: movieQuery)
    }
}

Layer Separation into frameworks (Modules)

New Project -> Create Project… -> Cocoa Touch Framework

Dependecy Injection container

Using factory protocols

// Define MoviesListViewControllersFactory protocol under the ViewController
protocol MoviesListViewControllersFactory {
    func makeMoviesQueriesSuggestionsListViewController(delegate: MoviesQueryListViewModelDelegate) -> UIViewController
}

// Make your DIContainer to conform to this protocol, and inject it into the ViewController that needs this injection
extension MoviesSceneDIContainer: MoviesListViewControllersFactory {}

final class MoviesSceneDIContainer {
    // MARK: - Movies List
    // Note: All makeViewController methods should return UIViewController, to not make it specific like return MoviesListViewController.
    // It will be easier when refactoring to SwiftUI, and you will not need to make all public when developing in separate module
    func makeMoviesListViewController() -> UIViewController {
        return MoviesListViewController.create(with: makeMoviesListViewModel(), moviesListViewControllersFactory: self)
    }

Using closures

// Define makeMoviesDetailsViewController closure that returns MoviesDetailsViewController
class MoviesListViewController: UIViewController {
    let makeMoviesDetailsViewController: () -> UIViewController
    ...
}

// Inject makeMoviesDetailsViewController closure that creates MoviesDetailsViewController
class MoviesSceneDIContainer {
    // MARK: - Movies List
    func makeMoviesListViewController() -> UIViewController {
        let makeMoviesDetailsViewController: () -> UIViewController = {
            return self.makeMoviesDetailsViewController()
        }
        return MoviesListViewController.create(with: makeMoviesListViewModel(),
                                               makeMoviesDetailsViewController: makeMoviesDetailsViewController)
    }
}

Conclusion

Summary


© 2019. All rights reserved.

Powered by Hydejack v8.1.1