# STEP 2 PR @havilog 안녕하세요 하비! Brody, Rowan 조 STEP 2 PR 보내드립니다 🫡 정말 오래걸렸네요... compositionalLayout / MVVM에 대해 자세히 알아보는 시간이었습니다..! 이번 리뷰도 잘 부탁드립니다~~ --- ## 고민한 점 ### 🔸 의존성 주입 각 타입의 프로퍼티에 기본값을 할당하기 보다는 이니셜라이저를 통해 주입받을 수 있도록 했습니다. 예를 들어, DateFormatter의 경우 TaskCellViewModel에서 사용되기 때문에 기본값을 할당하면 여러개의 인스턴스가 생성됩니다. 하나의 인스턴스만 활용하기 위해 DateFormatter를 최상위 뷰컨트롤러에 만들어 사용되는 타입으로 전달할 수 있도록 하였습니다. 모든 프로퍼티가 Protocol타입으로 정의되어있지 않기 때문에 아직 완벽하게 Testable한 코드는 아니지만 protocol만 정의해주면 testable하게 될 것 같습니다. </br> ### 🔸 요구사항에 맞는 레이아웃 설계 초기에는 하나의 컬렉션 뷰에서 컴포지셔널 레이아웃으로 세 개의 테이블뷰가 보이도록 만들었지만 이는 확장성이 떨어지고 레이아웃 잡는 코드가 복잡하다는 것을 직접 코드를 작성하면서 느꼈습니다. 그래서 하나의 컬렉션 뷰에서 관리하는 것이 아닌 3개의 컬렉션 뷰를 만드는 것이 좋다고 생각했습니다. 하지만 3개의 컬렉션 뷰는 화면에 task를 보여주는 공통의 역할을 하기에 한단계 상위의 뷰컨에서 하나의 컬렉션 뷰 타입을 갖는 여러개의 인스턴스를 갖도록 만들었습니다. ```swift // mainViewController.swift self.addChild(TaskCollectionViewController(viewModel: todoViewModel, dateFormatter: dateFormatter)) self.addChild(TaskCollectionViewController(viewModel: doingViewModel, dateFormatter: dateFormatter)) self.addChild(TaskCollectionViewController(viewModel: doneViewModel, dateFormatter: dateFormatter)) ``` 그리고 추후 특정 뷰모델에만 기능이 추가되었을 경우. 예를 들어 `DoingViewModel`에만 특정 기능이 추가되었을 경우를 대비해서 하나의 뷰모델타입에서 `enum`타입을 이용해 분기처리하는 것이 아닌 컬렉션 뷰에 매칭되는 각각의 구체적인 뷰모델을 갖도록 만들었습니다. ```swift // 뷰모델에서 공통적으로 어떤 함수를 요청하고 받을 지를 추상화한 프로토콜 protocol TaskListViewModel { } final class TodoViewModel: TaskListViewModel { } final class DoingViewModel: TaskListViewModel { } final class DoneViewModel: TaskListViewModel { } ```` </br> ### 🔸 TaskListViewModel - taskList 감지하기 `@Published` attribute를 설정한 프로퍼티가 publisher를 통해 값을 발행하는 타이밍이 프로퍼티 `willSet`이라는 것을 공식문서를 통해 알게 되었습니다. 이에 따라 $를 통해 해당 프로퍼티의 퍼블리셔를 이용하여 sanpshot을 dataSource에 apply하는 메서드를 바인딩하게 되면, CollectionView에 taskList의 변경사항이 반영되지 않은 채로 snapshot apply가 진행되어 Out of range 크래시가 발생하는 문제가 있었습니다. (ex. createTask의 경우) 이를 해결하기 위해 taskList의 didSet과 PassthroughSubject를 활용하여 taskList가 set 되어진 이후 스트림에 변경된 list를 send 했습니다. ```swift var taskList: [Task] = [] { didSet { currentTaskSubject.send((taskList, isUpdating)) } } let currentTaskSubject = PassthroughSubject<([Task], Bool), Never>() ``` </br> ### 🔸 View 관련 Filetree 화면을 기준으로(main, detail, changeWorkState) 폴더를 나누고 매칭되는 뷰와 뷰모델을 모아서 정리했습니다. </br> ## 조언을 얻고싶은 점 ### 🔸 반응형 프레임워크 + Diffable DataSource에서 업데이트 시 전체가 reload되는 문제. 현재 `TodoViewModel`이라는 뷰모델 내의 `taskList`프로퍼티가 변경되었을 때 이를 구독하고 있는 `taskCollectionViewController`에서 변경된`taskList`을 받아, 이를 이용해 스냅샷을 업데이트하고 있습니다. ```swift private func bindViewModelToView() { viewModel .currentTaskSubject .sink { taskList, isUpdating in isUpdating ? self.reloadDataSourceItems() : self.applyLatestSnapshot(taskList) self.viewModel.setState(isUpdating: false) } .store(in: &bindings) } private func applyLatestSnapshot(_ taskList: [Task]) { let taskIDList = taskList.map { $0.id } let section = Section.main(count: viewModel.taskList.count) var snapshot = Snapshot() snapshot.appendSections([section]) snapshot.appendItems(taskIDList, toSection: section) dataSource?.apply(snapshot) } private func reloadDataSourceItems() { guard var snapshot = dataSource?.snapshot() else { return } let taskListID = viewModel.taskList.map { $0.id } snapshot.reloadItems(taskListID) dataSource?.apply(snapshot) } ``` 하지만 이 코드는 전달받은 `taskList`에 해당하는 `id`들을 전부 가져와 `reloadItems`를 하고 있기 때문에 특정 셀만 변경되었는데도 불구하고 전체 아이템이 reload되는 문제를 갖고 있습니다. 반응형 프레임워크를 사용하기 전에는 수정되는 task의 id를 반환해서 그 id만 reload한 뒤에 `dataSource.apply(snapshot)`을 호출해서 셀 하나만 업데이트 되도록 만들었는데 현재 상태에서 이와 같이 해결하는 방법을 찾지 못했습니다. 이 문제를 어떻게 해결하면 좋을까요? </br> ### 🔸 ViewModel이 TaskList를 갖고 있어도 되는가 현재 MainViewModel과 TaskListViewModel에 프로퍼티로 taskList를 가지고 있습니다. 이에 따라 해당 list에 append / remove 하는 등의 로직을 view model이 가지게 되는데 이는 뷰 로직이 아니라 데이터 로직으로 생각될 수도 있을 것 같습니다. 이를 해결하기 위해 Service에서 fetch 할 때, Publisher<[Task], Never>를 반환해주는 식으로 바꿔보는게 어떨까 생각해봤습니다.. 하비의 의견은 어떠신가요??? 🤔 </br> ### 🔸 cellProvider에서 셀을 dequeue했을 때 reuseIdentifier가 없을 때 처리 현재 컬렉션 뷰를 갖고있는 `TaskCollectionViewController`안에서 `DiffableDatasource`에 전달하는 `cellProvider`는 다음과 같이 구현되어있습니다. ```swift private func cellProvider(_ collectionView: UICollectionView, indexPath: IndexPath, identifier: Task.ID) -> UICollectionViewCell? { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: TaskCell.identifier, for: indexPath ) as? TaskCell else { fatalError("cell does not have a reuseIdentifier") } guard let task = self.viewModel.taskList.filter({ $0.id == identifier }).first else { fatalError("There is nonexistent identifier") } let taskCellViewModel = TaskCellViewModel(task: task, dateFormatter: dateFormatter) cell.provide(viewModel: taskCellViewModel) return cell } ``` snapshot에서 주입하는 id가 뷰모델의 `taskList`와 매칭되지 않을 때 `fatalError`로 크래시나게 만들어주고 있습니다. **fatalError를 선택한 이유** * guard 문에서 else에 걸렸을 때 `UICollectionViewCell()`을 리턴해준 경우, cell이 dequeueReusableCell을 통해 반환되지 않았다는 크래시가 발생함 반면에, UICollectionViewDataSource를 채택했을 때는 cellForItemAt 메서드에서 빈 UICollectionViewCell을 반환하는 것이 가능했습니다. 앱을 일부러 크래시 내는 코드는 피하는 것이 좋다고 생각하는데, DiffableDataSource에서도 fatalError를 사용하지 않고 빈 Cell을 반환해주는 방법을 활용할 수 있을까요?