[iOS] - Clean Architecture, 직접 해보기


Clean architecture가 무엇인지 알아보자.


image

Clean Architecture가 뭐에요

소프트웨어를 디자인 할 때는 디자인 패턴을 사용하는 것도 중요하지만 구조적인 패턴을 사용하는 것도 중요하다. 모바일 sw 엔지니어링에서 가장 많이 사용되는 패턴들은 MVVM, Clean Architecture, Redux 패턴이 있다. 이 글에서는 Clean Architecture가 무엇인지 알아보고, MVVM과 함께 실제 코드에 적용해 보려고 한다.

개념적인 부분을 보기 위해 참고할 Github 프로젝트이다.

image

위의 왼쪽 그래프를 보면 여러 개의 레이어들이 존재하고 있는 것을 볼 수 있다. 이 그래프에서 핵심은

안쪽 레이어에 있는 애들은 바깥 레이어에 있는 것들을 몰라야 한다. 즉 안쪽 레이어에서 바깥쪽에 의존하지 말아야 한다. DI와 DIP의 개념을 생각하면 안쪽 레이어에서 바깥쪽에 의존하지 말아야 한다는 말은 안쪽 레이어의 객체(편의상 객체라 하겠다. 클래스, 구조체 등등) 바깥쪽 레이어의 객체를 내부에서 생성하면 안된다는 것이다. Java로 예를 들자면 의존성을 갖게 되는 것은 new를 통해 객체를 생성하는 것과 같다.

그래서 위의 그래프에서도 dependency rule의 화살표가 안쪽으로 향하고 있다. 안쪽 레이어에 의존해야 한다는 의미다.

Domain, Presentation, Data Layer

image

이렇게 레이어를 나눴는데, 여기서 그룹을 지을 수 있다. Domain 레이어, Presentation 레이어, 그리고 Data 레이어가 있다.

Domain layer(Business logic)

그래프의 가장 안쪽에 위치한 부분이다. 가장 안쪽에 있으므로 완전히 고립되어 있는 상태로, 바깥의 애들을 아무것도 모르는 애들이다.

Entity(Business Model), Use Case, Repository Interface 를 포함한다. 이 레이어는 다른 프로젝트에서도 재사용될 수 있다. 이렇게 레이어가 분리돼서 다른 의존성이나 3rd party가 필요하지 않기 때문에 앱을 테스트할 때 환경에 구애받지 않게 하고, 따라서 도메인 Use case 테스트를 몇 초 내에 할 수 있다.

하여간 도메인 레이어의 핵심은 다른 레이어(Presentation의 UIKit/SwiftUI나 Data 레이어의 Mapping Codable)의 어떤 것도 포함시키면 안된다.

Presentation layer

UI(UIViewController / SwiftUI View)를 포함한다. 각 View는 하나 이상의 Use Case를 실행시키는 ViewModel(Presenters)와 대응된다. 즉 한 view에 대응되는 하나의 viewModel이 있다.

위의 과녁 모양 그래프에서도 봤듯이, 도메인 레이어에만 의존하고 있다.

Data layer

Repository Implementation, 그리고 하나 이상의 Data Source를 포함한다. 위에서도 언급했지만 Repository Interface는 도메인 레이어에 속했다.

Data Source는 원격이나 로컬일 수 있다. 원격일 경우는 API 호출을 통해 Json 데이터를 내려 받는 경우가 되고, 로컬일 때는 데이터베이스일 것이다. Data Layer는 오로지 도메인 레이어에만 의존하고 있다. 여기서 헷갈리지 말아야 할게, 과녁 모양 그래프에서는 안쪽의 Presenters, 바깥에 DB, API, UI 등등이 있었는데 이 Presenters가 Presentation layer에 대응되고 가장 바깥의 영역이 Data 레이어에 대응되는 것은 아니다. 헷갈리면 위의 과녁 그래프에서 빨간색으로 그린 원들을 보자.

Data layer에는 네트워크로 받은 JSON 데이터를 도메인 모델로 매핑하는 것을 포함시킬 수 있다.

Data Flow

image

위 그림에서는 Dependency DirectionData flow-Request/Response로 각 레이어를 표현하고 있다. 특히 Repository interface(protocol) 에서 DI, 의존성 역전이 일어나는 것을 볼 수 있다. 의존성 역전을 아주 잠깐 훑어보자면 아래의 코드로 설명할 수 있다.

