본문 바로가기

iOS

Clean Architecture and MVVM on iOS 글 번역

이 글은 아래 영어로 된 글을 번역하였습니다.

제대로 읽지 않으면 이해하기 어려울 것 같아서 따로 번역하여 올립니다.(잘못 번역된 부분이 있을 수 있습니다.)

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

 

Clean Architecture and MVVM on iOS

When we develop software it is important to not only use design patterns, but also architectural patterns. There are many different…

tech.olx.com

 

우리가 소프트웨어를 개발할 때 디자인패턴만 사용하는 것뿐만 아니라 소프트웨어 아키텍처 패턴을 사용하는 것도 중요합니다.

소프트웨어 개발에 있어 다양한 많은 아키텍쳐 패턴들이 있습니다.

모바일 소프트웨어 개발에는 가장 범용적으로 MVVM, Clean Architecture과 Redux 패턴을 사용합니다.

 

글에서는 iOS App MVVM패턴과 Clean Architecture 적용시킨 프로젝트 예시를 보여줍니다.

Clean Architecture 표를 보면, 애플리케이션에 서로 다른 계층이 존재하는 걸 볼 수 있습니다.

가장 중요한 규칙은 내부계층이 외부계층의 의존성을 가지면 안된다는 것입니다.

외부에서 내부로 가리키는 화살표는 의존성 규칙입니다.

오직 외부계층이 내부계층의 의존성을 갖는다는것입니다.

 

계층들을 그룹화하면 세가지로 나타낼  있습니다. : Presentation, Domain, Data

Domain Layer(Business logic)은 가장 안쪽의 계층입니다.(다른 계층과 의존성을 갖지 않는, 완벽히 분리됨)

이 계층은 Entities, Use Cases, Repository Interfaces를 포함합니다.

이 계층은 잠재적으로 다른 프로젝트 안에서 재사용될 수 있습니다.

이러한 분리를 통해서 의존성이 필요하지 않으므로, 테스트 대상 내에서 호스트 앱을 사용하지 않을 수 있습니다.

이로 인해 Domain Use Cases 테스트가 단 몇 초 만에 완료됩니다.

Domain 계층은 다른 계층의 어떤 것이든 포함시키면 안 됩니다.(ex Presentation - UIKit, SwiftUI or Data Layer)

좋은 아키텍처가 Use Cases에 집중되는 이유는 아키텍트가 환경, 도구 그리고 프레임워크에 전념하지 않고 해당 Use Cases를 지원하는 구조를 안전하게 설명할 수 있기 때문입니다.

이것을 Screaming Architecture이라고 합니다.

 

Presentation Layer은 UI를 포함합니다(UIViewControllers, or SwiftUI Views)

Views는 하나 혹은 여러 개의 Use Cases를 실행하는 ViewModel에 의해 조정됩니다.

Presentation Layer 오직 Domain Layer에만 의존합니다.

 

Data Layer은 저장소 구현과 하나 이상의 Data Source를 포함합니다.

저장소들은 다른 Data Source로부터 데이터 조정하는 역할을 갖고 있습니다.

Data Source는 원격 혹은 로컬일 수 있습니다.

Data Layer은 오직 Domain Layer에만 의존합니다.

계층에서, 네트워크 JSON 데이터 매핑을 Domain Model 추가할 있습니다.

 

아래의 구성요소들은 계층사이의 의존성과 데이터흐름을 나타냅니다.

또한 리포지토리 인터페이스를 사용하는 종속성 반전 지점을 볼 수 있습니다.

계층의 설명은 기사 초반에 말했던 예제프로젝트를 기반으로 합니다.

Data Flow(데이터 흐름)

  1. View는 ViewModel로부터 메서드를 호출합니다.
  2. ViewModel은 Use case를 실행합니다.
  3. Use Case는 사용자와 리포지토리의 데이터를 결합합니다.
  4. 각 리포지토리는 원격데이터(네트워크), 영구적 DB저장소, 메모리에 있는 데이터를 반환합니다.
  5. 정보들은 항목의 아이템을 표시하는 View로 다시 이동합니다.

Dependency Direction(의존성 방향)

  • Presentation -> Domain Layer <- Data Repositories Layer
  • Presentation Layer (MVVM) = ViewModels(Presenters) + Views(UI) (뷰와 뷰모델)
  • Domain Layer = Entities + Use Cases + Repositories Interfaces (엔티티, 유스케이스, 리포지토리 인터페이스)
  • Data Repositories Layer = Repositories Implementations + API(Network) + Persistence DB (리포지토리 구현, API, 영구적 DB)

 

Example Project: "Movies App"

예제 프로젝트 주소: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM

 

GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoor

