RxSwift.Driver についての個人的見解


この投稿は、AbemaTV Advent Calendar 2017 18日目の記事です。

こんにちは、稲見 (@inamiy) です。
今年の3月に AbemaTV にジョインして、iOSエンジニアをしています。
普段、業務では RxSwift を使っていますが、本家の ReactiveX に存在しない独自拡張である Driver に関して常々思うところがあり、今回、自分の考えを整理してみたいと思います。

復習:Driverについて

  1. エラーを送らない
  2. メインスレッドでの送信保証
  3. 内部で share(replay: 1, scope: .whileConnected) を行う
    • 基本動作は ReactiveX.shareReplay(1) と似ている
      • shareReplay(1) = multicast(ReplaySubject(bufferSize: 1)).refCount()
    • ただし、実際の作りは ConnectableObservable + ReplaySubject を中継に挟まない
    • observer の数が0になった時、内部キャッシュ (ShareReplay1WhileConnectedConnection) を破棄し、数が再び1になったら、再生成する
      • shareReplay(1) で 内部キャッシュの ReplaySubject が破棄されないのとは対照的
      • メモリ効率の良いHot変換
      • 過去の古いキャッシュに引きずられないメリット

Driver について良く知られている性質は、大体この通りだと思います。
(他言語のRxユーザーは、 share(replay: 1, scope: .whileConnected) の挙動に若干戸惑うかもしれません1

さて、あまり知られていないかもしれませんが、Driverには下記の問題点2があります。
順に見ていきましょう。

  1. Driver は、必ずしもHotではない
  2. Driver のRxオペレータは、独自実装できない
  3. Driver のメソッドチェーンは、メモリを大量消費する

1. Driver は、必ずしもHotではない

早速ですが、RxSwift v4.0.0 で次の単純なコードを実行してみます。

let o = Observable.of(1, 2)
    .map { x -> Int in
        let now = Date().timeIntervalSince1970
        print("[map] \(x) -> \(now)")
        return x
    }
    .share(replay: 1, scope: .whileConnected)

print("--- 1 ---")
o.subscribe()
print("--- 2 ---")
o.subscribe()

結果はこうなります。

--- 1 ---
[map] 1 -> 1513433642.38432
[map] 2 -> 1513433642.38482
--- 2 ---
[map] 1 -> 1513433642.38628
[map] 2 -> 1513433642.38645

なんと、 値がまったくシェアもリプレイもされていません
これでは、Cold Observableと振る舞いが変わらないですね。
(もちろん、share(...)asDriver(...)に置き換えても同じ)

どうしてこうなったかは、ShareReplayScope.swiftを読むとすぐに分かります。
Observerの数が0になると呼ばれる_synchronized_dispose()が、上流のonError/onCompleted時にも呼ばれているからです。
すなわち、 川の最上流にColdを持ってくると、そのonError/onCompleted直後にDriverはColdとして振る舞います

余談ですが、この挙動は .whileConnected であれば、 replayCount = 1 以外でも同様に起こります。
RxSwift v4.0.0以降では、 share() を使ってHot変換したコードはすべて上記の振る舞いに化けますので、ご注意下さい。
(ただし、通常の用途で、例題のようなエッジケースが発生することは稀だと思います)

2. Driver のRxオペレータは、独自実装できない

これは Driver に限らず、 SharedSequence 全般について、Rxオペレータを独自で実装することができません

理由は、SharedSequence.swift#L30-L48 を見ると分かるように、

  • (internal) init なので、外部公開されておらず、Driverを生成することが難しい
  • SharedSequence.createUnsafe()があるけど、#if EXPANDABLE_SHARED_SEQUENCE マクロからして、とても使える感じがしない

からです。

「いやいや、asDriver()があるから余裕でしょ」と思われる方は、実際にDriver.mapを自作してみましょう。
多相的関数の中で、asDriver(/* NoError変換 */)の使用が、型システムによって阻まれる 様子が分かります。

「そこでControlEventを経由して.asDriver(/* 引数なし */)ですよ」と言える方は、なかなか勘が鋭いです。
が、その発想は、ControlEventの非安全なイニシャライザの弱点を突いているほか、余計なsubscribeOn()も付いてしまったり、やはり賢明なアプローチとは言えません。

3. Driver のメソッドチェーンは、メモリを大量消費する

これが見落としがちで一番重要な点ですが、 Driver を1つ生成 → drive (subscribe)」する度に、シェア用の内部キャッシュが1つ生まれている ことを忘れてはいけません。

つまり、

let o = source
    .asDriver(onErrorDriveWith: .empty())
    .map { ... }
    .map { ... }
    .map { ... }
    ...

は、実質

let o = source
    .share(replay: 1, scope: .whileConnected)
    .map { ... }.share(replay: 1, scope: .whileConnected)
    .map { ... }.share(replay: 1, scope: .whileConnected)
    .map { ... }.share(replay: 1, scope: .whileConnected)
    ...

に相当し、 Driver の各種Rxオペレータを多用すればするほど、drive時にその数だけのコピーが大量発生します(Observable.Eが値型の場合)。

もちろん、FRP(関数型リアクティブプログラミング)自体が、各々のRxオペレータに状態(内部キャッシュ)を閉じ込めて副作用を気軽に扱うツールである以上、富豪プログラミングの一種ですし、良識のある社会人なら多少非効率的であっても目を瞑るものですが、 全く活用されないキャッシュを作るのは、たとえ個々がbyte単位で軽量であっても無駄 です。

なので、良識のない個人的な意見としては、 Driver のRxオペレータは一切使わず、UIバインディング (drive) だけに専念する 使い方が一番賢いと思います。

なお、この 「Hot化する際に(ConnectableObservableを含めた様々な)状態を必要とする設計」は、RxSwiftに限らず、 ColdとHotを型レベルで区別しない、すべてのFRPフレームワークに共通する問題なので、広く一般認識として持っておくと良いでしょう。

ちなみにSwiftには既に、ReactiveXの概念をさらに発展させて、Cold/Hotを型で区別した より効率の良いフレームワークが存在します。
詳しくは下記のポエム(英語)を読んで、効率の違いを確かめてみて下さい。

まとめ

RxSwift.Driver について、思いの丈を綴ってみました。


  1. 確認した範囲では、RxJS.shareReplayは、昔はmulticastベース、現在は.whileConnected動作が仕様のようでした。 

  2. これらを些細な問題とみるか、設計ミスと捉えるかは、個人の自由です。