관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] clean architecture를 적용한 MVVM 코드 맛보기 본문

Architecture (swift)/MVVM (맛보기)

[iOS - swift] clean architecture를 적용한 MVVM 코드 맛보기

jake-kim 2021. 6. 19. 01:15

Domain Layer

: 영화 검색 결과 성공한 쿼리를 저장하는 Entities, SearchMoviesUseCase, DIP를 위한 프로토콜

  • Repository Protocol위치가 UseCase에 존재
  • UseCase에 주입: 비즈니스로직에 필요한 Repository
  • UseCase끼리는 서로 의존 가능
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.fetchMoviesList(query: requestValue.query, page: requestValue.page) { result in
            
            if case .success = result {
                self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
            }

            completion(result)
        }
    }
}

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

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

Presentation Layer

: MovieListView, MovieListViewModel

  • View는 ViewModel에 의존하고 있지만, ViewModel은 View에 의존하지 않는 형태이므로 View가 변경되어도 ViewModel은 영향받지 않는 형태
  • ViewModel에 Input, Output 프로토콜 존재
    • View에 영향받지 않고 ViewModel만 가지고 테스트할 수 있는 구조
  • ViewModel에는 UseCase, action을 주입받아서 초기화
    • action: Coordinator에서 ViewModel간의 delegate 통신을 설정 (actions?.showMovieDetails(movies[indexPath.row]))
// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here. 
protocol MoviesListViewModelInput {
    func didSearch(query: String)
    func didSelect(at indexPath: IndexPath)
}

protocol MoviesListViewModelOutput {
    var items: Observable<[MoviesListItemViewModel]> { get }
    var error: Observable<String> { get }
}

protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }

struct MoviesListViewModelActions {
    // Note: if you would need to edit movie inside Details screen and update this 
    // MoviesList screen with Updated movie then you would need this closure:
    //  showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
    let showMovieDetails: (Movie) -> Void
}

final class DefaultMoviesListViewModel: MoviesListViewModel {
    
    private let searchMoviesUseCase: SearchMoviesUseCase
    private let actions: MoviesListViewModelActions?
    
    private var movies: [Movie] = []
    
    // MARK: - OUTPUT
    let items: Observable<[MoviesListItemViewModel]> = Observable([])
    let error: Observable<String> = Observable("")
    
    init(searchMoviesUseCase: SearchMoviesUseCase,
         actions: MoviesListViewModelActions) {
        self.searchMoviesUseCase = searchMoviesUseCase
        self.actions = actions
    }
    
    private func load(movieQuery: MovieQuery) {
        
        searchMoviesUseCase.execute(movieQuery: movieQuery) { result in
            switch result {
            case .success(let moviesPage):
                // Note: We must map here from Domain Entities into Item View Models. Separation of Domain and View
                self.items.value += moviesPage.movies.map(MoviesListItemViewModel.init)
                self.movies += moviesPage.movies
            case .failure:
                self.error.value = NSLocalizedString("Failed loading movies", comment: "")
            }
        }
    }
}

// MARK: - INPUT. View event methods
extension MoviesListViewModel {
    
    func didSearch(query: String) {
        load(movieQuery: MovieQuery(query: query))
    }
    
    func didSelect(at indexPath: IndexPath) {
        actions?.showMovieDetails(movies[indexPath.row])
    }
}

// Note: This item view model is to display data and does not contain any domain model to prevent views accessing it
struct MoviesListItemViewModel: Equatable {
    let title: String
}

extension MoviesListItemViewModel {
    init(movie: Movie) {
        self.title = movie.title ?? ""
    }
}
  • ViewModel에 Input, Output이 있어서, ViewController 생성 시 ViewModelMock을 넣어서 ViewController 테스트
import FBSnapshotTestCase
@testable import MoviesSearch

class MoviesListViewTests: FBSnapshotTestCase {

    let movies: [Movie] = [
            Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
            Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2"),
            Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")
    ]

    override func setUp() {
        super.setUp()
        //self.recordMode = true
    }

    func test_whenViewIsEmpty_thenShowEmptyScreen() {
        // given
        let vc = MoviesListViewController.create(
            with: MoviesListViewModelMock.stub(isEmpty: true,
                                               emptyDataTitle: NSLocalizedString("Search results", comment: ""),
                                               searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
            ),
            posterImagesRepository: PosterImagesRepositoryMock())

        // then
        FBSnapshotVerifyView(vc.view)
    }