Template iOS app using Clean Architecture and MVVM. Includes DIContainer, FlowCoordinator, DTO, Response Caching and one of the views in SwiftUI - GitHub - kudoleh/iOS-Clean-Architecture-MVVM: Tem...

github.com

 

Domain Layer

예제프로젝트 안에서 Domain Layer을 찾아볼 수 있습니다.

여기에는 영화들을 검색하고, 최근 성공한 쿼리들을 저장하는 SearchMoviesUseCase와 Entities를 포함합니다.

또한 종속성 반전에 필요한 Data Repositories Interfaces를 포함하고 있습니다.

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)
}

Use Cases를 만드는 다른 방법은 start() 함수와 함께 UseCase 프로토콜을 사용하는 것이고, 모든 UseCase는 해당 프로토콜을 준수하는 것입니다.

예제프로젝트의 Ues Case 중 하나는 이 접근 방식을 따릅니다 : FetchRecentMovieQueriesUseCase

Use Cases는 Interactors라고도 불립니다.

Use Case 다른 Use Case들과 의존될 있습니다.

 

 

Presentation Layer

Presentation Layer은 MoviesListView로부터 관찰되는 항목이 포함된 MovieListViewModel을 포함합니다.

MoviesListViewModel은 UIKit을 임폴트 하지 않습니다.

뷰모델이 UIKit, SwiftUI와 같은 모든 UI 프레임워크를 채택하지 않으면서 쉽게 재사용하고 리팩터링 할 수 있습니다.

예로, 앞으로는 뷰모델은 변하지 않으므로 UIKit에서 SwiftUI로의 Views 리팩토링은 더욱더 쉬워질 것입니다.

// 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 ?? ""
    }
}

 

MoviesListViewModelInput과 MoviesListViewModelOutput의 인터페이스를 사용하여, 임의의 ViewModel을 쉽게 만들어 MoviesListViewContoller를 테스트할 수 있게 만듭니다.

또한 MoviesSearchFlowCoordinator에게 언제 다른 뷰를 나타낼지 전달할 MoviesListViewModelActions클로저가 있습니다.

액션 클로저가 Coordinator에 호출될 때, 영화의 자세한 내용의 뷰를 나타냅니다.

우리는 나중에 필요하다면 쉽게 많은 액션들을 추가하기 위해 구조체를 사용하여 액션을 그룹화합니다.

 

Presentation Layer은 또한 MoviesListViewModel의 데이터가 바인딩된 MoviesListViewController를 포함합니다.

 

UI는 비즈니스 로직이나 애플리케이션 로직(Business Models and UseCases)에 접근할 수 없고, 오직 뷰모델만 가능합니다.

이것이 바로 관심사의 분리입니다.

우리는 비즈니스 모델을 UI로 직접적으로 전달할 수 없습니다.

이것은 우리가 비즈니스모델을 뷰모델 내부의 뷰모델을 매핑하고 뷰에 전달하는 이유입니다.

 

우리는 또한 영화 검색을 시작하기 위해 뷰에서 뷰모델로 검색 이벤트 호출을 추가합니다.

import UIKit

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)
    }
}

항목들을 관찰하고, 항목이 바뀌면 뷰를 다시 불러옵니다.

여기엔 아래 MVVM섹션에서 설명할 간단한 Observable 사용합니다.

 

또한 MoviesSearchFlowCoordinator내부의 MoviesListViewModel 작업에 showMovieDetails함수를 할당하여 영화의 세부장면을 표시합니다.

 

protocol MoviesSearchFlowCoordinatorDependencies  {
    func makeMoviesListViewController() -> UIViewController
    func makeMoviesDetailsViewController(movie: Movie) -> UIViewController
}

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

    init(navigationController: UINavigationController,
         dependencies: MoviesSearchFlowCoordinatorDependencies) {
        self.navigationController = navigationController
        self.dependencies = dependencies
    }
    
    func start() {
        // Note: here we keep strong reference with actions closures, this way this flow do not need to be strong referenced
        let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails)
        let vc = dependencies.makeMoviesListViewController(actions: actions)
        
        navigationController?.pushViewController(vc, animated: false)
    }
    
    private func showMovieDetails(movie: Movie) {
        let vc = dependencies.makeMoviesDetailsViewController(movie: movie)
        navigationController?.pushViewController(vc, animated: true)
    }
}

ViewController의 크기와 책임을 줄이기 위해 프레젠테이션 로직에 Flow Coordinator을 사용합니다.

필요한 동안 Flow가 할당 해제 되지 않기 위해 Flow(액션 클로저, 자체기능)에 대한 강한 참조를 하고 있습니다.

 

이러한 접근방식은 다른 뷰들을 수정 없이 같은 뷰모델을 사용하기 쉽게 해 줍니다.

