iOS(UIKit)에서의 CleanArchitecture+MVVM 예시 뜯어보기
MVVM in iOS
MVVM in WPF (MVVM의 역사) MVVM은 WPF (Windows Presentation Foundation) 및 Silverlight의 기능을 활용하여 이벤트 중심 프로그래밍을 간소화하기 위해 Microsoft 아키텍처 Ken Cooper 및 Ted Peters가 개발했다. XAML (Extensib
jife98.tistory.com
이전 포스팅 에서, MVVM에 대한 개념과 iOS(UI Kit)에서의 MVVM에 대해 공부하고 알아보았다.
그래서, 어떻게 폴더구조와 파일들을 배치해야할까...?
여러 예제들마다, 제 각각이나, 깃헙에 스타도 많고 상세한 설명과 테스트 구조도 나와있는 예제파일을 토대로 MVVM에 대한 폴더구조와 파일들을 분석할려한다.
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
폴더구조
지구처럼 생겼네요. 우리는 내핵부터 파고 들어가 봅시다.
- 소스 코드의 의존성은 안쪽을 향하게끔 설계
- 안쪽으로 갈수록 잘 변하지 않는 요소들이기 때문
- 안쪽의 원은 바깥쪽의 원을 모르는 상태
- 바깥쪽의 원은 어떠한 것도 안쪽의 원에 영향을 주지 않는 구조
- 아키텍처 설계의 가정: 사용자(Actor)의 요구사항은 변경이 많이 없고 내부적으로 Web이나 DB, UI가 자주 바뀐다고 가정하여 설계
DomainLayer
- Entities + Use Cases + Repositories Interfaces
Entities
- DB에서 사용하는 테이블과 비슷한 역할을 한다.
- 서버에서 받아오는 원본 데이터(Json)
- Model에 가장 가깝다.
struct Movie: Equatable, Identifiable {
typealias Identifier = String
enum Genre {
case adventure
case scienceFiction
}
let id: Identifier
let title: String?
let genre: Genre?
let posterPath: String?
let overview: String?
let releaseDate: Date?
}
struct MoviesPage: Equatable {
let page: Int
let totalPages: Int
let movies: [Movie]
}
Use Cases
- Actor가 Entity를 원하는데, 이 값은 계산되거나 특정 로직에 의해서 얻어지므로 Actor가 원하는 Entity를 얻어내고 있는 '로직'
- Domain model을 검색하고 저장하기 위해 UseCase를 이용
- 그렇다면 굳이 필요할까?
- ViewModel에서 데이터 로직이 "Use Case"로 분리된다면, 더 가벼워지며, Repository등에 대한 의존성이 줄어든다.
- 아래는 Actor가 원하는 Entity인 MoviesPage를 얻기 위해서 필요한 '로직'
protocol SearchMoviesUseCase {
func execute(
requestValue: SearchMoviesUseCaseRequestValue,
cached: @escaping (MoviesPage) -> Void,
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,
cached: @escaping (MoviesPage) -> Void,
completion: @escaping (Result<MoviesPage, Error>) -> Void
) -> Cancellable? {
return moviesRepository.fetchMoviesList(
query: requestValue.query,
page: requestValue.page,
cached: cached,
completion: { result in
if case .success = result {
self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
}
completion(result)
})
}
}
struct SearchMoviesUseCaseRequestValue {
let query: MovieQuery
let page: Int
}
Repositories Interfaces
- Repositories 의 프로토콜
- Domain -> Data의 의존성을 느슨하게 하기 위해 protocol 형식으로 변경에 확장성을 용이하게 한 것을 의미
protocol MoviesRepository {
@discardableResult
func fetchMoviesList(
query: MovieQuery,
page: Int,
cached: @escaping (MoviesPage) -> Void,
completion: @escaping (Result<MoviesPage, Error>) -> Void
) -> Cancellable?
}
DataLayer
- DB, Repositories, DTO
- Domain 계층에서 만들어 놓은 Repositories Interfaces 구현체들이 존재, Local DB와 Remote 데이터를 가져오기 위한 장치들 존재
- ViewModel의 Input을 호출하고, ViewModel 내부에서 Use case를 실행하며 또 이 내부의 필요에 따라 Data Layer에 접근
Repositories
- UseCase에 의존
- Domain 과 Model 데이터 매핑 사이에 중개자
- 작업 도메인과 데이터 할당 또는 매핑 간의 종속성을 한 방향으로 명확하게 분리하고자 할 때, 사용
- 데이터 로직 분리 가능
- 단위 테스트를 통한 검증 가능
// **Note**: DTOs structs are mapped into Domains here, and Repository protocols does not contain DTOs
import Foundation
final class DefaultMoviesRepository {
private let dataTransferService: DataTransferService
private let cache: MoviesResponseStorage
private let backgroundQueue: DataTransferDispatchQueue
init(
dataTransferService: DataTransferService,
cache: MoviesResponseStorage,
backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated)
) {
self.dataTransferService = dataTransferService
self.cache = cache
self.backgroundQueue = backgroundQueue
}
}
extension DefaultMoviesRepository: MoviesRepository {
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) { [weak self, backgroundQueue] 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,
on: backgroundQueue
) { 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
}
}
DTO
- JSON의 response를 Entity로 변환
- Data 하위에 존재
- API로 부터 받은 response는 그대로 받고, response를 completion handler에서 domain 모델로 변환하는 식으로 사용
- 받은 response는 그대로 표출 > API의 response 모델을 알아보기 용이
- 사용하는 쪽에서 domain 모델을 사용하도록 변환 (Repositories)
struct MoviesRequestDTO: Encodable {
let query: String
let page: Int
}
PresentationLayer
- Entity 데이터를 그대로 표현하는데 필요한 계층
- Coordinator, View, ViewModel, Behaviors(특정 View의 event에 관해 적용되는 UI)
Coordinator
- ViewModel간 의존관계를 Coordinator로 끊은 상태 > ViewModel이 서로 의존하고 있지 않으므로 특정 ViewModel을 수정하면 Coordinator만 변경하면 되므로 상대적으로 자유롭게 ViewModel을 갈아 끼우기 쉬운 구조이기 때문에 사용
- 화면의 관리를 해주는 객체, 방법
final class AppFlowCoordinator {
var navigationController: UINavigationController
private let appDIContainer: AppDIContainer
init(
navigationController: UINavigationController,
appDIContainer: AppDIContainer
) {
self.navigationController = navigationController
self.appDIContainer = appDIContainer
}
func start() {
// In App Flow we can check if user needs to login, if yes we would run login flow
let moviesSceneDIContainer = appDIContainer.makeMoviesSceneDIContainer()
let flow = moviesSceneDIContainer.makeMoviesSearchFlowCoordinator(navigationController: navigationController)
flow.start()
}
}
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
- useCase 실행 -> useCase는 Repository로 부터 데이터 조합
import Foundation
struct MoviesListViewModelActions {
/// Note: if you would need to edit movie inside Details screen and update this Movies List screen with updated movie then you would need this closure:
/// showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void
let showMovieDetails: (Movie) -> Void
let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void
let closeMovieQueriesSuggestions: () -> Void
}
enum MoviesListViewModelLoading {
case fullScreen
case nextPage
}
protocol MoviesListViewModelInput {
func viewDidLoad()
func didLoadNextPage()
func didSearch(query: String)
func didCancelSearch()
func showQueriesSuggestions()
func closeQueriesSuggestions()
func didSelectItem(at index: Int)
}
protocol MoviesListViewModelOutput {
var items: Observable<[MoviesListItemViewModel]> { get } /// Also we can calculate view model items on demand: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/pull/10/files
var loading: Observable<MoviesListViewModelLoading?> { get }
var query: Observable<String> { get }
var error: Observable<String> { get }
var isEmpty: Bool { get }
var screenTitle: String { get }
var emptyDataTitle: String { get }
var errorTitle: String { get }
var searchBarPlaceholder: String { get }
}
typealias MoviesListViewModel = MoviesListViewModelInput & MoviesListViewModelOutput
final class DefaultMoviesListViewModel: MoviesListViewModel {
private let searchMoviesUseCase: SearchMoviesUseCase
private let actions: MoviesListViewModelActions?
var currentPage: Int = 0
var totalPageCount: Int = 1
var hasMorePages: Bool { currentPage < totalPageCount }
var nextPage: Int { hasMorePages ? currentPage + 1 : currentPage }
private var pages: [MoviesPage] = []
private var moviesLoadTask: Cancellable? { willSet { moviesLoadTask?.cancel() } }
private let mainQueue: DispatchQueueType
// MARK: - OUTPUT
let items: Observable<[MoviesListItemViewModel]> = Observable([])
let loading: Observable<MoviesListViewModelLoading?> = Observable(.none)
let query: Observable<String> = Observable("")
let error: Observable<String> = Observable("")
var isEmpty: Bool { return items.value.isEmpty }
let screenTitle = NSLocalizedString("Movies", comment: "")
let emptyDataTitle = NSLocalizedString("Search results", comment: "")
let errorTitle = NSLocalizedString("Error", comment: "")
let searchBarPlaceholder = NSLocalizedString("Search Movies", comment: "")
// MARK: - Init
init(
searchMoviesUseCase: SearchMoviesUseCase,
actions: MoviesListViewModelActions? = nil,
mainQueue: DispatchQueueType = DispatchQueue.main
) {
self.searchMoviesUseCase = searchMoviesUseCase
self.actions = actions
self.mainQueue = mainQueue
}
// MARK: - Private
private func appendPage(_ moviesPage: MoviesPage) {
currentPage = moviesPage.page
totalPageCount = moviesPage.totalPages
pages = pages
.filter { $0.page != moviesPage.page }
+ [moviesPage]
items.value = pages.movies.map(MoviesListItemViewModel.init)
}
private func resetPages() {
currentPage = 0
totalPageCount = 1
pages.removeAll()
items.value.removeAll()
}
private func load(movieQuery: MovieQuery, loading: MoviesListViewModelLoading) {
self.loading.value = loading
query.value = movieQuery.query
moviesLoadTask = searchMoviesUseCase.execute(
requestValue: .init(query: movieQuery, page: nextPage),
cached: { [weak self] page in
self?.mainQueue.async {
self?.appendPage(page)
}
},
completion: { [weak self] result in
self?.mainQueue.async {
switch result {
case .success(let page):
self?.appendPage(page)
case .failure(let error):
self?.handle(error: error)
}
self?.loading.value = .none
}
})
}
private func handle(error: Error) {
self.error.value = error.isInternetConnectionError ?
NSLocalizedString("No internet connection", comment: "") :
NSLocalizedString("Failed loading movies", comment: "")
}
private func update(movieQuery: MovieQuery) {
resetPages()
load(movieQuery: movieQuery, loading: .fullScreen)
}
}
// MARK: - INPUT. View event methods
extension DefaultMoviesListViewModel {
func viewDidLoad() { }
func didLoadNextPage() {
guard hasMorePages, loading.value == .none else { return }
load(movieQuery: .init(query: query.value),
loading: .nextPage)
}
func didSearch(query: String) {
guard !query.isEmpty else { return }
update(movieQuery: MovieQuery(query: query))
}
func didCancelSearch() {
moviesLoadTask?.cancel()
}
func showQueriesSuggestions() {
actions?.showMovieQueriesSuggestions(update(movieQuery:))
}
func closeQueriesSuggestions() {
actions?.closeMovieQueriesSuggestions()
}
func didSelectItem(at index: Int) {
actions?.showMovieDetails(pages.movies[index])
}
}
// MARK: - Private
private extension Array where Element == MoviesPage {
var movies: [Movie] { flatMap { $0.movies } }
}
View
- ViewModel의 메소드 호출
- UI레이아웃
import UIKit
final class MoviesListViewController: UIViewController, StoryboardInstantiable, Alertable {
@IBOutlet private var contentView: UIView!
@IBOutlet private var moviesListContainer: UIView!
@IBOutlet private(set) var suggestionsListContainer: UIView!
@IBOutlet private var searchBarContainer: UIView!
@IBOutlet private var emptyDataLabel: UILabel!
private var viewModel: MoviesListViewModel!
private var posterImagesRepository: PosterImagesRepository?
private var moviesTableViewController: MoviesListTableViewController?
private var searchController = UISearchController(searchResultsController: nil)
// MARK: - Lifecycle
static func create(
with viewModel: MoviesListViewModel,
posterImagesRepository: PosterImagesRepository?
) -> MoviesListViewController {
let view = MoviesListViewController.instantiateViewController()
view.viewModel = viewModel
view.posterImagesRepository = posterImagesRepository
return view
}
override func viewDidLoad() {
super.viewDidLoad()
setupViews()
setupBehaviours()
bind(to: viewModel)
viewModel.viewDidLoad()
}
private func bind(to viewModel: MoviesListViewModel) {
viewModel.items.observe(on: self) { [weak self] _ in self?.updateItems() }
viewModel.loading.observe(on: self) { [weak self] in self?.updateLoading($0) }
viewModel.query.observe(on: self) { [weak self] in self?.updateSearchQuery($0) }
viewModel.error.observe(on: self) { [weak self] in self?.showError($0) }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
searchController.isActive = false
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == String(describing: MoviesListTableViewController.self),
let destinationVC = segue.destination as? MoviesListTableViewController {
moviesTableViewController = destinationVC
moviesTableViewController?.viewModel = viewModel
moviesTableViewController?.posterImagesRepository = posterImagesRepository
}
}
// MARK: - Private
private func setupViews() {
title = viewModel.screenTitle
emptyDataLabel.text = viewModel.emptyDataTitle
setupSearchController()
}
private func setupBehaviours() {
addBehaviors([BackButtonEmptyTitleNavigationBarBehavior(),
BlackStyleNavigationBarBehavior()])
}
private func updateItems() {
moviesTableViewController?.reload()
}
private func updateLoading(_ loading: MoviesListViewModelLoading?) {
emptyDataLabel.isHidden = true
moviesListContainer.isHidden = true
suggestionsListContainer.isHidden = true
LoadingView.hide()
switch loading {
case .fullScreen: LoadingView.show()
case .nextPage: moviesListContainer.isHidden = false
case .none:
moviesListContainer.isHidden = viewModel.isEmpty
emptyDataLabel.isHidden = !viewModel.isEmpty
}
moviesTableViewController?.updateLoading(loading)
updateQueriesSuggestions()
}
private func updateQueriesSuggestions() {
guard searchController.searchBar.isFirstResponder else {
viewModel.closeQueriesSuggestions()
return
}
viewModel.showQueriesSuggestions()
}
private func updateSearchQuery(_ query: String) {
searchController.isActive = false
searchController.searchBar.text = query
}
private func showError(_ error: String) {
guard !error.isEmpty else { return }
showAlert(title: viewModel.errorTitle, message: error)
}
}
// MARK: - Search Controller
extension MoviesListViewController {
private func setupSearchController() {
searchController.delegate = self
searchController.searchBar.delegate = self
searchController.searchBar.placeholder = viewModel.searchBarPlaceholder
searchController.obscuresBackgroundDuringPresentation = false
searchController.searchBar.translatesAutoresizingMaskIntoConstraints = true
searchController.searchBar.barStyle = .black
searchController.hidesNavigationBarDuringPresentation = false
searchController.searchBar.frame = searchBarContainer.bounds
searchController.searchBar.autoresizingMask = [.flexibleWidth]
searchBarContainer.addSubview(searchController.searchBar)
definesPresentationContext = true
if #available(iOS 13.0, *) {
searchController.searchBar.searchTextField.accessibilityIdentifier = AccessibilityIdentifier.searchField
}
}
}
extension MoviesListViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let searchText = searchBar.text, !searchText.isEmpty else { return }
searchController.isActive = false
viewModel.didSearch(query: searchText)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
viewModel.didCancelSearch()
}
}
extension MoviesListViewController: UISearchControllerDelegate {
func willPresentSearchController(_ searchController: UISearchController) {
updateQueriesSuggestions()
}
func willDismissSearchController(_ searchController: UISearchController) {
updateQueriesSuggestions()
}
func didDismissSearchController(_ searchController: UISearchController) {
updateQueriesSuggestions()
}
}
MVVM의 Flow 구조이다
MVVM 유의점
MVVM을 처음 접한 뒤, 정해진 틀에 내 코드를 맞추는게 정말..힘들다.. 🥹
- Entity와 Model 사이에 갭이 크지 않으면, Entity를 Model로 취급해도 가능 할 것 같다
- 간단한 앱은 MVC가 더 적합하다.
- Model의 상태는 ViweModel에서만 갖고있게 하는게 좋다.
- View에 곧 반영될 것이기 때문에 뷰에 관련된 모델을 관리하는곳은 ViewModel이 적합
해당 MVVM의 폴더구조도 예시이기 때문에, 무조건 옳은 것이 아니다.
프로젝트 규모, D&C, SOLID, 디자인 패턴 등 다양하게 고려하여, 폴더 구조 및 디자인 패턴등을 채택하여 사용하면 될 것 같다.