Coffee coffee = new Coffee; //dependency
Programmer programmer = new Programmer(coffee); // 의존성 주입. 밖에서 의존성 생성 후 주입했으므로 의존성이 역전됨

원래 Programmer 클래스 안에서 new로 생성한 Coffee 객체를 외부에서 생성해 주입한 것이다. 이처럼 원래는 Domain Use Case에서는 Data Repository에 의존하고 있으면 안되는데 Data Repository에 request와 response를 받아야 한다. 이 상황에서 Data Repository를 사용하기 위해 의존성 역전을 통해 의존성을 없애면서 Domain에서 Data Reposiotry를 사용할 수 있게 한 것이다.

Data Flow

  1. View(UI)ViewModel(Presenter)의 메서드를 호출한다.
  2. ViewModelUseCase를 실행시킨다.
  3. Use CaseUserRepositories에서 데이터를 취합한다.
  4. RepositoryRemote Data(NW), Persistent DB저장소나 메모리 데이터를 반환한다.
  5. 반환된 데이터가 우리가 아이템들을 화면에 출력할 View에 전달된다.

Dependency Direction

Presentation 레이어 ➡ Domain 레이어 ⬅️ Data Repository 레이어

  • Presentation layer(MVVM) = ViewModels(Presenters) + Views(UI)
  • Domain layer = Entities + Use Cases + Repositories Interfaces
  • Data Repositories layer = Repositories Implementations + API(NW) + Persistence DB

Example Project : “Movies App”

image

도메인 레이어

위에 링크로 달아놓은 예시 프로젝트에서 도메인 레이어를 찾을 수 있다. 엔티티와 영화를 찾고 최근 쿼리를 저장하는 UseCase를 가지고 있다. 또한 DI(Dependency Inversion)에 필요한 Data Repositories Interface를 가지고 있다.

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 Case들을 생성하는 또 다른 방법은 UseCase 프로토콜의 start() 함수를 이용하고 모든 use case들이 이 프로토콜을 구현하도록 하면 된다. 예제 프로젝트에서 이 방법을 사용하는 유즈 케이스 중 하나는 FetchRecentMovieQueriesUseCase이다. Use Case는 Interactor라고도 불린다.

Presentation layer

MoviesListViewModel이 있고, 이 안의 아이템들은 MoviesListView에서 출력할 것이다. MoviesListViewModel은 UIKit을 포함하지 않는다. 앞에서도 간단히 언급했지만, ViewModel을 UIKit, SwiftUI, WatchKit과 같은 UI 프레임워크와 분리시켜 놓으면 재사용하고 리팩토링 하기 쉽다. ViewModel은 UIKit이나 SwiftUI와 별개이기 때문에 리팩토링이 더 쉬워질 것이다.

// 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을 만들어서 MoviesListViewController를 mocking을 통해 테스트하기 쉽게 만들었다. 또한, MoviesListViewModelActions 클로저를 갖고 있고, 이 클로저는 MoviesSearchFlowCoordinator에게 다른 뷰들을 언제 출력할지를 알려준다. action 클로저는 coordinator가 영화 디테일 화면을 출력할 때 호출된다. 여기서 action들의 그룹을 구조체로 묶어서 주는데 이는 이후에 더 많은 action들이 필요하게 될 경우 쉽게 추가하기 위해서이다.

Presentation layer는 또한 MoviesListViewModel의 데이터를 바인딩하는 MoviesListViewController를 포함하고 있다.

UI는 비즈니스 로직이나 앱 로직에 접근할 수 없고, ViewModel만이 여기에 접근할 수 있다. 이게 관심사의 분리를 적용한 것이다. 비즈니스 모델을 바로 View에 전달할 수 없다. 이게 ViewModel에서 비즈니스 모델을 ViewModel로 맵핑해서 View에 전달하는 이유이다.

또한 View에서 ViewModel에게 영화를 찾는 것을 시작하라는 호출을 추가했다.

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

여기서는 아이템을 관찰해서 이게 바뀔 때 뷰를 다시 로드한다. 간단한 Observable 을 사용하며, 코드는 예제 프로젝트에서 확인할 수 있다.

