Medium - Clean Architecture and MVVM on iOS
in Trend
Trend 파악을 Medium 기고문 요약 포스팅 - iOS에서의 MVVM과 깔끔한 구조
Photo by Joel Filipe on Unsplash
Data Flow
Domain Layer
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.moviesList(query: requestValue.query, page: requestValue.page) { [weak self] result in
guard let strongSelf = self else { return }
switch result {
case .success:
strongSelf.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
completion(result)
case .failure:
completion(result)
}
}
}
}
// Repository Interfaces
protocol MoviesRepository {
func moviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}
protocol MoviesQueriesRepository {
func recentsQueries(number: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void)
func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void)
}
Presentation Layer
// Note: We cannot have any UI frameworks(like UIKit or SwiftUI) imports here.
protocol MoviesListViewModelInput {
func didSearch(query: String)
}
protocol MoviesListViewModelOutput {
var items: Observable<[MoviesListViewItemModel]> { get }
var route: Observable<MoviesListViewModelRoute> { get }
var error: Observable<String> { get }
}
protocol MoviesListViewModel: MoviesListViewModelInput, MoviesListViewModelOutput { }
enum MoviesListViewModelRoute {
case initial
case showMovieDetail(movieId: MovieId)
}
final class DefaultMoviesListViewModel: MoviesListViewModel {
// MARK: - OUTPUT
let items: Observable<[MoviesListViewItemModel]> = Observable([MoviesListViewItemModel]())
let route: Observable<MoviesListViewModelRoute> = Observable(.initial)
let error: Observable<String> = Observable("")
private func load(movieQuery: MovieQuery) {
searchMoviesUseCase.execute(movieQuery: movieQuery) { [weak self] result in
guard let strongSelf = self else { return }
switch result {
case .success(let moviesPage):
// Note: We must map here from Domain Entities to View Items Models. Separation of Domain and View
strongSelf.items.value = strongSelf.mapToItems(moviesPage.movies)
case .failure:
strongSelf.error.value = NSLocalizedString("Failed loading movies", comment: "")
}
}
}
}
// MARK: - INPUT. View event methods
extension MoviesListViewModel {
func didSearch(query: String) {
guard !query.isEmpty else { return }
load(movieQuery: MovieQuery(query: query))
}
}
struct MoviesListViewItemModel: Equatable {
let title: String
}
import UIKit
final class MoviesListViewController: UIViewController, StoryboardInstantiable, UISearchBarDelegate {
private(set) 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)
}
func bind(to viewModel: MoviesListViewModel) {
viewModel.items.observe(on: self) { [weak self] items in
self?.moviesTableViewController?.items = items
}
viewModel.route.observe(on: self) { [weak self] route in
self?.handle(route)
}
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)
}
}
// MARK: - Handle Routing
extension MoviesListViewController {
func handle(_ route: MoviesListViewModelRoute) {
switch route {
case .initial: break
case .showMovieDetail(let movieId):
let vc = moviesListViewControllersFactory.makeMoviesDetailsViewController(movieId: MovieId)
navigationController?.pushViewController(vc, animated: true)
}
}
class MoviesSceneDIContainer {
// MARK: - Movies List
func makeMoviesListViewController() -> UIViewController {
if #available(iOS 13.0, *) { // SwiftUI
return UIHostingController(rootView: MoviesListView.create(with: makeMoviesListViewModel(),
moviesListViewControllersFactory: self))
} else { // UIKit
return MoviesListViewController.create(with: makeMoviesListViewModel(), moviesListViewControllersFactory: self)
}
}
}
Data Layer
final class DefaultMoviesRepository {
private let dataTransferService: DataTransfer
init(dataTransferService: DataTransfer) {
self.dataTransferService = dataTransferService
}
}
extension DefaultMoviesRepository: MoviesRepository {
public func moviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
let endpoint = APIEndpoints.movies(query: query.query, page: page)
return self.dataTransferService.request(with: endpoint) { (response: Result<MoviesPage, Error>) in
switch response {
case .success(let moviesPage):
completion(.success(moviesPage))
case .failure(let error):
completion(.failure(error))
}
}
}
}
// It is needed to map from JSON data to Decodable MoviesPage inside DataTransferService
extension MoviesPage: Decodable {
private enum CodingKeys: String, CodingKey {
case page
case totalPages = "total_pages"
case movies = "results"
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.page = try container.decode(Int.self, forKey: .page)
self.totalPages = try container.decode(Int.self, forKey: .totalPages)
self.movies = try container.decode([Movie].self, forKey: .movies)
}
}
Infrastructure Layer(Network)
struct APIEndpoints {
static func movies(query: String, page: Int) -> DataEndpoint<MoviesPage> {
return DataEndpoint(path: "3/search/movie/",
queryParameters: ["query": query,
"page": "\(page)"])
}
}
let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.apiBaseURL)!,
queryParameters: ["api_key": appConfigurations.apiKey])
let apiDataNetwork = DefaultNetworkService(session: URLSession.shared,
config: config)
let endpoint = APIEndpoints.movies(query: query.query, page: page)
dataTransferService.request(with: endpoint) { (response: Result<MoviesPage, Error>) in
let moviesPage = try? response.get()
}
MVVM
class Observable<Value> {
private var closure: ((Value) -> ())?
public var value : Value {
didSet { DispatchQueue.main.async { self.closure?(self.value) } }
}
public init(_ value: Value) {
self.value = value
}
func observe(_ closure: @escaping (Value) -> Void) {
self.closure = closure
DispatchQueue.main.async { closure(self.value) }
}
}
MVVMs Communication
// Step 1: Define delegate and add it to first ViewModel as weak property
protocol MoviesQueryListViewModelDelegate: class {
func moviesQueriesListDidSelect(movieQuery: MovieQuery)
}
class MoviesQueryListViewModel {
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)
}
}
Layer Separation into frameworks (Modules)
Dependecy Injection container
Using factory protocols
// Define MoviesListViewControllersFactory protocol under the ViewController
protocol MoviesListViewControllersFactory {
func makeMoviesQueriesSuggestionsListViewController(delegate: MoviesQueryListViewModelDelegate) -> UIViewController
}
// Make your DIContainer to conform to this protocol, and inject it into the ViewController that needs this injection
extension MoviesSceneDIContainer: MoviesListViewControllersFactory {}
final class MoviesSceneDIContainer {
// MARK: - Movies List
// Note: All makeViewController methods should return UIViewController, to not make it specific like return MoviesListViewController.
// It will be easier when refactoring to SwiftUI, and you will not need to make all public when developing in separate module
func makeMoviesListViewController() -> UIViewController {
return MoviesListViewController.create(with: makeMoviesListViewModel(), moviesListViewControllersFactory: self)
}
Using closures
// Define makeMoviesDetailsViewController closure that returns MoviesDetailsViewController
class MoviesListViewController: UIViewController {
let makeMoviesDetailsViewController: () -> UIViewController
...
}
// Inject makeMoviesDetailsViewController closure that creates MoviesDetailsViewController
class MoviesSceneDIContainer {
// MARK: - Movies List
func makeMoviesListViewController() -> UIViewController {
let makeMoviesDetailsViewController: () -> UIViewController = {
return self.makeMoviesDetailsViewController()
}
return MoviesListViewController.create(with: makeMoviesListViewModel(),
makeMoviesDetailsViewController: makeMoviesDetailsViewController)
}
}