최근 RxSwift를 활용한 프로젝트 속 diffable datasource를 통해서 collection view를 구현하던 중, collection view의 아이템이 많아지니 collection view에 binding된 여러 요소들에게서 예상치 못한 에러가 발생했다.
먼저 에러가 생기는 기존의 코드를 확인해보자.
self.datasource = UICollectionViewDiffableDataSource(
collectionView: self.collectionView,
cellProvider: { [weak self ] collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TicketListCollectionViewCell.className,
for: indexPath
) as? TicketListCollectionViewCell else { return UICollectionViewCell() }
cell.setData(with: item)
if item.ticketStatus == .notUsed {
self?.bindQRCodeExpandView(cell, with: item)
}
return cell
})
///
private func bindQRCodeExpandView(_ cell: TicketListCollectionViewCell, with item: TicketItemEntity) {
let qrCodeImageView = cell.ticketInformationView.qrCodeImageView
let ticketName = item.ticketName
qrCodeImageView.rx.tapGesture()
.when(.recognized)
.asDriver(onErrorDriveWith: .never())
.drive(with: self) { owner, _ in
guard let QRCodeImage = qrCodeImageView.image else { return }
let viewController = owner.qrExpandViewControllerFactory(QRCodeImage, ticketName)
viewController.modalPresentationStyle = .fullScreen
owner.present(viewController, animated: true)
}
.disposed(by: self.disposeBag)
}
위 코드는 cell의 qrCodeImageView에다가 tapGesture를 붙히는 과정이다. 그래서 cell의 qrCodeImageView를 탭하는 제스처를 핸들링할 수 있었다.
근데, collection view의 데이터가 많아지니 cell의 qrCodeImageView를 탭하면 원하는 정보를 띄어주는 것이 아니라, 다른 cell의 정보를 띄어주는 것을 알 수 있었다. 또한, 탭을 한번만 했는데도 탭 체스처가 여러 번 입력되는 에러 또한 발생했다.
일단, 다른 정보가 띄어진다는 부분에서 무조건 셀의 재사용 이슈 때문이라는 것을 짐작할 수 있었다.
아래의 그림과 같이 CollectionView나 TableView는 매 cell을 새로 생성하지 않는다. 화면에서 지워진 cell을 queue에 집어넣고, 화면에서 나타날 cell을 queue에서 dequeue를 하여 활용한다. 그래서 우리는 Cell의 prepareForReuse를 활용하여 cell의 내용들을 초기화하고, setData를 통해서 원하는 데이터를 보여준다.
또한, 아래의 코드에서 qrCodeImageView의 탭 제스처 구독의 결과 값인 disposable을 self(view controller)의 diseposeBag에 넣는다.
qrCodeImageView.rx.tapGesture()
.when(.recognized)
.asDriver(onErrorDriveWith: .never())
.drive(with: self) { owner, _ in
guard let QRCodeImage = qrCodeImageView.image else { return }
let viewController = owner.qrExpandViewControllerFactory(QRCodeImage, ticketName)
viewController.modalPresentationStyle = .fullScreen
owner.present(viewController, animated: true)
}
.disposed(by: self.disposeBag)
Disposable/DisposeBag
여기서 잠깐 disposable과 disposeBag에대해서 간단하게 확인을 해보자.
우리가 생성한 subscription의 disposable을 disposeBag에 넣는 메소드는 아래와 같이 구현이 되어있다.
기본적으로 자신의 bag에 disposable을 넣는 것이다.
extension Disposable {
/// Adds `self` to `bag`
///
/// - parameter bag: `DisposeBag` to add `self` to.
public func disposed(by bag: DisposeBag) {
bag.insert(self)
}
}
여기서 disposable은 아래와 같이 구현이 되어있다.
쉽게 말해서 우리가 흔히하는 Disposable.disposed(by: self.disposeBag)는 해당 disposable을 disposables라는 disposable을 담는 배열에 append하는 것이다. 여기서 중요한 부분은 deinit 부분이다. 만약 disposeBag가 deinit되면 dispose 메소드가 실행이되고, 이는 자신의 disposables에 들어가 있는 disposable들을 싹 다 비워버리는 것이다. 이 과정은 disposable들 즉, subscription들을 끊어버리는 과정이라고 생각할 수 있다.
public final class DisposeBag: DisposeBase {
private var disposables = [Disposable]()
public func insert(_ disposable: Disposable) {
self._insert(disposable)?.dispose()
}
private func _insert(_ disposable: Disposable) -> Disposable? {
self.lock.performLocked {
if self.isDisposed {
return disposable
}
self.disposables.append(disposable)
return nil
}
}
/// This is internal on purpose, take a look at `CompositeDisposable` instead.
private func dispose() {
let oldDisposables = self._dispose()
for disposable in oldDisposables {
disposable.dispose()
}
}
private func _dispose() -> [Disposable] {
self.lock.performLocked {
let disposables = self.disposables
self.disposables.removeAll(keepingCapacity: false)
self.isDisposed = true
return disposables
}
}
deinit {
self.dispose()
}
}
그렇다면 우리는 맨 위에서 말했던 cell의 재사용 이슈에 따른 중복 바인딩 이슈를 어떻게 해결할 수 있을까?
먼저 기존의 self.disposeBag에 넣었던 subscription의 disposable을 cell의 disposeBag에 넣는다.
final class TicketListCollectionViewCell: UICollectionViewCell {
var disposeBag = DisposeBag()
}
//...//
qrCodeImageView.rx.tapGesture()
.when(.recognized)
.asDriver(onErrorDriveWith: .never())
.drive(with: self) { owner, _ in
guard let QRCodeImage = qrCodeImageView.image else { return }
let viewController = owner.qrExpandViewControllerFactory(QRCodeImage, ticketName)
viewController.modalPresentationStyle = .fullScreen
owner.present(viewController, animated: true)
}
.disposed(by: cell.disposeBag)
그리고 우리는 해당 cell의 disposeBag을 cell의 재사용될 때마다 새롭게 갈아끼워줘야된다. 아래와 같이 cell을 뽑았을 때 설정해줘도 되고, 그게 아니라 prepareForReuse를 활용할 수 있다.
self.datasource = UICollectionViewDiffableDataSource(
collectionView: self.collectionView,
cellProvider: { [weak self ] collectionView, indexPath, item in
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TicketListCollectionViewCell.className,
for: indexPath
) as? TicketListCollectionViewCell else { return UICollectionViewCell() }
cell.disposeBag = DisposeBag()
cell.setData(with: item)
if item.ticketStatus == .notUsed {
self?.bindQRCodeExpandView(cell, with: item)
}
return cell
})
/// 아니면 cell의 prepareForReuse
override func prepareForReuse() {
super.prepareForReuse()
self.ticketNumberLabel.text = nil
self.ticketTypeLabel.text = nil
self.ticketInformationView.resetData()
self.disposeBag = DisposeBag()
}
이러한 과정을 통해서 맨 위에서 말했던 재사용 이슈에 따른 중복 바인딩 에러를 해결할 수 있었다.
앞으로 collection view나 table view를 활용할 때 재사용에 관한 고민을 더 철저히 하고(데이터가 많으면 많을수록) disposeBag와 같이 무의식적으로 작성하는 코드에대해서도 의식적으로 고민을 해야할 필요성을 느끼게 되었다.
'Swift' 카테고리의 다른 글
[Swift] Async, Await (2) [feat. WWDC] (1) | 2023.08.28 |
---|---|
[Swift] Async, Await(1) (feat. WWDC) (0) | 2023.08.22 |
[Swift] WWDC21 - ARC in Swift: Basics and Beyond (0) | 2023.08.15 |
[Swift] [weak self]는 언제 사용할까? (0) | 2023.08.11 |
[Swift] WWDC16 Understanding Swift Performance(3) (0) | 2023.08.03 |