위에서 MoviesListViewModel에 action을 struct로 전달했는데, 이 구조체의 action에 showMovieDetails(movie:)를 할당하는 것은 MoviesSearchFlowCoordinator에서 했다. 즉 MoviesSearchFlowCoordinator에서 함수들을 담은 action을 정의하고, 이를 ViewModel에게 넘겨줌으로써 ViewModel이 MoviesSearchFlowCoordinator에 상세히 정의되어 있는 함수들을 담은 action을 실행할 수 있게 되는 것이다. 사실 이 부분은 플로우를 이해하려면 예제 프로젝트를 보는 것이 가장 도움이 된다. 플로우 상 다른 여러개의 클래스가 관여하고 있기 때문에, 프로젝트를 직접 다운받아서 MovieViewModel이 생성될 때 어떻게 action을 주입해서 생성하는지, 여기에 FlowCoordinator가 어떻게 개입하는 지 확인하는 게 도움이 될 것이다.

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

Presentation logic, 그리고 뷰 컨트롤러의 사이즈와 책임을 줄이기 위해 Flow Coordinator를 사용했다. Flow에 강한 참조를 해서 필요한 한 Flow가 해제되지 않도록 설정한다.

이 방법을 통해, 수정 없이 같은 ViewModel을 여러 다른 뷰에 사용할 수 있다. iOS 13.0인지만 확인하고 UIKit대신에 SwiftUI View를 만든 다음 같은 뷰 모델을 적용할 수 있다. 예제 프로젝트에서는 MoviesQueriesSuggestionList의 예제가 있으니 확인하면 좋을 것 같다.

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

데이터 레이어는 DefaultMoviesRepository를 포함한다. 도메인 레이어에 정의된 인터페이스를 따르고(Dependency Inversion), 여기에서는 JSON 데이터와 CoreData Entity들을 도메인 모델로 맵핑한다.

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

Data Transfer Objects인 DTO는 JSON response를 도메인으로 맵핑하기 위해 사용되는 중간 객체이다. 만약 endpoint response를 캐싱하고 싶다면 DTO를 이를 Persisten 객체로 맵핑해서 persistent 저장소에 저장할 수 있다.

위의 코드를 보면 Data 레이어에서 DTO라는 껍데기를 이용해서 데이터를 받고, 이를 도메인(model, entity)로 변환하고 있다. 근데 DTO를 따로 두는 이유는 무엇일까? 사실 복잡하지 않은 entity(model)을 가지는 경우 api를 호출해서든 로컬 데이터베이스에서 가져온 데이터를 그대로 사용하면 되고, 이는 DTO를 통해서 받은 데이터를 그대로 entity로 변환하면 되기 때문에 DTO의 존재 이유가 없다고 생각할 수도 있다. 하지만 model 객체(구조체 등등)을 이용해서 직접 데이터 레이어에서 데이터를 받지 않고 DTO를 두는 이유는 아래와 같다.

  1. Model(Entity)를 어디에도 종속적이지 않게 만들 수 있다.
  2. 데이터 layer를 통해 받는 데이터 응답이 달라질때마다 DTO만 수정하면 되고, model은 수정할 필요가 없다.

1번의 경우, 예를 들어 데이터를 api 호출을 통해 json으로 받고 이를 객체로 변환하기 위해서 이 객체는 Codable이라는 프로토콜을 따르고 있어야 한다. 하지만 이 Codable이라는 프로토콜은 데이터를 다운받는다는 것 떄문에 생겨난 것이지, model, 도메인 개념 자체에 집중하면 있을 필요가 없는 개념이다. DTO와 엔티티를 분리시켜 놓으면 그 어디에도 종속적이지 않게, 엔티티를 모든 레이어를 가로지르는 핵심 모델로 만들 수 있다.

2번의 경우, 이것도 마찬가지로 api 호출을 통해 json으로 데이터를 받는다고 가정해보자. 만약 DTO를 사용하고 있지 않은데 서버에서 내려주는 데이터의 형식이 바뀌게 되었다면 model 자체를 수정해야 한다. 먼저, 이런식으로 변경이 일어날때마다 코드로 수정하는 부분이 생기게 되는 것은 좋지 않다. 그리고 이 model은 도메인 개념을 나타내는 개념인데, 데이터 레이어(도메인 개념에서 벗어난 영역)의 변화가 도메인 영역에도 영향을 끼친다는 것은 좋은 구조가 아니다. 도메인 개념을 모델링한 도메인 모델은 도메인 개념에 변화가 생겼거나, 도메인 모델을 구체화한 도메인 객체에서 도메인 개념이 모호한 것을 명확하게 할 필요가 있을 때 바뀔 수 있는데, 위 경우 이런 상황에 해당하지 않는다.