    func test_whenHasItems_thenShowItemsOnScreen() {
        // given
        let items = movies.map(MoviesListItemViewModel.init)
        let vc = MoviesListViewController.create(
            with: MoviesListViewModelMock.stub(items: Observable(items),
                                               isEmpty: false,
                                               emptyDataTitle: NSLocalizedString("Search results", comment: ""),
                                               searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
            ),
            posterImagesRepository: PosterImagesRepositoryMock())

        // then
        FBSnapshotVerifyView(vc.view)
    }
}


struct MoviesListViewModelMock: MoviesListViewModel {
    // MARK: - Input
    func viewDidLoad() {}
    func didLoadNextPage() {}
    func didSearch(query: String) {}
    func didCancelSearch() {}
    func showQueriesSuggestions() {}
    func closeQueriesSuggestions() {}
    func didSelectItem(at index: Int) {}

    // MARK: - Output
    var items: Observable<[MoviesListItemViewModel]>
    var loading: Observable<MoviesListViewModelLoading?>
    var query: Observable<String>
    var error: Observable<String>
    var isEmpty: Bool
    var screenTitle: String
    var emptyDataTitle: String
    var errorTitle: String
    var searchBarPlaceholder: String

    static func stub(items: Observable<[MoviesListItemViewModel]> = Observable([]),
                     loading: Observable<MoviesListViewModelLoading?> = Observable(nil),
                     query: Observable<String> = Observable(""),
                     error: Observable<String> = Observable(""),
                     isEmpty: Bool = true,
                     screenTitle: String = NSLocalizedString("Movies", comment: ""),
                     emptyDataTitle: String = NSLocalizedString("Search results", comment: ""),
                     errorTitle: String = NSLocalizedString("Error", comment: ""),
                     searchBarPlaceholder: String = NSLocalizedString("Search Movies", comment: "")) -> Self {
        .init(items: items,
              loading: loading,
              query: query,
              error: error,
              isEmpty: isEmpty,
              screenTitle: screenTitle,
              emptyDataTitle: emptyDataTitle,
              errorTitle: errorTitle,
              searchBarPlaceholder: searchBarPlaceholder)
    }
}
  • ViewModel에서 UseCase들을 테스트
import XCTest

class MoviesListViewModelTests: XCTestCase {
    
    private enum SearchMoviesUseCaseError: Error {
        case someError
    }
    
