RxSwift使い始めの頃、DisposeBag
はおまじないのように書いてるだけだったのですが、UICollectionViewを実装するときにセルに置いていたボタンのタップイベントが無駄に多く発火していることに気づき、調べた結果DisposeBag
を正しく使えていなかったことが原因だとわかったので、ここにまとめておきます
ViewControllerのdisposeBagの使い回し
セルに配置しているボタンをタップしたらViewModelのbuttonTapped(product:)
を呼ぶという実装で、はじめはボタンタップイベントへのサブスクリプションをViewControllerのdisposeBag
に渡していました。
class ProductListViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
var vm: ProductListViewModelProtocol!
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
vm.products
.asDriver(onErrorJustReturn: [])
.drive(
collectionView.rx.items(
cellIdentifier: "ProductListCell",
cellType: ProductListCell.self
)
) { (_, element, cell) in
cell.button.rx.tap.asDriver()
.debug(element.id.description) // デバッグ
.drive(onNext: { _ in
self.vm.buttonTapped(product: element)
})
.disposed(by: self.disposeBag) // 問題箇所!!
}
.disposed(by: disposeBag)
}
これでどんな問題が起きるかというと、デバッグログを見るとわかるのですが、画面をスクロールしてセルをどんどん表示していくと、新しいセルが表示されるたびにcell.button.rx.tap
にサブスクライブしています。
以下の数字は表示しているセルの番号だと思ってください。
画面上で8個目のセルまで表示したときに、内部では10個目のセルまで設定がされているようです。
1 -> subscribed
2 -> subscribed
3 -> subscribed
4 -> subscribed
5 -> subscribed
6 -> subscribed
7 -> subscribed
8 -> subscribed
9 -> subscribed
10 -> subscribed
ここで8個目のセルのボタンをタップすると、以下のように3個目のセルも反応してしまっています。
3 -> Event next(())
vm.buttonTapped is called
8 -> Event next(())
vm.buttonTapped is called
セルのライフサイクルに合わせてdisposeBagを使う
UICollectionView(UITableView)ではメモリ効率化のためにセルを再利用するようになっています。
先程の例だと、3個目のセルと8個目のセルは同じUICollectionViewCellのインスタンスが使われているということになります。
このため、そのインスタンスのボタンタップイベントをサブスクライブしている2つのオブザーバが反応したというわけです。
この問題を解決するには、セルが再利用される際に前回のサブスクリプションをdisposeしてあげる必要があります。
改善したコードは以下の通りです。
class ProductListCell: UICollectionViewCell {
@IBOutlet weak var button: UIButton!
var disposeBag = DisposeBag()
...省略...
vm.products
.asDriver(onErrorJustReturn: [])
.drive(
collectionView.rx.items(
cellIdentifier: "ProductListCell",
cellType: ProductListCell.self
)
) { (_, element, cell) in
cell.disposeBag = DisposeBag()
cell.button.rx.tap.asDriver()
.debug(element.id.description) // デバッグ
.drive(onNext: { _ in
self.vm.buttonTapped(product: element)
})
.disposed(by: cell.disposeBag) // セルのdisposeBagを使う!!
}
.disposed(by: disposeBag)
セルのクラスにdisposeBag
プロパティを宣言し、ViewControllerでセルを再利用するときにdisposeBag
を初期化してあげます。
これによってもとのdisposeBag
オブジェクトはどこからも参照されなくなり、このdisposeBag
オブジェクトが管理しているサブスクリプションは全てdisposeされます。
先程の例でいうと、8個目のセルが表示されるとき、3個目のセルのボタンに対してのサブスクリプションはdisposeされるようになります。
ログを確認してみる以下のようになります。
セルが再利用されるとき、サブスクリプションはdisposeされます。
先ほどと同じように8個目のセルのボタンをタップすると、1回だけbuttonTapped(product:)
が呼ばれているのがわかります。
1 -> subscribed
2 -> subscribed
3 -> subscribed
4 -> subscribed
5 -> subscribed
1 -> isDisposed
6 -> subscribed
2 -> isDisposed
7 -> subscribed
3 -> isDisposed
8 -> subscribed
4 -> isDisposed
9 -> subscribed
9 -> isDisposed
5 -> subscribed
5 -> isDisposed
9 -> subscribed
5 -> isDisposed
10 -> subscribed
8 -> Event next(())
vm.buttonTapped is called
まとめ
上述のようにDisposeBag
は正しく使わないと問題を引き起こします。
無駄にイベントに反応してしまうだけでなく、リソースが解放されないためメモリリークの原因にもなります。
ちなみに、この問題に対応する際に知ったdebug
オペレータは非常に便利なので、是非活用してみてください。