SwiftUI 특성상 ViewModel을 분리하는게 매우 쉽다. @ObservedObject 짱짱!
덕분에 실습을 진행하면서 대부분의 프로젝트에서 ViewModel로 분리는 했지만 문제가 많았다. Massive ViewModel이랄까?
그래서 해커톤 때는 이런 것들을 걷어내고 아키텍쳐적으로 조금 더 괜찮은 앱을 구현하고 싶었다.
다행히도 팀원 중 한 분이 클린 아키텍쳐에 대해 잘 알고 계셔서 도움을 많이 받았고, 전보다는 어느정도 완성된 앱을 만들 수 있었다.
서론이 길었지만 아무튼 해커톤을 계기로 Clean Architecure에 대해 다시 공부하고 싶어서 전에 북마크해둔 글을 보면서 개념을 정리해보는 시간을 가졌다.
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
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
Clean Architecture
우선 이런 형태를 가진다.
의존 관계는 안쪽으로 향하게끔 설계를 하고, 안쪽에 있는 요소들은 바깥쪽에 있는 요소의 존재를 모른다.
예를 들어, Presenters에 해당하는 ViewModel은 Domain에 해당하는 Usecase에 의존적이지만, (생성자를 통해 전달)
Domain은 ViewModel에 대해 알 필요가 없다.
종속성 방향
- Presentation Layer → Domain Layer ← Data Layer
- Presentation Layer는 Domain Layer에 의존
- Domain Layer가 Data Layer에 의존 → 추상화된 Repository에 의존하므로 의존성 역전
데이터 흐름
- View는 ViewModel에서 메서드를 호출한다.
- ViewModel은 UseCase를 실행한다.
- UseCase는 User와 Repository의 데이터를 결합한다.
- Repository는 DB, Network와 연결되어 데이터를 반환한다.
- 이렇게 한 쪽 방향으로 흐른 플로우는 다시 반대로 흘러간다. (단방향, 콜백)
Domain Layer
- Entity, Usecase
- Entity : 우리가 지금까지 Model로 작성한 것들
- Usecase: 소프트웨어 공학에서 기능 단위로 설명하는데 거의 비슷하다고 볼 수 있음. 위 사진에서 볼 수 있듯, 영화 검색(Movie Search) 유스케이스, 최신 영화 목록?(Fetch Recent Movie Queries) 유스케이스처럼 기능에 대한 단위
안드로이드 공식 문서이지만, 아키텍쳐적인 관점에서는 iOS와 동일하므로 첨부했다.
Domain Layer에 대한 특징이 나와있다.
😑 근데 유스케이스를 꼭 써야하나요?
→ Clean Architecture를 접하기 전까지만 하더라도 나도 같은 생각이였다. 여러 글을 참고하면서 내린 결론은 인터페이스적인 개념이 적용됐다는 것이다. 물론 이렇게 나누지 않고, Presentation가 Repository를 직접 참조해도 상관은 없다고 생각한다.
그렇다면 어떤 장점이 있기에, 이렇게 분리를 한 것일까?
일단, 직관적이다. ViewModel만 딱 봤을 때, 기능 단위로 분류해놓은 Usecase를 보고 바로 이해할 수 있게 된다.
다양한 Repository Layer를 참조하지 않고, 기능에 대한 최소 단위인 Usecase 참조하고 있으므로 ViewModel에서 어떤 기능(서비스)를 제공하고 있는지 명확하게 알 수 있다.
또한 기능이 확장됨에 따라 하나의 Usecase에서 다양한 Repository를 참조하는 경우가 발생할 것이다. 하지만 ViewModel은? Usecase가 어떤 Repository와 연결되어 있는지 알 필요가 없다. 그냥 내가 구현하려는 기능에 해당하는 Usecase를 바라보면 된다.
가령, 특정 기능에 대한 내부적인 로직이 조금 변경될 필요가 있다고 가정해보자. Usecase가 아닌, Repository 를 직접 참조하게 된다면 Repository 수정에 따른 ViewModel의 수정이 불가피할 것이다. 그런데 Usecase를 참조한다면? VIewModel은 그냥 가만히 있어도 된다. 비즈니스 로직을 담고 있는 Usecase 내에서 수정이 일어나기 때문이다.
설명이 길었지만 결론은 다른 Layer들이 어떤 일을 하고 있는지 알 필요가 전혀 없다는 것이다.
직접적으로 맞닿아있는 안쪽 Layer만 알고 있으면 된다.
내 윗집, 옆집에 누가 살고 있는지만 알면 된다는 점
결론,
- 유지보수 용이
- 직관적인 요구사항
- 의존성 최소화
Presentation Layer
- ViewModel에는 import UIkit, SwiftUI가 없어야 한다. (모든 UI 프레임워크와의 단절)
- 이렇게 구현하면 UIKit → SwiftUI 전환이 매우 용이해진다. 다른 부분은 신경쓰지 않고 UI만 변경해주면 되기 때문이다.
1. ViewModel
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 { }
- 프로토콜 먼저 선언해주고, 프로토콜을 채택하여 정의한다.
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: "")
}
}
}
}
- 생성자로 Domain Layer(Usecase)의 인스턴스를 전달받는다.
// 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 ?? ""
}
}
- 또한 Model을 직접적으로 참조하면 안된다. View가 직접적으로 접근하면 안되기 때문이다. (처음에 그림을 생각할 것)
2. ViewController
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)
}
}
- UI는 비즈니스 로직이나 어플리케이션 로직에 직접적으로 접근할 수 없고, 항상 ViewModel에만 접근할 수 있다.
- 아까 말했던 것처럼 ViewModel에 Entity에 해당하는 프로퍼티(items)를 별도로 둬서 View에서 Entity에 대해 알 필요가 없게 만들었다.
- 또한 View에서 ViewModel을 Observing하는 형태로 구현한다.
- weak self를 통해 순환 참조를 방지한다.
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)
}
}
- View와 ViewModel 사이에 Coordinator를 추가해서 ViewModel을 수정하지 않고 다른 뷰를 쉽게 사용할 수 있게 만들어 준다.
- 또한 ViewController에 대한 책임을 줄여주는 역할을 한다.
- Delegate랑 비슷한듯? Coordinator는 좀 더 알아봐야겠다.
Data Layer
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() })
}
}
...
- Usecase가 의존하고 있는 Repository가 있다.
- DTO(Data Transfer Object)가 정의되어 있다.
- DB, Network로 데이터를 가져오고, Domain에 매핑된다. (completion으로 전달, Usecase를 거쳐 최종적으로 ViewModel까지)
개념적으로는 이해를 했지만 실제로 써보면서 요놈들이랑 더 친해져야겠다.