この投稿は、AbemaTV Advent Calendar 2017 18日目の記事です。
こんにちは、稲見 (@inamiy) です。
今年の3月に AbemaTV にジョインして、iOSエンジニアをしています。
普段、業務では RxSwift を使っていますが、本家の ReactiveX に存在しない独自拡張である Driver
型 に関して常々思うところがあり、今回、自分の考えを整理してみたいと思います。
復習:Driver
について
- エラーを送らない
- メインスレッドでの送信保証
- 内部で
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があります。
順に見ていきましょう。
-
Driver
は、必ずしもHotではない -
Driver
のRxオペレータは、独自実装できない -
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
について、思いの丈を綴ってみました。
-
確認した範囲では、
RxJS.shareReplay
は、昔はmulticast
ベース、現在は.whileConnected
動作が仕様のようでした。 ↩ -
これらを些細な問題とみるか、設計ミスと捉えるかは、個人の自由です。 ↩