    let moviesPages: [MoviesPage] = {
        let page1 = MoviesPage(page: 1, totalPages: 2, movies: [
            Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
            Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2")])
        let page2 = MoviesPage(page: 2, totalPages: 2, movies: [
            Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")])
        return [page1, page2]
    }()
    
    class SearchMoviesUseCaseMock: SearchMoviesUseCase {
        var expectation: XCTestExpectation?
        var error: Error?
        var page = MoviesPage(page: 0, totalPages: 0, movies: [])
        
        func execute(requestValue: SearchMoviesUseCaseRequestValue,
                     cached: @escaping (MoviesPage) -> Void,
                     completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
            if let error = error {
                completion(.failure(error))
            } else {
                completion(.success(page))
            }
            expectation?.fulfill()
            return nil
        }
    }
    
    func test_whenSearchMoviesUseCaseRetrievesFirstPage_thenViewModelContainsOnlyFirstPage() {
        // given
        let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
        searchMoviesUseCaseMock.expectation = self.expectation(description: "contains only first page")
        searchMoviesUseCaseMock.page = MoviesPage(page: 1, totalPages: 2, movies: moviesPages[0].movies)
        let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
        // when
        viewModel.didSearch(query: "query")
        
        // then
        waitForExpectations(timeout: 5, handler: nil)
        XCTAssertEqual(viewModel.currentPage, 1)
        XCTAssertTrue(viewModel.hasMorePages)
    }
    
    func test_whenSearchMoviesUseCaseRetrievesFirstAndSecondPage_thenViewModelContainsTwoPages() {
        // given
        let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
        searchMoviesUseCaseMock.expectation = self.expectation(description: "First page loaded")
        searchMoviesUseCaseMock.page = MoviesPage(page: 1, totalPages: 2, movies: moviesPages[0].movies)
        let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
        // when
        viewModel.didSearch(query: "query")
        waitForExpectations(timeout: 5, handler: nil)
        
        searchMoviesUseCaseMock.expectation = self.expectation(description: "Second page loaded")
        searchMoviesUseCaseMock.page = MoviesPage(page: 2, totalPages: 2, movies: moviesPages[1].movies)
        
        viewModel.didLoadNextPage()
        
        // then
        waitForExpectations(timeout: 5, handler: nil)
        XCTAssertEqual(viewModel.currentPage, 2)
        XCTAssertFalse(viewModel.hasMorePages)
    }
    
    func test_whenSearchMoviesUseCaseReturnsError_thenViewModelContainsError() {
        // given
        let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
        searchMoviesUseCaseMock.expectation = self.expectation(description: "contain errors")
        searchMoviesUseCaseMock.error = SearchMoviesUseCaseError.someError
        let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
        // when
        viewModel.didSearch(query: "query")
        
        // then
        waitForExpectations(timeout: 5, handler: nil)
        XCTAssertNotNil(viewModel.error)
    }
    
    func test_whenLastPage_thenHasNoPageIsTrue() {
        // given
        let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
        searchMoviesUseCaseMock.expectation = self.expectation(description: "First page loaded")
        searchMoviesUseCaseMock.page = MoviesPage(page: 1, totalPages: 2, movies: moviesPages[0].movies)
        let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
        // when
        viewModel.didSearch(query: "query")
        waitForExpectations(timeout: 5, handler: nil)
        
        searchMoviesUseCaseMock.expectation = self.expectation(description: "Second page loaded")
        searchMoviesUseCaseMock.page = MoviesPage(page: 2, totalPages: 2, movies: moviesPages[1].movies)

        viewModel.didLoadNextPage()
        
        // then
        waitForExpectations(timeout: 5, handler: nil)
        XCTAssertEqual(viewModel.currentPage, 2)
        XCTAssertFalse(viewModel.hasMorePages)
    }
}
  • Coordinator에서 MoviesListViewModelActions를 구현하고, 특정 ViewModel에서 실행하면 동작하도록 설정
    • Actions 선언부: ViewModel
    • Actions 구현부: Coordinator
import UIKit

protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController(actions: MoviesListViewModelActions) -> MoviesListViewController
    func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
    func makeMoviesQueriesSuggestionsListViewController(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> UIViewController
}

final class MoviesSearchFlowCoordinator {
    
    private weak var navigationController: UINavigationController?
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    private weak var moviesListVC: MoviesListViewController?
    private weak var moviesQueriesSuggestionsVC: UIViewController?

    init(navigationController: UINavigationController,
         dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.navigationController = navigationController
        self.dependencies = dependencies
    }
    
    func start() {
        // Note: here we keep strong reference with actions, this way this flow do not need to be strong referenced
        let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails,
                                                 showMovieQueriesSuggestions: showMovieQueriesSuggestions,
                                                 closeMovieQueriesSuggestions: closeMovieQueriesSuggestions)
        let vc = dependencies.makeMoviesListViewController(actions: actions)

        navigationController?.pushViewController(vc, animated: false)
        moviesListVC = vc
    }

    private func showMovieDetails(movie: Movie) {
        let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
        navigationController?.pushViewController(vc, animated: true)
    }

    private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) {
        guard let moviesListViewController = moviesListVC, moviesQueriesSuggestionsVC == nil,
            let container = moviesListViewController.suggestionsListContainer else { return }

        let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)

        moviesListViewController.add(child: vc, container: container)
        moviesQueriesSuggestionsVC = vc
        container.isHidden = false
    }

    private func closeMovieQueriesSuggestions() {
        moviesQueriesSuggestionsVC?.remove()
        moviesQueriesSuggestionsVC = nil
        moviesListVC?.suggestionsListContainer.isHidden = true
    }
}
  • ViewModel에 Input, Output 프로토콜을 이용한 TestCode
import Foundation
import FBSnapshotTestCase
@testable import MoviesSearch

class MoviesListViewTests: FBSnapshotTestCase {

    let movies: [Movie] = [
            Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
            Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2"),
            Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")
    ]

    override func setUp() {
        super.setUp()
        //self.recordMode = true
    }

    func test_whenViewIsEmpty_thenShowEmptyScreen() {
        // given
        let vc = MoviesListViewController.create(
            with: MoviesListViewModelMock.stub(isEmpty: true,
                                               emptyDataTitle: NSLocalizedString("Search results", comment: ""),
                                               searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
            ),
            posterImagesRepository: PosterImagesRepositoryMock())

        // then
        FBSnapshotVerifyView(vc.view)
    }