iOS 13.0 이상 버전을 확인하고 UIKit 대신에 SwiftUI를 만들고 같은 뷰모델로 데이터 바인딩을 합니다. 그렇지 않다면 UIKit을 생성합니다.

예제프로젝트에서 MoviesQueriesSuggestionList에 대한 SwiftUI예제를 추가해 두었습니다.

적어도 Xcode 11 베타버전이 필요합니다.

// MARK: - Movies Queries Suggestions List
func makeMoviesQueriesSuggestionsListViewController(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> UIViewController {
   if #available(iOS 13.0, *) { // SwiftUI
       let view = MoviesQueryListView(viewModelWrapper: makeMoviesQueryListViewModelWrapper(didSelect: didSelect))
       return UIHostingController(rootView: view)
   } else { // UIKit
       return MoviesQueriesTableViewController.create(with: makeMoviesQueryListViewModel(didSelect: didSelect))
   }
}

 

 

Data Layer

Data Layer은 DefaultMoviesRepository를 포함합니다.

이것은 Domain Layer(의존성이 분리됨) 내부에 정의된 인터페이스를 준수합니다.

또한 JSON 데이터와 CoreData Entities Domain Model 매핑하는 기능을 추가합니다.

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는 JSON응답을 Domain으로 매핑하는 중간 객체로 사용됩니다.

또한 응답의 마지막을 캐시한다면 영구 데이터전송객체를 영구객체에 매핑하여 영구 저장소에 저장됩니다.

(마지막 응답값을 데이터전송객체를 매핑하여 저장소에 저장한다는 얘기인듯합니다..)

(인터넷 접속과 관계없이 최근에 불러온 데이터를 보여주기 위해서 저장해 두는 것?)

 

일반적인 데이터저장소는 API 데이터서비스와 영구저장소와 함께 주입될 수 있습니다.

데이터 저장소는 두 개의 의존성을 갖고 데이터를 반환합니다.

규칙은 먼저 캐시 된 데이터를 출력하기위해 영구저장소를 호출하는것입니다.(NSManagerObject는 DTO 객체를 통해 매핑되고, 캐시된 데이터클로저에서 검색됨)

그다음 최신 업데이트된 데이터를 리턴할 API Data Service를 호출합니다.

그 다음 영구 저장소는 최신으로 업데이트됩니다.(DTO가 영구객체에 매핑되고 저장됩니다.)

그 다음 DTO는 Domain으로 매핑되고 업데이트된 데이터/ 완료 클로저에서 검색됩니다.

이 방식은 유저들이 즉각적으로 데이터를 볼 수 있습니다.

네트워크 연결이 없음에도 사용자들은 영구저장소로부터 가장 최신의 데이터를 볼 수 있습니다.

 

저장소와 API는 완전히 다른 구현으로 대체될 수 있습니다(예를 들어 CoreData에서 Realm으로)

앱의 모든 나머지 계층들은 이러한 변화에 영항을 받지 않지만 DB 세부사항이기 떄문입니다.

 

Infrastructure Layer(Network)

이것은 네트워크 프레임워크를 둘러싼 래퍼입니다. Alamofire일 수 있습니다.(아니면 다른 네트워크 프레임워크)

이것은 네트워크 파라미터들로 구성할 수 있습니다.(예를 들어 기본 URL)

또한 엔드포인트 정의를 지원하고 데이터 매핑 메서드를 포함합니다(Decodable 사용한)

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()
}

 

 

MVVM

Model-View-ViewModel 패턴은 UI와 Domain사이의 관심사를 완전한 분리됨을 제공합니다.

클린 아키텍처와 함께 사용하면 Presentation과 UI 계층의 관심사 분리에 도움을 줍니다.

 

다른 뷰 구현은 같은 뷰모델이 사용될 수 있습니다.

예를 들어, CarsAroundListView와 CarsAroundMapView는 둘 다 CarsAroundViewModel을 사용할 수 있습니다.

또한 하나의 UIKit 뷰와 다른 하나의 SwiftUI도 같은 뷰모델로 지정할 수 있습니다.

뷰모델 내부에 UIKit, WatchKit, SwiftUI를 임폴트하지 않는 것을 기억하는 것이 중요합니다.

이러한 방식은 필요하다면 다른 플랫폼에서 쉽게 재사용될 있다는점입니다.

View와 ViewModel 간의 데이터바인딩은 예시로 클로저, 델리게이트 또는 관찰자(RxSwift 같은)로 이루어집니다.

Combine과 SwiftUI 또한 사용될 수 있지만 최소한의 iOS 13 버전이상을 사용해야 합니다.

View는 ViewModel과 직접적인 관계가 있으며, View내부에서 이벤트가 발생할 때마다 ViewModel에게 전달합니다

ViewModel에서는 View에 대한 직접참조는 없습니다.(데이터바인딩만 참조)

