RxSwift で循環参照を起こさない書き方を考える


Binder の独自実装

RxSwift などでデータバインディングする場合、次のようにクロージャを渡すことがあります。

viewModel.text
    .subscribe(onNext: { [weak label] text in
        label?.text = text
    })
    .disposed(by: disposeBag) 

循環参照を起こさないように weak でオブジェクトをキャプチャする必要があります。

RxCocoa で提供されている Binder を使用すると、循環参照を起こさずに記述できます。
Observable<String?> を UILabel にバインディングする場合は、次のように記述します。

viewModel.text
    .bind(to: label.rx.text)
    .disposed(by: disposeBag)

rx.text は RxCocoa で次のように実装されています。

extension Reactive where Base: UILabel {
    public var text: Binder<String?> {
        return Binder(self.base) { label, text in
            label.text = text
        }
    }
}

上述の要領で Binder を独自に実装することができます。

extension Reactive where Base: ViewController {
    var text: Binder<String?> {
        return Binder(base) { vc, text in
            vc.label.text = text
        }
    }
}

このように Binder を実装することで、次のようなメリットがあります。

  • weak でオブジェクトをキャプチャするコードがなくなる
  • subscribe 部分からクロージャが消えてコードが簡潔に見えるようになる

Binder の独自実装による問題

しかし、次のような問題もあると思います。

  • 循環参照を完全に防げていない
  • 本質的ではない実装の量産

循環参照を完全に防げていない

次のような実装は、強い参照でキャプチャするので循環参照を引き起こします。

extension Reactive where Base: ViewController {
    var text: Binder<String?> {
        let vc = base
        return Binder(base) { _, text in // 第一パラメータを使用していない
            vc.setText(text) // vc を weak キャプチャしていない
        }
    }
}

Binder はクロージャの第一パラメータ使用することで、循環参照を防ぐことができます。
第一パラメータを使用しないのであれば、 weak キャプチャする必要があります。

結局、循環参照を防ぐためには、プログラマが実装時に注意を払う必要があるということになります。

本質的ではない実装の量産

Observable から値を受け取りたいがために、都度 Binder を実装することは、本質的ではない処理を量産していることを意味しています。

Rx を使用していなければ、値を受け取るためのセッターやメソッドがあれば十分だからです。

RxSwift を利用したとたんに、 subscribeBinder でクロージャを記述しなければならないことは、Rx を利用するメリットと同時にデメリットを授かっていると言えます。

循環参照を起こさない書き方を考える

上述の問題を冗長なコードと捉えて、処理を共通化してみます。
循環参照を防ぐための weak キャプチャする処理を共通化して、セッターやメソッドだけ渡して subscribe できるようにします。

ObservableType にメソッドを追加

RxSwift の ObservableType に次のようなメソッドを追加します。

import RxSwift
import RxCocoa

public extension ObservableType  {

    // セッターでバインド
    func bind<Object: AnyObject>(to object: Object, keyPath: ReferenceWritableKeyPath<Object, E>) -> Disposable {
        return bind { [weak object] element in
            object?[keyPath: keyPath] = element
        }
    }

    // メソッドでバインド
    func bind<Object: AnyObject>(to object: Object, selector: @escaping (Object) -> (E) -> Void) -> Disposable {
        return bind { [weak object] element in
            object.map(selector)?(element)
        }
    }
}

このような拡張をすると、subscribe 時にセッターやメソッドだけを渡すことができます。
循環参照も起きません。
次のようにして使います。

セッターでバインド

class ViewController: UIViewController {
    ....
    var text: String?
    ....
    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.text
            .bind(to: self, keyPath: \.text)
            .disposed(by: disposeBag)
    }

メソッドでバインド

class ViewController: UIViewController {
    ....
    func setText(_ text: String?) {
    }
    ....
    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.text
            .bind(to: self, selector: ViewController.setText)
            .disposed(by: disposeBag)
    }

ViewController.setText のように (クラス名).(インスタンスメソッド) のような記述をした場合は、インスタンスメソッドではなく、カリー化された関数になります。
上述の例の setText の場合は次のような型の関数です。

let foo: (ViewController) -> (String?) -> () = ViewController.setText

ViewController.setText 自体は特定の ViewController オブジェクトとは無関係な関数です。
bind(to: ... で渡している具体的なオブジェクトを内部の実装で weak キャプチャしているので循環参照は発生しません。

ReferenceWritableKeyPath についても同様に、特定のオブジェクトとは無関係ですので循環参照を回避することができます。