다시 본론으로 돌아가자면, 일반적으로 Data Repository들은 API Data service와 Persistent Data Storage 를 포함한다. Data Repository는 데이터를 리턴하기 위해 이 두 의존성을 갖는다. 룰은 처음에 캐싱된 데이터 결과가 있는지를 persistent 저장소에서 찾는 것이다.(NSManagedObject는 DTO 객체를 통해 도메인으로 매핑되고, chaced data closure에서 받을 수 있다.) 그리고 가장 최근의 업데이트된 데이터를 리턴하는 API Data Service를 호출한다. Persistent Storage는 이 최근의 데이터로 업데이트 된다. 그리고 DTO는 도메인으로 맵핑되며 updated data/completion closure에서 받을 수 있다. 이 방법으로 유저는 데이터를 즉각적으로 볼 수 있다. 인터넷 커넥션이 없다 해도, 사용자들은 persistent 저장소에서 최신의 데이터를 볼 수 있을 것이다.

저장소와 API는 완전히 다르게 구현할 수 있다. 다른 레이어는 이 변화에 영향을 받지 않게 된다.

Infrastructure layer(Network)

네트워크 프레임워크를 둘러싼 wrapper 로, Alamofire나 다른 프레임워크가 될 수 있다. Base Url과 같은 네트워크 파라미터로 구성될 수 있다. 또한 endpoint를 정의하고 데이터 맵핑 메서드를 포함하고 있다.

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와 도메인의 관심사 분리를 깔끔하게 할 수 있게 도와준다.

클린 아키텍처와 같이 사용하면 Presentation과 UI Layer 사이의 관심사 분리를 돕는다.

위에서, ViewModel에서는 UI 프레임워크를 포함시키지 않아 viewd에서 분리시켰고, 이 때문에 같은 ViewModel을 사용하면서 다양한 view를 구현할 수 있다고 했다. 예를 들어, 하나의 CarViewModel을 CarAroundListView와 CarAroundMapView에 모두 사용할 수 있다. 또한 한 뷰는 UIKit으로 만들고 다른 뷰는 SwiftUI로 만들 수도 있다. ViewModel에서 UIKit, WatchKit, SwiftUI를 import하지 않는 것이 중요하다. 이 방법으로 필요할때마다 다른 플랫폼에서 쉽게 재사용할 수 있다.

image

View와 ViewModel 사이의 데이터 바인딩은 클로저, delegate나 observables로 할 수 있다. Combine과 SwiftUI도 사용할 수 있지만 지원하는 iOS가 13버전 이상이어야 한다. View는 ViewModel에 직접적인 관계를 가지고 있으며 View에서 이벤트가 발생했을 때 ViewModel에 알린다. ViewModel에서는 View를 직접 참조하지 않다.

클로저와 didSet을 같이 사용해서 third-party 의존성을 없애는 것을 해보자.

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을 굉장히 간단화한 버전이다. UI를 포함한 Presentation Layer에 의해 observe가 실행되므로 observer의 block을 메인 쓰레드에서 실행시킨다.

아래는 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 } // observable 객체의 값이 변경되면 이 클로저를 실행시킬겁니다
    }
    
    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 {}

Observing block에서 viewModel에 접근하면 리테인 사이클로 인해 memory leak이 발생하므로, viewModel에 접근할 때는 오로지 self를 이용해서 self?.viewModel로 접근해야 한다.

아래는 TableViewCell에서 데이터 바인딩을 하는 것이다.

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

만약 view(UITableViewCell)가 재사용이 가능하다면 unbind를 해줘야 한다.

MVVM 템플릿은 여기에서 더 확인할 수 있다.

MVVMs Communication

1. Delegation

한 MVVM(screen)의 ViewModel은 delegation 패턴을 사용해서 다른 MVVM(screen)의 ViewModel과 소통한다.

image

예를 들어, ItemsListViewModel과 ItemEditViewModel이 있다. 그리고 ItemEditViewModelDidEditItem(item) 메서드를 가지는 ItemEditViewModelDelegate 프로토콜을 생성했다. 그리고 extension ListItemsiewModel: ItemEditViewModelDelegate로 이 프로토콜을 따르게 한다.

// Step 1: Delegate를 선언하고 첫번째 ViewModel을 weak로 할당한다.
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:  두번째 viewModel이 이 delegate 프토토콜을 구현하게 한다.
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
    func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
        update(movieQuery: movieQuery)
    }
}

이 경우 Delegate들을 Responder로 이름지을 수 있다. (ItemEditViewModelResponder 이렇게)

