https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3
클린아키텍처에 대해 공부해보려한다. 모든 소스코드와 내용은 상단 글의 번역과 정리를 토대로 한 내용입니다.
클린아키텍처(Clean Architecture)?
소프트웨어를 개발할 때 디자인 패턴 뿐 아니라 아키텍처 패턴도 사용하는 것이 중요하다.
소프트웨어 엔지니어링에는 다양한 아키텍처 패턴이 있다 모바일 소프트웨어 엔지니어링에서 가장 많이 사용되는 패턴은 MVVM, Clean Architectrue및 Redux이다.
여기에선 MVVM 및 Clean Architecture패턴이 iOS앱에 적용되는 방법을 실제 예제와 프로젝트를 통해 공부해본다.
개념을 참고할 GitHub 이다.
위의 그래프를 보면 여러 레이어로 나뉘어져 있다. 여기서 핵심은 내부레이어가 외부레이어를 몰라야한다는 점이다. 즉 내부레이어는 외부레이어에 종속되면 안된다. 예를 들자면 외부 레이어에 해당하는 객체를 안쪽 레이어에서 생성하면 안된다는 것이다. 또한 바깥쪽에서 안쪽으로 향하는 화살표는 Dependency rule이다. 바깥쪽에서 안쪽으로만 종속성이 가능하다.
여기서 모든 레이어를 그룹화한 결과는 3가지 Presentation, Domain, Data레이어로 또 나뉜다.
Presentation, Domain, Data
Domain Layer(Business logic)
그래프의 가장 안쪽에 위치하며 다른 레이어의 종속성이 없으므로 완전히 격리되어있는 상태이다.
Entity(비즈니스 모델), Use Case 및 Repository Interface 를 포함한다. 이 레이어는 다른 프로젝트에서도 재사용될 수 있다. 이렇게 레이어가 분리되어 다른 의존성이나 3rd party가 필요하지 않기 때문에 앱을 테스트할 때 환경에 구애받지 않는다.
따라서 도메인 Use Case 테스트는 몇 초 내에 실행할 수 있다.
도메인 레이어는 다른 레이어(Presentation의 SwiftUI/UIKit)등을 포함하면 안된다.
Presentation Layer
UI(UIViewController 또는 SwiftUI Views)가 포함된다. View는 하나 이상의 Use Case를 실행하는 ViewModel(Presenter)에 의해 조정되며 하나 이상의 Use Case를 실행한다. PresentaionLayer는 도메인 레이어에만 의존한다.
Data Layer
Repository Implementation(저장소 구현)과 하나 이상의 데이터 소스가 포함된다. 위에서 언급했지만 Repository Interface는 도메인 레이어에 속한다.
DataSource는 원격 또는 로컬일 수 있다. 원격일 경우는 API호출을 통해 Json데이터를 내려받는 경우가 되고, 로컬일 때는 내부 데이터 베이스일 것이다. DataLayer는 도메인 레이어에만 의존한다. 이 레이어에서는 네트워크로 받은 Json데이터를 도메인 모델로 매핑하는 것을 포함할 수 있다.
이 그림에서 Dependency Direction과 Data flow - Request/Response로 각 레이어를 표시하고 있다.
Repository Interface(프로토콜)을 사용하는 지점에서 DI/의존성 역전이 일어나는 것을 볼 수 있다.
그에 대한 데이터 흐름 예제는 아래와 같다.
DataFlow(데이터 흐름)
1. View(UI)가 ViewModel(Presenter)의 메서드를 호출한다.
2. ViewModel이 UseCase를 실행시킨다.
3. Use Case가 User와 Repositories에서 데이터를 가져온다.
4. 각 Repository는 Remote Data(Network), Persitent DB 저장소, In-memory Data(원격 또는 Cached)에서 데이터를 반환한다.
5. 반환된 데이터가 우리가 아이템들을 화면에 출력할 View에 전달한다.
Dependency Direction(종속성 방향)
Presentation 레이어 ➡️ Domain 레이어 ⬅️ Data Repository 레리어
Presentation 레이어(MVVM) = ViewModel(Presenters) + Views(UI)
Domain 레이어 = Entities + Use Cases + Repositories Interface(프로토콜)
Data Repositories 레이어 = Repositories Implementations(구현) + API(Network) + Persitence DB
Example Project: "Movies App"
도메인 레이어
최상단 링크로 설정해둔 GitHub프로젝트를 보면 도메인 레이어를 찾을 수 있다.
이 레이어에는 Entity와 영화 검색 및 최근 검색 쿼리를 저장하는 UseCase를 가지고 있다. 또한 DI(Dependency Inversion, 의존성 역전)에 필요한 Data Repository 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들이 이 프로토콜을 구현하도록 하면 된다. 예제 프로젝트에서 이 방법을 사용하는 Use Case중 하나는 FetchRecentMovieQueriesUseCase이다. Use Case는 Interactor라고도 불린다.
Presentation Layer
MovieListViewModel이 있고, 이 안의 아이템들은 MoviewListView에서 출력할 것이다.
MoviewListViewModel은 UIKit을 포함하지 않는다. ViewModel을 UIKit, SwiftUI, WatchKit과 같은 UI프레임워크와 분리시켜 놓으면 재사용하고 리팩토링 하기 쉽다. ViewModel은 UIKit이나 SwiftUI와 별개이기 때문에 리팩토링이 더 쉬워질 것이다.
// 주의: UIKit이나 SwiftUI와 같은 UI 프레임워크를 import할 수 없습니다.
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: 만약 Details 화면에서 영화를 수정하고 이를 업데이트한 후
// MoviesList 화면에서 업데이트 된 영화를 보려면 이 클로저가 필요합니다:
// 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):
// 주의: 여기서는 Domain Entities를 Item View Models로 매핑해야 합니다. Domain과 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])
}
}
// 주의: 이 아이템 뷰 모델은 데이터를 표시하기 위한 것이며, 뷰가 이를 액세스하지 않도록 도메인 모델을 포함하지 않습니다.
struct MoviesListItemViewModel: Equatable {
let title: String
}
extension MoviesListItemViewModel {
init(movie: Movie) {
self.title = movie.title ?? ""
}
}
이 예제는 MoviesListViewModelInput과 MoviesListViewModelOutput을 만들어서 MoviewsListViewController를 mocking을 통해 테스트 하기 쉽게 만들었다. 또한, MoviesListViewModelActions클로저를 갖고있고, 이 클로저는 MoviesSearchFlowCoordinator에게 다른 뷰들을 언제 출력할지 알려준다. action클로저는 coordinator가 영화 디테일 화면을 출력할 때 호출된다. 여기서 action들의 그룹을 구조체로 묶어서 주는데 이는 이후에 더 많은 action들이 필요하게 될 경우 쉽게 추가하기 위해서이다.
Presentation layer는 또한 MoviesListViewModel의 데이터를 바인딩하는 MoviewsListViewController를 포함하고 있다.
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을 사용한다.
위에서 MoviewListViewModel에 Action을 struct로 전달 했는데, 이 구조체의 showMoviewDetail(movie:)를 할당하는 것은 MoviesSearchFlowCoordinator에서 했다. 즉 MoviesSearchFlowCoordinator에서 함수들을 담은 action을 정의하고 이를 ViewModel에게 넘겨줌으로써 ViewModel이 MoviesSearchFlowCoordinator에 상세히 정의되어 있는 함수들을 담은 action을 실행할 수 있게 되는것이다. 사실 이 부분은 플로우를 이해하려면 예제 프로젝트를 보는 것이 가장 도움이 된다. 플로우 상 다른 여러개의 클래스가 관여하고 있기 때문에 프로젝트를 직접 다운받아서 MoviewViewModel이 생성될 때 어떻게 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, 그리고 뷰 컨트롤러의 사이즈와 책임을 줄이기 위해 FlowCoordinator를 사용했다. Flow에 강한 참조를 필요해서 필요한 한 Flow가 해제되지 않도록 설정한다. 이 방법을 통해 수정 없이 같은 ViewModel을 여러 다른 뷰에서 사용할 수 있다. iOS 13.0인지만 확인하고 UiKit대신에 SwiftUI View를 만든 다음 다음 같은 뷰 모델을 적용할 수 있다. 예제 프로젝트에서는 MoviesQueriesSuggestionList의 예제가 있으니 확인하면 좋을듯하다.
Entity: Actor가 필요로 하는 데이터 모델을 의미한다. 특정 도메인 에서 사용되는 struct모델