    func test_whenHasItems_thenShowItemsOnScreen() {
        // given
        let items = movies.map(MoviesListItemViewModel.init)
        let vc = MoviesListViewController.create(
            with: MoviesListViewModelMock.stub(items: Observable(items),
                                               isEmpty: false,
                                               emptyDataTitle: NSLocalizedString("Search results", comment: ""),
                                               searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
            ),
            posterImagesRepository: PosterImagesRepositoryMock())

        // then
        FBSnapshotVerifyView(vc.view)
    }
}


struct MoviesListViewModelMock: MoviesListViewModel {
    // MARK: - Input
    func viewDidLoad() {}
    func didLoadNextPage() {}
    func didSearch(query: String) {}
    func didCancelSearch() {}
    func showQueriesSuggestions() {}
    func closeQueriesSuggestions() {}
    func didSelectItem(at index: Int) {}

    // MARK: - Output
    var items: Observable<[MoviesListItemViewModel]>
    var loading: Observable<MoviesListViewModelLoading?>
    var query: Observable<String>
    var error: Observable<String>
    var isEmpty: Bool
    var screenTitle: String
    var emptyDataTitle: String
    var errorTitle: String
    var searchBarPlaceholder: String

    static func stub(items: Observable<[MoviesListItemViewModel]> = Observable([]),
                     loading: Observable<MoviesListViewModelLoading?> = Observable(nil),
                     query: Observable<String> = Observable(""),
                     error: Observable<String> = Observable(""),
                     isEmpty: Bool = true,
                     screenTitle: String = NSLocalizedString("Movies", comment: ""),
                     emptyDataTitle: String = NSLocalizedString("Search results", comment: ""),
                     errorTitle: String = NSLocalizedString("Error", comment: ""),
                     searchBarPlaceholder: String = NSLocalizedString("Search Movies", comment: "")) -> Self {
        .init(items: items,
              loading: loading,
              query: query,
              error: error,
              isEmpty: isEmpty,
              screenTitle: screenTitle,
              emptyDataTitle: emptyDataTitle,
              errorTitle: errorTitle,
              searchBarPlaceholder: searchBarPlaceholder)
    }
}
  • View
    • viewModel을 가지고 있는 형태
    • viewModel.output을 obserbing하는 형태
final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
    
    private 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)
    }
    
    private func bind(to viewModel: MoviesListViewModel) {
        viewModel.items.observe(on: self) { [weak self] items in
            self?.moviesTableViewController?.items = items
        }
        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)
    }
}

Data Layer

  • Repository 인터페이스
  • DTO: JSON 응답에서 도메인으로 매핑하기위한 객체
    • Encodable의 RequestDTO
    • Decodable의 ResponseDTO
  • Mapping to Domain: toDomain() 함수
final class DefaultMoviesRepository {
    
    private let dataTransferService: DataTransfer
    
    init(dataTransferService: DataTransfer) {
        self.dataTransferService = dataTransferService
    }
}

extension DefaultMoviesRepository: MoviesRepository {
    
