이제 MVVM을 활용해서 투두 애플리케이션을 만들어보자.
MVVM은 MVP 아키텍처와 비슷하다. ViewModel이 Presenter와 비슷한 기능을 하기 때문이다.
MVP는 View와 Presenter의 결합도가 높다는 단점이 있다. 즉, View와 Presenter가 서로를 참조해야 한다.
하지만 데이터 바인딩을 사용하면 이 결합도를 낮출 수 있다!
AppDelegate, SceneDelegate에서 첫 화면으로 쓰일 ViewController 객체를 생성한다. 이때 뷰컨 객체에 서비스에 대한 dependency를 전달하지 않는다. ViewModel 안에서 dependency를 주입할 것이기 때문!
Home Screen의 메인 컴포넌트는 HomeViewModel
이다. 이 뷰모델은 HomeView
에 바인딩되어 있다.
뷰컨에서 HomeViewModel
의 객체를 생성하고, 이를 View에게 전달한다.
또한 화면 전환 역시 뷰컨이 담당한다.
extension HomeViewController: HomeViewControllerDelegate {
func addList() {
navigationController?.pushViewController(AddListViewController(), animated: true)
}
func selectedList(_ list: TasksListModel) {
let taskViewController = TaskListViewController(tasksListModel: list)
navigationController?.pushViewController(taskViewController, animated: true)
}
}
Coordinator 패턴을 사용하면 뷰컨에서 화면전환 로직을 제거할 수 있다.
MVVM의 View에서 특히 주목해야 할 것은 데이터 바인딩을 사용해서 ViewModel의 상태 변경을 감지하는 방법이다.
RxSwift를 사용하면 쉽게 View의 컴포넌트들과 Model을 연결할 수 있다.
class HomeView: UIView {
...
private let viewModel: HomeViewModel!
private let disposeBag = DisposeBag()
init(frame: CGRect = .zero, viewModel: HomeViewModel) {
self.viewModel = viewModel
...
bindViewToModel(viewModel)
}
}
private extension HomeView {
...
func bindViewToModel(_ viewModel: HomeViewModel) {
...
}
}
먼저 UITableView
를 바인딩해보자. 일단 테이블 뷰의 Delegate를 설정한다.
tableView.rx
.setDelegate(self)
.disposed(by: disposeBag)
그 다음 섹션, 행, 항목의 수에 대한 정보를 전달한다.
viewModel.output.lists
.drive(tableView.rx.items(cellIdentifier: ToDoListCell.reuseId, cellType: ToDoListCell.self)) { (_, list, cell) in
cell.setCellParametersForList(list)
}
.disposed(by: disposeBag)
Input/Output 접근법에 따르면, 태스크(할 일)의 목록이 ViewModel의 output이 된다. tableView.rx.items()
메소드를 사용해서 이 데이터와 테이블 뷰 cell들을 바인딩한다.
다음으로 사용자가 셀을 선택하는 액션을 구독한다. 이때는 input.selectRow
파라미터를 사용한다.
tableView.rx.itemSelected
.bind(to: viewModel.input.selectRow)
.disposed(by: disposeBag)
viewModel.output.selectedList
.drive(onNext: { [self] list in
delegate?.selectedList(list)
})
.disposed(by: disposeBag)
itemSelected
는 선택한 셀의 indexPath를 반환해준다.TaskListModel
을 output으로 방출한다.addListButton.rx.tap
.asDriver()
.drive(onNext: { [self] in
delegate?.addList()
})
.disposed(by: disposeBag)
버튼을 탭했을 때 실행될 로직은 위와 같이 작성한다. rx를 사용하면 버튼 이벤트를 tap
이라는 메소드로 간단하게 처리할 수 있다.
마지막으로 table view를 reload하는 코드를 추가한다.
viewModel.input.reload.accept(())
ViewModel은 Model로부터 데이터를 얻은 데이터를 View에게 전달하고, View의 비즈니스 로직을 관리한다.
class HomeViewModel {
var output: Output!
var input: Input!
struct Input {
let reload: PublishRelay<Void>
let deleteRow: PublishRelay<IndexPath>
let selectRow: PublishRelay<IndexPath>
}
struct Output {
let hideEmptyState: Driver<Bool>
let lists: Driver<[TasksListModel]>
let selectedList: Driver<TasksListModel>
}
...
}
HomeViewModel
의 Input과 Output은 위와 같이 구성되어 있다.
Input과 Output을 정의한 다음에는 ViewModel의 생성자 안에서 동작을 정의한다.
init(taskListService: TasksListServiceProtocol) {
...
// Inputs
let reload = PublishRelay<Void>()
_ = reload.subscribe(onNext: { [self] _ in
fetchTasksLists()
})
let deleteRow = PublishRelay<IndexPath>()
_ = deleteRow.subscribe(onNext: { [self] indexPath in
tasksListService.deleteList(listAtIndexPath(indexPath))
})
let selectRow = PublishRelay<IndexPath>()
_ = selectRow.subscribe(onNext: { [self] indexPath in
taskList.accept(listAtIndexPath(indexPath))
})
self.input = Input(reload: reload, deleteRow: deleteRow, selectRow: selectRow)
// Outputs
let items = lists
.asDriver(onErrorJustReturn: [])
let hideEmptyState = lists
.map({ items in
return !items.isEmpty
})
.asDriver(onErrorJustReturn: false)
let selectedList = taskList.asDriver()
output = Output(hideEmptyState: hideEmptyState, lists: items, selectedList: selectedList)
...
다시 또 정리해보자..
asDriver()
메소드를 사용해서 각 Output들을 driver로 변환만 해준다.그리고 Model에 접근하는 메소드 역시 ViewModel에서 작성한다.