기존에 진행했던 프로젝트를 RxSwift로 갈아 엎는 리팩토링 진행 중에 있습니다.
RxCollectionView를 사용하는 도중, 여러 개의 섹션을 구분 짓기 위해 RxDataSources를 사용하였습니다.
이번 포스팅에서는 해당 기능을 통해 구현 도중 있었던 트러블슈팅 중 첫 번째 입니다.
제가 이 프로젝트에서 사용하는 모델은 두 개입니다.
Section은 확장 가능한 테이블뷰 형태(컬렉션뷰로 구현)의 섹션이며, 타이머 리스트를 가지고 있습니다.
MyTimer는 타이머에 필요한 정보들을 가지고 있습니다.
struct Section: Codable {
var id: UUID
var title: String
var isExpanded: Bool
var createdDate: Date
var items: [MyTimer]
init(id: UUID, title: String, isExpanded: Bool, createdDate: Date, items: [MyTimer]) {
self.id = id
self.title = title
self.isExpanded = isExpanded
self.createdDate = createdDate
self.items = items
}
}
// MARK: - RxDataSources
extension Section: Equatable, IdentifiableType, AnimatableSectionModelType {
typealias Identity = UUID
typealias Item = MyTimer
var identity: UUID {
return id
}
init(original: Section, items: [Item]) {
self = original
self.items = items
}
}
// MARK: - Update
extension Section {
mutating func updateIsExpanded() {
self.isExpanded.toggle()
}
mutating func updateTitle(title: String) {
self.title = title
}
}
struct MyTimer: Codable {
var sectionID: UUID
var id: UUID
var title: String
var min: Int
var sec: Int
var createdDate: Date
init(sectionID: UUID, id: UUID, title: String, min: Int, sec: Int) {
self.sectionID = sectionID
self.id = id
self.title = title
self.min = min
self.sec = sec
self.createdDate = Date()
}
}
// MARK: - RxDataSources
extension MyTimer: Equatable, IdentifiableType {
typealias Identity = UUID
var identity: UUID {
return id
}
}
// MARK: - Update
extension MyTimer {
mutating func updateTimer(sectionID: UUID, title: String, min: Int, sec: Int) {
self.sectionID = sectionID
self.title = title
self.min = min
self.sec = sec
}
}
저의 데이터 흐름은 크게 이렇게 되어 있습니다.
Storage -> TimerManager -> ViewModel -> ViewController
Storage에선 두 개의 모델을 저장하고 불러오는 역할을 하며, BehaviorRelay 타입으로 가지고 있습니다.
TimerManager는 Storage 객체를 가지고 있으며, 싱글톤 패턴으로 정의되어 있습니다.
Storage와 연동하여 데이터들의 변화를 감지하고 저장하는 등의 역할을 합니다.
TimerListViewModel은 TimerListViewController와 바인딩하여 섹션과 타이머 리스트를 화면에 보여주는 역할을 합니다.
ViewModel은 TimerManager로부터 데이터를 받아옵니다.
DataSource가 업데이트가 되면, Storage부터 ViewController까지 모두 데이터가 정상적으로 업데이트 되는 상황입니다.
하지만, CollectionView에선 업데이트된 데이터를 감지하지 못하고 변경되기 전의 데이터를 보여주고 있었습니다.
// BehaviorRelay<[Section]>과 <[MyTimer]>를 Driver로 전달합니다.
// Section이 타이머들을 처음부터 가진게 아니라 여기서 자신의 아이디를 가진 타이머들을 items에 추가합니다.
// 두 모델을 독립적으로 관리하여 수정에 유리하도록 이렇게 구현하였습니다.
func getData() -> Driver<[Section]> {
return sections
.map { [weak self] sections -> [Section] in
let timerDict = Dictionary(grouping: self?.timers.value ?? [], by: { $0.sectionID })
let sortedSections = sections.sorted(by: { $0.createdDate > $1.createdDate })
return sortedSections.map { section in
let timers = section.isExpanded
? (timerDict[section.id] ?? []).sorted(by: { $0.createdDate > $1.createdDate })
: []
return Section(id: section.id, title: section.title, isExpanded: section.isExpanded, createdDate: section.createdDate, items: timers)
}
}
.asDriver(onErrorJustReturn: [])
}
// 섹션 업데이트 시에 호출되는 메서드
private func updateStorageSections(_ completion: (inout [Section]) -> Void) {
var sections = sections.value
completion(§ions)
storage.setSectionData(data: sections)
}
// Input Output의 형태로 ViewController와 바인딩 합니다.
struct Input {
let menuButtonTapEvent: Observable<Void>
let addSectionButtonTapEvent: Observable<Void>
let addTimerButtonTapEvent: Observable<Void>
let settingsButtonTapEvent: Observable<Void>
let controlViewTapEvent: Observable<Void>
}
struct Output {
let data: Driver<[Section]>
let showButtons: Signal<Void>
let presentAddSectionViewController: Signal<Void>
let presentAddTimerViewController: Signal<Void>
let presentSettingsViewController: Signal<Void>
}
func transform(input: Input) -> Output {
let dataModel = TimerManager.shared.getData()
let showButtons = Signal.merge(
convertObervableToSignal(input.menuButtonTapEvent),
convertObervableToSignal(input.controlViewTapEvent)
)
let presentAddSectionViewController = convertObervableToSignal(input.addSectionButtonTapEvent)
let presentAddTimerViewController = convertObervableToSignal(input.addTimerButtonTapEvent)
let presentSettingsViewController = convertObervableToSignal(input.settingsButtonTapEvent)
return Output(
data: dataModel,
showButtons: showButtons,
presentAddSectionViewController: presentAddSectionViewController,
presentAddTimerViewController: presentAddTimerViewController,
presentSettingsViewController: presentSettingsViewController)
}
// 컬렉션뷰를 설정하는 부분
let dataSource = RxCollectionViewSectionedAnimatedDataSource<Section>(
configureCell: { dataSource, collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TimerListCell.id, for: indexPath) as? TimerListCell else {
return UICollectionViewCell()
}
let section = dataSource.sectionModels[indexPath.section]
print("---- Item ---- \n", item)
cell.setupBindings(section: section, timer: item) { [weak self] sectionID, timerID in
self?.didTapTimerButtons(sectionID: sectionID, timerID: timerID)
}
return cell
},
configureSupplementaryView: { dataSource, collectionView, kind, indexPath in
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: TimerListHeaderView.id, for: indexPath) as? TimerListHeaderView else {
return UICollectionReusableView()
}
let section = dataSource.sectionModels[indexPath.section]
print("---- Section ---- \n", section)
header.setupBindings(section: section) { [weak self] type, id in
self?.didTapSectionButtons(type: type, id: id)
}
return header
})
let input = TimerListViewModel.Input(
menuButtonTapEvent: timerListView.menuButton.rx.tap.asObservable(),
addSectionButtonTapEvent: timerListView.addSectionButton.rx.tap.asObservable(),
addTimerButtonTapEvent: timerListView.addTimerButton.rx.tap.asObservable(),
settingsButtonTapEvent: timerListView.settingsButton.rx.tap.asObservable(),
controlViewTapEvent: timerListView.controlView.rx.tapGesture().when(.recognized).map { _ in }.asObservable()
)
let output = viewModel.transform(input: input)
// 데이터를 컬렉션뷰에 바인딩하는 부분
output.data
.do(onNext: { [weak self] data in
self?.displayNoTimersLabel(isEmpty: data.isEmpty)
})
.drive(timerListView.collectionView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
해당 과정에서, header(Section 데이터를 보여주는 헤더 뷰)는 해당 Section의 title을 업데이트 하는 버튼을 가지고 있습니다.
이를 통해 Section의 title을 업데이트를 하면, 데이터는 정상적으로 업데이트 됩니다.
ViewController의 output.data에 까지 변경된 데이터가 잘 들어오죠. 하지만, 컬렉션뷰에서는 이전의 제목만 보여준다는 엄청난 문제가 발생했었습니다ㅠㅠ
ViewController에서 변경된 데이터가 들어올 때마다 컬렉션뷰를 reload하여 데이터 새로고침을 시도했습니다.
하지만 조금 이상하게 작동하더군요.. 2번 업데이트 하면 이전 업데이트가 적용된다거나..
아마도 0.2~3초 정도 텀을 주면 적용 될 수도 있겠다 싶었지만 뭔가 아닌 것 같아서 다른 방법을 찾기로 했습니다.
RxCollectionView update, RxDataSources update 등등의 키워드를 넣어서 검색해보았는데 원하는 정보가 없었습니다.
요즘 지선생님을 통해 모르는 부분을 많이 질문하거나 효율적인 코드가 어떤 것인지 보기 위해 자주 사용하는데요, 역시 이 질문 또한 코드와 함께 해봤습니다.
하지만 답변은 계속해서 다음과 같았죠.
- 데이터 소스가 올바르게 설정되었는지 확인하기: RxCollectionViewSectionedAnimatedDataSource가 올바르게 설정되었는지, 그리고 데이터 소스가 변경될 때 올바르게 바인딩되고 있는지 확인합니다.
- 프로토콜 준수: Section과 Timer 모델이 IdentifiableType 및 Equatable 프로토콜을 준수하는지 확인합니다. 이 부분은 데이터가 올바르게 비교되고 변경되었음을 인식하는 데 중요합니다.
- RxDataSource 바인딩 확인: RxDataSources와 CollectionView가 올바르게 바인딩되어 있는지 확인합니다.
- 데이터 변경 확인: sectionsRelay와 같은 데이터 소스가 변경될 때 올바르게 새 값을 방출하는지 확인합니다.
하라는 대로 다 해봤지만 해결되지 않았습니다..
2번째 방법에서 구글링 했을 때는 안보였었는데..
1주일동안 이것저것 해도 안되길래 멘탈이 흔들릴 때 쯤 갑자기 RxDataSources 레포에 누군가 자와 같은 상황이 있지 않을까 생각이 떠올랐습니다.
그래서 들어가 봤더니 역시나 있었습니다!!
1주일동안 고구마 상태였다가 드디어 해결할 수 있었습니다.
HeaderView is not updating when collectionview's datasource is changed.
어느 은인 분께서 decideViewTransition 이란 걸 사용하면 된다고 알려주셨고.. 정말 해결이 되었습니다 흑흑..
let dataSource = RxCollectionViewSectionedAnimatedDataSource<Section>(
// 추가된 부분
decideViewTransition: { _, _, changeset in
return changeset.isEmpty ? .reload : .animated
},
configureCell: { dataSource, collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TimerListCell.id, for: indexPath) as? TimerListCell else {
return UICollectionViewCell()
}
let section = dataSource.sectionModels[indexPath.section]
print("---- Item ---- \n", item)
cell.setupBindings(section: section, timer: item) { [weak self] sectionID, timerID in
self?.didTapTimerButtons(sectionID: sectionID, timerID: timerID)
}
return cell
},
configureSupplementaryView: { dataSource, collectionView, kind, indexPath in
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: TimerListHeaderView.id, for: indexPath) as? TimerListHeaderView else {
return UICollectionReusableView()
}
let section = dataSource.sectionModels[indexPath.section]
print("---- Section ---- \n", section)
header.setupBindings(section: section) { [weak self] type, id in
self?.didTapSectionButtons(type: type, id: id)
}
return header
})
decideViewTransition { }
을 RxCollectionViewSectionedAnimatedDataSource에 추가해주니 해결이 됐습니다.
데이터를 변경하면 즉시 컬렉션뷰에서 인지하고 잘 바뀌네요.
그래서 알아봤습니다.
decideViewTransition이 뭐냐?
딱히 레포에도 이게 뭐다! 라는 설명은 없어 코드를 까봤습니다.
RxCollectionViewSectionedAnimatedDatSource.swift 파일입니다.
public typealias DecideViewTransition = (CollectionViewSectionedDataSource<Section>, UICollectionView, [Changeset<Section>]) -> ViewTransition
/// Calculates view transition depending on type of changes
public var decideViewTransition: DecideViewTransition
ViewTransition 타입에 맞게 뷰를 전환해주는 변수라고 주석문으로 써있네요.
public init(
animationConfiguration: AnimationConfiguration = AnimationConfiguration(),
decideViewTransition: @escaping DecideViewTransition = { _, _, _ in .animated },
configureCell: @escaping ConfigureCell,
configureSupplementaryView: ConfigureSupplementaryView? = nil,
moveItem: @escaping MoveItem = { _, _, _ in () },
canMoveItemAtIndexPath: @escaping CanMoveItemAtIndexPath = { _, _ in false }
) {
self.animationConfiguration = animationConfiguration
self.decideViewTransition = decideViewTransition
super.init(
configureCell: configureCell,
configureSupplementaryView: configureSupplementaryView,
moveItem: moveItem,
canMoveItemAtIndexPath: canMoveItemAtIndexPath
)
}
명시적으로 선언하지 않으면 기본 값이 .animated 입니다.
open func collectionView(_ collectionView: UICollectionView, observedEvent: Event<Element>) {
Binder(self) { dataSource, newSections in
// 생략
let oldSections = dataSource.sectionModels
do {
let differences = try Diff.differencesForSectionedView(initialSections: oldSections, finalSections: newSections)
switch dataSource.decideViewTransition(dataSource, collectionView, differences) {
case .animated:
// each difference must be run in a separate 'performBatchUpdates', otherwise it crashes.
// this is a limitation of Diff tool
for difference in differences {
let updateBlock = {
// sections must be set within updateBlock in 'performBatchUpdates'
dataSource.setSections(difference.finalSections)
collectionView.batchUpdates(difference, animationConfiguration: dataSource.animationConfiguration)
}
collectionView.performBatchUpdates(updateBlock, completion: nil)
}
case .reload:
dataSource.setSections(newSections)
collectionView.reloadData()
return
}
}
// 생략
decideViewTransition이 사용되는 부분 입니다.
performBatchUpdates를 통해 애니메이션 효과와 함께 데이터의 변경사항을 적용하고 있습니다.
decideViewTransition을 명시적으로 선언해주지 않은 경우(문제 해결 전 코드)엔 .animated 부분만 적용이 됩니다.
때문에, 데이터가 복잡하거나 많은 경우엔 애니메이션 업데이트의 제한 때문에 적절한 업데이트가 이루어지지 않을 수도 있다고 합니다.
저의 문제는 Section을 추가하거나 삭제하면 변경 사항이 제대로 이루어졌지만, 내부 속성을 변경하면 적용이 안되었었습니다.
decideViewTransition: { _, _, changeset in
return changeset.isEmpty ? .reload : .animated
}
해당 코드를 추가하였을 때 정상적으로 작동한 것을 보면,
데이터모델에 새로운 모델이 추가되거나 기존 모델이 삭제되면 changeset에서 데이터의 변화를 잘 감지하지만,
기존 모델의 내부 속성을 변경하면 데이터 변화를 감지하지 못한 것 같습니다.
그렇기 때문에, 해당 경우엔 전체를 reload하여 적절하게 데이터 변경사항이 반영된 것 같네요.
1주일간의 디버깅이 이제 끝나서 참 다행입니다만 또 언제 이런 문제가 터질지 모르니 열심히 공부해야겠네요.
또한, RxSwift는 역시 써드파티 라이브러리이다 보니 해결 방안을 찾는 데에 있어 시간이 좀 더 걸렸다고 생각했습니다.
SwiftUI와 함께 Combine도 빨리 공부하는 날이 왔으면 좋겠네요.