    public func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        
        let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                                     page: page))
        return dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
            switch response {
            case .success(let moviesResponseDTO):
                completion(.success(moviesResponseDTO.toDomain()))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

// MARK: - Data Transfer Object (DTO)
// It is used as intermediate object to encode/decode JSON response into domain, inside DataTransferService
struct MoviesRequestDTO: Encodable {
    let query: String
    let page: Int
}

struct MoviesResponseDTO: Decodable {
    private enum CodingKeys: String, CodingKey {
        case page
        case totalPages = "total_pages"
        case movies = "results"
    }
    let page: Int
    let totalPages: Int
    let movies: [MovieDTO]
}
...
// MARK: - Mappings to Domain
extension MoviesResponseDTO {
    func toDomain() -> MoviesPage {
        return .init(page: page,
                     totalPages: totalPages,
                     movies: movies.map { $0.toDomain() })
    }
}
...
  • DTO는 Repository 구현부에서 사용
  • DTO 역할
    • Request에 필요한 Encodable: struct -> JSON
    • Response에 필요한 Decodable: JSON -> struct
    • Response로 부터 받은 데이터를 실제 domain에서 사용하는 모델로 변경하는 메소드 toDomain()
  • 네이밍
    • -Service: 네트워크 관련
    • -cache: Endpoint 응답을 캐시하기 위하여 DTO를 NSManagedObject에 매핑하여 CoreData 영구저장소에 저장하는 방법
  • cache를 사용하는 이유: 사용자가 데이터를 즉시 볼 수 있는 장점, 인터넷에 연결되어 있지 않아도 CoreData에서 데이터를 볼 수 있는 장점
    • cache 사용 방법: 네트워크 API 결과값을 반환하기 전에, CoreData에서 출력을 요청 -> API로부터 데이터가 오면 CoreData를 최신 데이터로 업데이트
final class DefaultMoviesRepository {

    private let dataTransferService: DataTransferService
    private let cache: MoviesResponseStorage

    init(dataTransferService: DataTransferService, cache: MoviesResponseStorage) {
        self.dataTransferService = dataTransferService
        self.cache = cache
    }
}

extension DefaultMoviesRepository: MoviesRepository {

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

        let requestDTO = MoviesRequestDTO(query: query.query, page: page)
        let task = RepositoryTask()

        cache.getResponse(for: requestDTO) { result in

            if case let .success(responseDTO?) = result {
                cached(responseDTO.toDomain())
            }
            guard !task.isCancelled else { return }

            let endpoint = APIEndpoints.getMovies(with: requestDTO)
            task.networkTask = self.dataTransferService.request(with: endpoint) { result in
                switch result {
                case .success(let responseDTO):
                    self.cache.save(response: responseDTO, for: requestDTO)
                    completion(.success(responseDTO.toDomain()))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
        return task
    }
}

인프라 계층(network)

  • 기초
    • API(Application Programming Interface): 프로그램들이 서로 상호작용하는 것을 도와주는 매개체
    • endpoint: GET/POST/PUT/DELETE를 설정하여 request하기 전 마지막 설정

struct APIEndpoints {
    
    static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -> Endpoint<MoviesResponseDTO> {

        return Endpoint(path: "search/movie/",
                        method: .get,
                        queryParametersEncodable: moviesRequestDTO)
    }
}


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

let endpoint = APIEndpoints.getMovies(with: MoviesRequestDTO(query: query.query,
                                                             page: page))
dataTransferService.request(with: endpoint) { (response: Result<MoviesResponseDTO, Error>) in
    let moviesPage = try? response.get()
}
  • Custom Observable
    • value에 값이 emit될때마다 didSet에서 closure를 통해 subscriber에게 값 방출
    • Presentation Layer에서 사용하기 때문에 main thread에서 observer를 호출
public final class Observable<Value> {
    
    struct Observer<Value> {
        weak var observer: AnyObject?
        let block: (Value) -> Void
    }
    
    private var observers = [Observer<Value>]()
    
    public var value: Value {
        didSet { notifyObservers() }
    }
    
    public init(_ value: Value) {
        self.value = value
    }
    
    public func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) {
        observers.append(Observer(observer: observer, block: observerBlock))
        observerBlock(self.value)
    }
    
    public func remove(observer: AnyObject) {
        observers = observers.filter { $0.observer !== observer }
    }
    
    private func notifyObservers() {
        for observer in observers {
            DispatchQueue.main.async { observer.block(self.value) }
        }
    }
}
  • ViewController에서 Data binding 하는 예시
final class ExampleViewController: UIViewController {
    
    private var viewModel: MoviesListViewModel!
    
    private func bind(to viewModel: ViewModel) {
        self.viewModel = viewModel
        viewModel.items.observe(on: self) { [weak self] items in
            self?.tableViewController?.items = items
            // Important: You cannot use viewModel inside this closure, it will cause retain cycle memory leak (viewModel.items.value not allowed)
            // self?.tableViewController?.items = viewModel.items.value // This would be retain cycle. You can access viewModel only with self?.viewModel
        }
        // Or in one line
        viewModel.items.observe(on: self) { [weak self] in self?.tableViewController?.items = $0 }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel)
        viewModel.viewDidLoad()
    }
}


protocol ViewModelInput {
    func viewDidLoad()
}

protocol ViewModelOutput {
    var items: Observable<[ItemViewModel]> { get }
}

protocol ViewModel: ViewModelInput, ViewModelOutput {}
  • TalbleViewCell에 대한 데이터 바인딩
final class MoviesListItemCell: UITableViewCell {

    private var viewModel: MoviesListItemViewModel! { didSet { unbind(from: oldValue) } }
  
    func fill(with viewModel: MoviesListItemViewModel) { 
        self.viewModel = viewModel
        bind(to: viewModel)
    }
    
    private func bind(to viewModel: MoviesListItemViewModel) {
        viewModel.posterImage.observe(on: self) { [weak self] in self?.imageView.image = $0.flatMap(UIImage.init) }
    }
    
    private func unbind(from item: MoviesListItemViewModel?) {
        item?.posterImage.remove(observer: self)
    }
}

통신 방법

  • ViewModel간의 통신: Delegate pattern
    • A화면 -> B화면에서 처리 후 다시 A화면으로 넘어오는 경우 -> delegate로 알림
    • B화면에는 delegate?.실행
    • A화면에는 delegate를 구현

// Step 1: Define delegate and add it to first ViewModel as weak property
protocol MoviesQueryListViewModelDelegate: class {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}
...
final class DefaultMoviesQueryListViewModel: MoviesListViewModel {
    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)
    }
}
  • Coordinator를 이용한 통신
    • FlowCoordinator에 의해 할당되거나 주입되는 클로저를 이용
    • MoviesListViewModel이 액션 클로저인 showMovieQueriesSuggestions을 사용하여 MoviesQueriesSuggestions뷰를 표시하는 방법
