3. MVVM: Model-View-ViewModel (3)

Seoyoung Lee·2024년 1월 12일
0

MyToDos Application Screens

이제 MVVM을 활용해서 투두 애플리케이션을 만들어보자.

MVVM은 MVP 아키텍처와 비슷하다. ViewModel이 Presenter와 비슷한 기능을 하기 때문이다.

MVP는 View와 Presenter의 결합도가 높다는 단점이 있다. 즉, View와 Presenter가 서로를 참조해야 한다.

하지만 데이터 바인딩을 사용하면 이 결합도를 낮출 수 있다!

AppDelegate and SceneDelegate

AppDelegate, SceneDelegate에서 첫 화면으로 쓰일 ViewController 객체를 생성한다. 이때 뷰컨 객체에 서비스에 대한 dependency를 전달하지 않는다. ViewModel 안에서 dependency를 주입할 것이기 때문!

Home Screen

Home Screen의 메인 컴포넌트는 HomeViewModel 이다. 이 뷰모델은 HomeView 에 바인딩되어 있다.

HomeViewController

뷰컨에서 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 패턴을 사용하면 뷰컨에서 화면전환 로직을 제거할 수 있다.

HomeView

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) {
        ...
    }
}

View의 컴포넌트와 ViewModel 바인딩하기

먼저 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)
  1. 사용자가 table view의 cell을 선택한다.
  2. 사용자가 선택한 cell의 indexPath를 ViewModel에 전달한다.
    1. itemSelected 는 선택한 셀의 indexPath를 반환해준다.
  3. 이에 대한 TaskListModel 을 output으로 방출한다.
addListButton.rx.tap
    .asDriver()
    .drive(onNext: { [self] in
        delegate?.addList()
    })
    .disposed(by: disposeBag)

버튼을 탭했을 때 실행될 로직은 위와 같이 작성한다. rx를 사용하면 버튼 이벤트를 tap 이라는 메소드로 간단하게 처리할 수 있다.

마지막으로 table view를 reload하는 코드를 추가한다.

viewModel.input.reload.accept(())

HomeViewModel

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)
        ...

다시 또 정리해보자..

  • Input: 사용자와 인터랙션이 발생하면 이를 Input 속성에게 전달(바인딩)한다. ViewModel 내에서 Input 속성들을 구독하고 있기 때문에 인터랙션이 일어난 후에 ViewModel에서 관련된 처리를 해줄 수 있다.
  • Output: Output 속성을 구독하고 무언가 처리하는 건 View이기 때문에, ViewModel에서는 asDriver() 메소드를 사용해서 각 Output들을 driver로 변환만 해준다.

그리고 Model에 접근하는 메소드 역시 ViewModel에서 작성한다.

profile
나의 내일은 파래 🐳

0개의 댓글