이 예시에서, 우리는 타사 의존성을 피하기 위해 클로저의 와 didSet의 간단한 조합을 사용합니다.

public final class Observable<Value> {
    
    private var closure: ((Value) -> ())?

    public var value: Value {
        didSet { closure?(value) }
    }

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

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

이것은 여러 관찰자와 관찰자 제거가 포함된 전체 구현을 볼 수 있는 매우 간단한 버전의 Observable입니다.

 

ViewController의 데이터바인딩 예시:

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 {}

클로저 관찰에서 뷰모델 접근은 허용하지 않습니다. 이것은 메모리 릭 현상을 발생하기 때문입니다.

오직 self와 함께 뷰모델을 접근할 수 있습니다: self?.viewModel

 

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)
    }
}

뷰가 재사용가능하다면 바인딩을 해제해야 합니다.(테이블  셀)

 

MVVMs Communication (MVVM의 통신)

Delegation

하나의 MVVM 뷰모델은 델리게이트 패턴을 사용하여 또다른 하나의 MVVM 뷰모델과 통신합니다.

예를 들어, ItemsListViewModel과 ItemEditViewModel이 있습니다.

그다음 ItemEditViewModelDidEditItem메서드를 갖는 ItemEditViewModelDelegate 프로토콜을 만듭니다.

그리고 이것을 다음 프로토콜을 채택하도록 만듭니다. Extension ListItemsviewModel: ItemEditViewModelDelegate

// 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)
    }
}

경우 델리게이트를 Responders 지정할 있습니다: ItemEditViewModelResponder

 

Closures

또 다른 통신방법은 FlowCoordinator로부터 주입되거나 할당받은 클로저를 사용하는 것입니다.

이 예제프로젝트에서는 MoviesListViewModel이 액션 클로저 showMovieQueriesSuggestions를 사용하여 MoviesQueriesSuggestionsView를 표시하는 방법을 볼 수 있습니다.

또한 매개변수 (_ didSelect: MovieQuery) -> Void를 전달하여 해당 View에서 재호출 할 수 있습니다.

통신은 MoviesSearchFlowCoordinator내부와 연결되어 있습니다.

// 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)
    }
}

 

 

Layer Separation into frameworks (Modules)

이제 예제 앱의 각 계층(도메인, 프레젠테이션, UI, 데이터, 인프라네트워크)들은 별도의 프레임워크와 쉽게 분리할 수 있습니다.

그다음 CocoaPod 사용해 이러한 프레임워크를 당신의 기본 앱에 포함시킬 있습니다.

 

Dependency Injection Container (의존성 주입 컨테이너?)

의존성 주입은 한 객체가 다른 객체의 종속성을 제공하는 기술입니다.

앱 내 DIContainer 모든 주입의 중심단위입니다.

 

종속성 팩토리 프로토콜 사용

많은 보기 중 하나는 종속성 생성을 DIContainer에 위임하는 종속성 프로토콜을 선언하는 것입니다.

이를 수행하기 위해 MoviesSearchFlowCoordinatorDependencies프로토콜을 정의하고 MoviesSceneDIContainer가 이 프로토콜을 준수하도록 생성합니다. 그다음 이 주입이 필요한 MoviesSearchFlowCoordinator에 주입하여 MoviesListViewController를 생성하고 표시해야 합니다.

여기에 단계가 있습니다.

// 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)
    }
}

클로저 사용

또 다른 옵션은 클로저를 사용하는 것입니다.

이를 수행하기 위해 주입이 필요로 하는 클래스 내부에 클로저 정의가 필요합니다. 그리고 클로저를 주입합니다.

// 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, Redux입니다.

MVVM과 클린 아키텍처는 별도로 사용할 수 있습니다. 그러나 MVVM은 오직 Presentation Layer내부에서의 관심사 분리만 제공하지만, 클린아키텍처는 코드를 쉽게 테스트하고 재사용하고 이해하도록 모듈식 계층을 분할합니다.

유스케이스가 저장소를 호출하는 것 외에 다른 작업이 없더라도 유스케이스 생성을 건너뛰지 않는 것이 중요합니다.

이 방식은, 당신의 아키텍처가 새로운 개발자가 당신의 유스케이스들을 봤을 때 자체적으로 설명될 것입니다.

 

이것은 출발점으로 유용할지라도, 모든 면에서 적합하진 않습니다.

당신의 프로젝트에서 요구사항을 충족하는 아키텍처를 고르세요.

 

클린 아키텍처는 TDD에서 매우 잘 작동합니다(Test Driven Development)(테스트 중심 개발)

이 아키텍쳐는 프로젝트를 테스트하기 용이하게 만들어주고 계층들을 쉽게 교체할 수 있습니다.(UI나 데이터)

도메인 기반 설계는 클린 아키텍쳐와 맞습니다.