Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- Observable
- ios
- 애니메이션
- tableView
- rxswift
- collectionview
- UICollectionView
- ribs
- clean architecture
- swiftUI
- RxCocoa
- 리팩토링
- MVVM
- Clean Code
- 스위프트
- 리펙토링
- swift documentation
- Refactoring
- uitableview
- Xcode
- UITextView
- 리펙터링
- uiscrollview
- combine
- 클린 코드
- Protocol
- Human interface guide
- SWIFT
- map
- HIG
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] clean architecture를 적용한 MVVM 코드 맛보기 본문
Architecture (swift)/MVVM (맛보기)
[iOS - swift] clean architecture를 적용한 MVVM 코드 맛보기
jake-kim 2021. 6. 19. 01:15Domain Layer
: 영화 검색 결과 성공한 쿼리를 저장하는 Entities, SearchMoviesUseCase, DIP를 위한 프로토콜
- Repository Protocol위치가 UseCase에 존재
- UseCase에 주입: 비즈니스로직에 필요한 Repository
- UseCase끼리는 서로 의존 가능
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)
}
Presentation Layer
: MovieListView, MovieListViewModel
- View는 ViewModel에 의존하고 있지만, ViewModel은 View에 의존하지 않는 형태이므로 View가 변경되어도 ViewModel은 영향받지 않는 형태
- ViewModel에 Input, Output 프로토콜 존재
- View에 영향받지 않고 ViewModel만 가지고 테스트할 수 있는 구조
- ViewModel에는 UseCase, action을 주입받아서 초기화
- action: Coordinator에서 ViewModel간의 delegate 통신을 설정 (actions?.showMovieDetails(movies[indexPath.row]))
// 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 ?? ""
}
}
- ViewModel에 Input, Output이 있어서, ViewController 생성 시 ViewModelMock을 넣어서 ViewController 테스트
import FBSnapshotTestCase
@testable import MoviesSearch
class MoviesListViewTests: FBSnapshotTestCase {
let movies: [Movie] = [
Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2"),
Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")
]
override func setUp() {
super.setUp()
//self.recordMode = true
}
func test_whenViewIsEmpty_thenShowEmptyScreen() {
// given
let vc = MoviesListViewController.create(
with: MoviesListViewModelMock.stub(isEmpty: true,
emptyDataTitle: NSLocalizedString("Search results", comment: ""),
searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
),
posterImagesRepository: PosterImagesRepositoryMock())
// then
FBSnapshotVerifyView(vc.view)
}
func test_whenHasItems_thenShowItemsOnScreen() {
// given
let items = movies.map(MoviesListItemViewModel.init)
let vc = MoviesListViewController.create(
with: MoviesListViewModelMock.stub(items: Observable(items),
isEmpty: false,
emptyDataTitle: NSLocalizedString("Search results", comment: ""),
searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
),
posterImagesRepository: PosterImagesRepositoryMock())
// then
FBSnapshotVerifyView(vc.view)
}
}
struct MoviesListViewModelMock: MoviesListViewModel {
// MARK: - Input
func viewDidLoad() {}
func didLoadNextPage() {}
func didSearch(query: String) {}
func didCancelSearch() {}
func showQueriesSuggestions() {}
func closeQueriesSuggestions() {}
func didSelectItem(at index: Int) {}
// MARK: - Output
var items: Observable<[MoviesListItemViewModel]>
var loading: Observable<MoviesListViewModelLoading?>
var query: Observable<String>
var error: Observable<String>
var isEmpty: Bool
var screenTitle: String
var emptyDataTitle: String
var errorTitle: String
var searchBarPlaceholder: String
static func stub(items: Observable<[MoviesListItemViewModel]> = Observable([]),
loading: Observable<MoviesListViewModelLoading?> = Observable(nil),
query: Observable<String> = Observable(""),
error: Observable<String> = Observable(""),
isEmpty: Bool = true,
screenTitle: String = NSLocalizedString("Movies", comment: ""),
emptyDataTitle: String = NSLocalizedString("Search results", comment: ""),
errorTitle: String = NSLocalizedString("Error", comment: ""),
searchBarPlaceholder: String = NSLocalizedString("Search Movies", comment: "")) -> Self {
.init(items: items,
loading: loading,
query: query,
error: error,
isEmpty: isEmpty,
screenTitle: screenTitle,
emptyDataTitle: emptyDataTitle,
errorTitle: errorTitle,
searchBarPlaceholder: searchBarPlaceholder)
}
}
- ViewModel에서 UseCase들을 테스트
import XCTest
class MoviesListViewModelTests: XCTestCase {
private enum SearchMoviesUseCaseError: Error {
case someError
}
let moviesPages: [MoviesPage] = {
let page1 = MoviesPage(page: 1, totalPages: 2, movies: [
Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2")])
let page2 = MoviesPage(page: 2, totalPages: 2, movies: [
Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")])
return [page1, page2]
}()
class SearchMoviesUseCaseMock: SearchMoviesUseCase {
var expectation: XCTestExpectation?
var error: Error?
var page = MoviesPage(page: 0, totalPages: 0, movies: [])
func execute(requestValue: SearchMoviesUseCaseRequestValue,
cached: @escaping (MoviesPage) -> Void,
completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
if let error = error {
completion(.failure(error))
} else {
completion(.success(page))
}
expectation?.fulfill()
return nil
}
}
func test_whenSearchMoviesUseCaseRetrievesFirstPage_thenViewModelContainsOnlyFirstPage() {
// given
let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
searchMoviesUseCaseMock.expectation = self.expectation(description: "contains only first page")
searchMoviesUseCaseMock.page = MoviesPage(page: 1, totalPages: 2, movies: moviesPages[0].movies)
let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
// when
viewModel.didSearch(query: "query")
// then
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(viewModel.currentPage, 1)
XCTAssertTrue(viewModel.hasMorePages)
}
func test_whenSearchMoviesUseCaseRetrievesFirstAndSecondPage_thenViewModelContainsTwoPages() {
// given
let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
searchMoviesUseCaseMock.expectation = self.expectation(description: "First page loaded")
searchMoviesUseCaseMock.page = MoviesPage(page: 1, totalPages: 2, movies: moviesPages[0].movies)
let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
// when
viewModel.didSearch(query: "query")
waitForExpectations(timeout: 5, handler: nil)
searchMoviesUseCaseMock.expectation = self.expectation(description: "Second page loaded")
searchMoviesUseCaseMock.page = MoviesPage(page: 2, totalPages: 2, movies: moviesPages[1].movies)
viewModel.didLoadNextPage()
// then
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(viewModel.currentPage, 2)
XCTAssertFalse(viewModel.hasMorePages)
}
func test_whenSearchMoviesUseCaseReturnsError_thenViewModelContainsError() {
// given
let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
searchMoviesUseCaseMock.expectation = self.expectation(description: "contain errors")
searchMoviesUseCaseMock.error = SearchMoviesUseCaseError.someError
let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
// when
viewModel.didSearch(query: "query")
// then
waitForExpectations(timeout: 5, handler: nil)
XCTAssertNotNil(viewModel.error)
}
func test_whenLastPage_thenHasNoPageIsTrue() {
// given
let searchMoviesUseCaseMock = SearchMoviesUseCaseMock()
searchMoviesUseCaseMock.expectation = self.expectation(description: "First page loaded")
searchMoviesUseCaseMock.page = MoviesPage(page: 1, totalPages: 2, movies: moviesPages[0].movies)
let viewModel = DefaultMoviesListViewModel(searchMoviesUseCase: searchMoviesUseCaseMock)
// when
viewModel.didSearch(query: "query")
waitForExpectations(timeout: 5, handler: nil)
searchMoviesUseCaseMock.expectation = self.expectation(description: "Second page loaded")
searchMoviesUseCaseMock.page = MoviesPage(page: 2, totalPages: 2, movies: moviesPages[1].movies)
viewModel.didLoadNextPage()
// then
waitForExpectations(timeout: 5, handler: nil)
XCTAssertEqual(viewModel.currentPage, 2)
XCTAssertFalse(viewModel.hasMorePages)
}
}
- Coordinator에서 MoviesListViewModelActions를 구현하고, 특정 ViewModel에서 실행하면 동작하도록 설정
- Actions 선언부: ViewModel
- Actions 구현부: Coordinator
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에 Input, Output 프로토콜을 이용한 TestCode
import Foundation
import FBSnapshotTestCase
@testable import MoviesSearch
class MoviesListViewTests: FBSnapshotTestCase {
let movies: [Movie] = [
Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"),
Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2"),
Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")
]
override func setUp() {
super.setUp()
//self.recordMode = true
}
func test_whenViewIsEmpty_thenShowEmptyScreen() {
// given
let vc = MoviesListViewController.create(
with: MoviesListViewModelMock.stub(isEmpty: true,
emptyDataTitle: NSLocalizedString("Search results", comment: ""),
searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
),
posterImagesRepository: PosterImagesRepositoryMock())
// then
FBSnapshotVerifyView(vc.view)
}
func test_whenHasItems_thenShowItemsOnScreen() {
// given
let items = movies.map(MoviesListItemViewModel.init)
let vc = MoviesListViewController.create(
with: MoviesListViewModelMock.stub(items: Observable(items),
isEmpty: false,
emptyDataTitle: NSLocalizedString("Search results", comment: ""),
searchBarPlaceholder: NSLocalizedString("Search Movies", comment: "")
),
posterImagesRepository: PosterImagesRepositoryMock())
// then
FBSnapshotVerifyView(vc.view)
}
}
struct MoviesListViewModelMock: MoviesListViewModel {
// MARK: - Input
func viewDidLoad() {}
func didLoadNextPage() {}
func didSearch(query: String) {}
func didCancelSearch() {}
func showQueriesSuggestions() {}
func closeQueriesSuggestions() {}
func didSelectItem(at index: Int) {}
// MARK: - Output
var items: Observable<[MoviesListItemViewModel]>
var loading: Observable<MoviesListViewModelLoading?>
var query: Observable<String>
var error: Observable<String>
var isEmpty: Bool
var screenTitle: String
var emptyDataTitle: String
var errorTitle: String
var searchBarPlaceholder: String
static func stub(items: Observable<[MoviesListItemViewModel]> = Observable([]),
loading: Observable<MoviesListViewModelLoading?> = Observable(nil),
query: Observable<String> = Observable(""),
error: Observable<String> = Observable(""),
isEmpty: Bool = true,
screenTitle: String = NSLocalizedString("Movies", comment: ""),
emptyDataTitle: String = NSLocalizedString("Search results", comment: ""),
errorTitle: String = NSLocalizedString("Error", comment: ""),
searchBarPlaceholder: String = NSLocalizedString("Search Movies", comment: "")) -> Self {
.init(items: items,
loading: loading,
query: query,
error: error,
isEmpty: isEmpty,
screenTitle: screenTitle,
emptyDataTitle: emptyDataTitle,
errorTitle: errorTitle,
searchBarPlaceholder: searchBarPlaceholder)
}
}
- View
- viewModel을 가지고 있는 형태
- viewModel.output을 obserbing하는 형태
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)
}
}
Data Layer
- Repository 인터페이스
- DTO: JSON 응답에서 도메인으로 매핑하기위한 객체
- Encodable의 RequestDTO
- Decodable의 ResponseDTO
- Mapping to Domain: toDomain() 함수
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() })
}
}
...
- DTO는 Repository 구현부에서 사용
- DTO 역할
- Request에 필요한 Encodable: struct -> JSON
- Response에 필요한 Decodable: JSON -> struct
- Response로 부터 받은 데이터를 실제 domain에서 사용하는 모델로 변경하는 메소드 toDomain()
- 네이밍
- -Service: 네트워크 관련
- -cache: Endpoint 응답을 캐시하기 위하여 DTO를 NSManagedObject에 매핑하여 CoreData 영구저장소에 저장하는 방법
- cache를 사용하는 이유: 사용자가 데이터를 즉시 볼 수 있는 장점, 인터넷에 연결되어 있지 않아도 CoreData에서 데이터를 볼 수 있는 장점
- cache 사용 방법: 네트워크 API 결과값을 반환하기 전에, CoreData에서 출력을 요청 -> API로부터 데이터가 오면 CoreData를 최신 데이터로 업데이트
final class DefaultMoviesRepository {
private let dataTransferService: DataTransferService
private let cache: MoviesResponseStorage
init(dataTransferService: DataTransferService, cache: MoviesResponseStorage) {
self.dataTransferService = dataTransferService
self.cache = cache
}
}
extension DefaultMoviesRepository: MoviesRepository {
public 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) { 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) { 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
}
}
인프라 계층(network)
- 기초
- API(Application Programming Interface): 프로그램들이 서로 상호작용하는 것을 도와주는 매개체
- endpoint: GET/POST/PUT/DELETE를 설정하여 request하기 전 마지막 설정
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()
}
- Custom Observable
- value에 값이 emit될때마다 didSet에서 closure를 통해 subscriber에게 값 방출
- Presentation Layer에서 사용하기 때문에 main thread에서 observer를 호출
public final class Observable<Value> {
struct Observer<Value> {
weak var observer: AnyObject?
let block: (Value) -> Void
}
private var observers = [Observer<Value>]()
public var value: Value {
didSet { notifyObservers() }
}
public init(_ value: Value) {
self.value = value
}
public func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) {
observers.append(Observer(observer: observer, block: observerBlock))
observerBlock(self.value)
}
public func remove(observer: AnyObject) {
observers = observers.filter { $0.observer !== observer }
}
private func notifyObservers() {
for observer in observers {
DispatchQueue.main.async { observer.block(self.value) }
}
}
}
- ViewController에서 Data binding 하는 예시
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 }
}
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 {}
- TalbleViewCell에 대한 데이터 바인딩
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)
}
}
통신 방법
- ViewModel간의 통신: Delegate pattern
- A화면 -> B화면에서 처리 후 다시 A화면으로 넘어오는 경우 -> delegate로 알림
- B화면에는 delegate?.실행
- A화면에는 delegate를 구현
// Step 1: Define delegate and add it to first ViewModel as weak property
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: Make second ViewModel to conform to this delegate
extension MoviesListViewModel: MoviesQueryListViewModelDelegate {
func moviesQueriesListDidSelect(movieQuery: MovieQuery) {
update(movieQuery: movieQuery)
}
}
- Coordinator를 이용한 통신
- FlowCoordinator에 의해 할당되거나 주입되는 클로저를 이용
- MoviesListViewModel이 액션 클로저인 showMovieQueriesSuggestions을 사용하여 MoviesQueriesSuggestions뷰를 표시하는 방법
// MoviesQueryList.swift
// Step 1: Define action closure to communicate to another ViewModel, e.g. here we notify MovieList when query is selected
typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void
// Step 2: Call action closure when needed
class MoviesQueryListViewModel {
init(didSelect: MoviesQueryListViewModelDidSelectAction? = nil) {
self.didSelect = didSelect
}
func didSelect(item: MoviesQueryListItemViewModel) {
didSelect?(MovieQuery(query: item.query))
}
}
// MoviesQueryList.swift
// Step 3: When presenting MoviesQueryListView we need to pass this action closure as paramter (_ didSelect: MovieQuery) -> Void
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: Inside FlowCoordinator we connect communication of two viewModels, by injecting actions closures as self function
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)
}
}
Cocoapods Framework 계층 분리 방법
DI container
- 개념: 한 개체가 다른 개체의 종속성을 제공하는 방법
- 방법1. DI Factory protocol을 이용
- Dependencies 프로톸로 정의
- MoviesSceneDIContainer가 이 프로토콜을 따르도록 적용
- 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)
}
}
- DI 방법2. 클로저 이용
- 주입이 필요한 클래스 내부에서 클로저를 참조하고 있다가, 이 클로저를 주입하는 형태
// Define makeMoviesListViewController closure that returns MoviesListViewController
class MoviesSearchFlowCoordinator {
private var makeMoviesListViewController: () -> MoviesListViewController
init(navigationController: UINavigationController,
makeMoviesListViewController: @escaping () -> MoviesListViewController) {
...
self.makeMoviesListViewController = makeMoviesListViewController
}
...
}
// And inject MoviesSceneDIContainer's `self`.makeMoviesListViewController function into class that needs it
final class MoviesSceneDIContainer {
...
// MARK: - Flow Coordinators
func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator {
return MoviesSearchFlowCoordinator(navigationController: navigationController,
makeMoviesListViewController: self.makeMoviesListViewController)
}
// MARK: - Movies List
func makeMoviesListViewController() -> MoviesListViewController {
...
}
}
위 구조에서 클린 아키텍처를 유지하는 원칙
- 테스트없이 코드를 작성하는 것을 지양
- 가능하면 서드파티 프레임워크에 종속하는 것을 지양
* 참고
- MVVM template: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/tree/master/MVVM%20Templates
- 개념: https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3
'Architecture (swift) > MVVM (맛보기)' 카테고리의 다른 글
Comments