2. Closure

소통하는 다른 방법은 할당되거나 FlowCoordinator에 의해 주입받은 클로저를 사용하는 것이다. 예제 프로젝트에서 이 방법을 사용했다. 위에 언급했었던 MoviesSearchFlowCoordinator가 기억날 것이다. 예제 프로젝트에서는 MoviesListViewModel이 MoviesQueriesSuggestionsView를 출력하기 위해 action 클로저 showMovieQueriesSuggestions를 사용했다. 또한 (_ didSelect:MovieQuery) -> Void 파라미터를 전달해서 해당 View에서 다시 호출될 수 있게 했다. 이 communication은 MoviesSearchFlowCoordinator 안에서 설정되었다.

// MoviesQueryList.swift
// Step 1: 다른 ViewModel과 communicate할 action 클로저를 정의한다. 여기서는 query가 선택되었을 때 MovieList를 알린다.
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void

// Step 2: 필요할 때 action closure를 호출한다.
class MoviesQueryListViewModel {
    init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
        self.didSelect = didSelect
    }
    func didSelect(item: MoviesQueryListItemViewModel) {
        didSelect?(MovieQuery(query: item.query))
    }
}

// MoviesQueryList.swift
// Step 3: MoviesQueryListView를 출력할때, 파라미터 (_ didSelect: MovieQuery) -> Void의 타입인 action closure를 전달한다.
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: FlowCoordinator 안에서 self function으로 action closure를 주입함으로써 두 viewModel 간의 communication을 설정한다.
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)
    }
}

프레임워크로 레이어 분리(모듈)

각 레이어(Domain, Presentation, UI, Data, Infrastructure Network)는 별도의 프레임워크로 쉽게 분리될 수 있다.

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

그리고 이 프레임워크를 CocoaPods를 잉ㅇ해서 메인 앱에 포함시킬 수 있다.

권한 이슈 때문에 ExampleMVVM.xcworkspace를 삭제하고, pod install를 실행해서 새롭게 다운받아야 할 것이다.

image

Dependency Injection Container

의존성 주입은 한 객체가 다른 객체의 의존성을 제공하는 기술이다. 앱의 DIContainer는 모든 주임을 해주는 클래스이다. 각 클래스의 구성은 아래와 같다.

image

객체를 생성하고 주입을 통해 객체간 관계를 설정하는 책임은 모두 이 컨테이너가 가진다. 이러면 클라이언트는 자신이 사용할 객체를 이 컨테이너를 통해 의존성 주입을 받는다. 따라서 클라이언트에서는 이 컨테이너가 주는 객체가 어떤 구체적인 구현 클래스에서 생성이 되었는지 알 필요가 없고, 정의된 인터페이스를 통해 동작하기만 하면 된다. 이는 객체간의 관계가 같은 인터페이스로 구현된 mock이나 다른 클래스의 객체로 대체될 수 있으므로, 테스트하고 변경하기 쉬워짐을 의미한다.

Using dependencies factory protocols

한 가지 방법은 의존성 생성을 DIContainer에 위임하는 의존성 프로토콜을 선언하는 것이다. 이걸 하기 위해서는 MoviesSearchFlowCoordinatorDependencies 프로토콜을 정의해야 하고 MoviesSceneDIContainer가 이 프로토콜을 따르게 해야 한다. 그리고 MoviesListViewController를 생성하고 출력하기 위해 이 주입이 필요한 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)
    }
}

Using closures

다른 옵션은 클로저를 사용하는 것이다. 이를 위해 주입이 필요한 클래스 안에서 클로저를 선언하고, 클로저를 주입한다.

// MoviesListViewController를 반환하는 makeMoviesListViewController 클로저를 정의한다.
class MoviesSearchFlowCoordinator {
   
    private var makeMoviesListViewController: () -> MoviesListViewController

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

// MoviesSceneDiContainer의 `self`.makeMoviesListViewController 함수를 필요한 곳에 주입한다.
final class MoviesSceneDIContainer {
    ...
    // MARK: - Flow Coordinators
    func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
        return MoviesSearchFlowCoordinator(navigationController: navigationController,
                                           makeMoviesListViewController: self.makeMoviesListViewController)
    }
    
    // MARK: - Movies List
    func makeMoviesListViewController() -> MoviesListViewController {
        ...
    }
}

출처

  • https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3
  • https://github.com/kudoleh/iOS-Clean-Architecture-MVVM
  • https://develogs.tistory.com/7