Skip to content

셀 재사용에 따른 중복 binding 이슈(feat.disposeBag)

Yongjae Kim edited this page Apr 9, 2024 · 2 revisions

[관련 블로그 링크]

문제상황 -> 해결형식으로 바꾸기!...(링크도!... or 토글!...)

  • 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와 같이 무의식적으로 작성하는 코드에대해서도 의식적으로 고민을 해야할 필요성을 느끼게 되었다.