// MoviesQueryList.swift
// Step 1: Define action closure to communicate to another ViewModel, e.g. here we notify MovieList when query is selected
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void

// Step 2: Call action closure when needed
class MoviesQueryListViewModel {
    init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
        self.didSelect = didSelect
    }
    func didSelect(item: MoviesQueryListItemViewModel) {
        didSelect?(MovieQuery(query: item.query))
    }
}

// MoviesQueryList.swift
// Step 3: When presenting MoviesQueryListView we need to pass this action closure as paramter (_ didSelect: MovieQuery) -> Void
struct MoviesListViewModelActions {
    let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
}

class MoviesListViewModel { 
    var actions: MoviesListViewModelActions?

    func showQueriesSuggestions() {
        actions?.showMovieQueriesSuggestions { self.update(movieQuery: $0) } 
        //or simpler actions?.showMovieQueriesSuggestions(update)
    }
}

// FlowCoordinator.swift
// Step 4: Inside FlowCoordinator we connect communication of two viewModels, by injecting actions closures as self function
class MoviesSearchFlowCoordinator {
    func start() {
        let actions = MoviesListViewModelActions(showMovieQueriesSuggestions: self.showMovieQueriesSuggestions)
        let vc = dependencies.makeMoviesListViewController(actions: actions)  
        present(vc)
    }

    private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) {
        let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect)
        present(vc)
    }
}

Cocoapods Framework 계층 분리 방법

DI container 

  • 개념: 한 개체가 다른 개체의 종속성을 제공하는 방법
  • 방법1. DI Factory protocol을 이용
    • Dependencies 프로톸로 정의
    • MoviesSceneDIContainer가 이 프로토콜을 따르도록 적용
    • MoviesSearchFlowCoordinator 주입
// Define Dependencies protocol for class or struct that needs it
protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController() -> MoviesListViewController
}

class MoviesSearchFlowCoordinator {
    
    private let dependencies: MoviesSearchFlowCoordinatorDependencies

    init(dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.dependencies = dependencies
    }
...
}

// Make the DIContainer to conform to this protocol
extension MoviesSceneDIContainer: MoviesSearchFlowCoordinatorDependencies {}

// And inject MoviesSceneDIContainer `self` into class that needs it
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           dependencies: self)
    }
}
  • DI 방법2. 클로저 이용
    • 주입이 필요한 클래스 내부에서 클로저를 참조하고 있다가, 이 클로저를 주입하는 형태
// Define makeMoviesListViewController closure that returns MoviesListViewController
class MoviesSearchFlowCoordinator {
   
    private var makeMoviesListViewController: () -> MoviesListViewController

    init(navigationController: UINavigationController,
         makeMoviesListViewController: @escaping () -> MoviesListViewController) {
        ...
        self.makeMoviesListViewController = makeMoviesListViewController
    }
    ...
}

// And inject MoviesSceneDIContainer's `self`.makeMoviesListViewController function into class that needs it
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           makeMoviesListViewController: self.makeMoviesListViewController)
    }
    
    // MARK: - Movies List
    func makeMoviesListViewController() -> MoviesListViewController {
        ...
    }
}

위 구조에서 클린 아키텍처를 유지하는 원칙

  • 테스트없이 코드를 작성하는 것을 지양
  • 가능하면 서드파티 프레임워크에 종속하는 것을 지양

* 참고

- MVVM template: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/tree/master/MVVM%20Templates

- 개념: https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3

Comments