こんにちは、delyでクラシルiOSアプリ開発を担当している稲見 (@inamiy)です。 この記事は「dely #2 Advent Calendar 2020」の25日目の記事です。
昨日は、delyのSREチームのjoooee0000(高山)さんによる delyのSREチームがオンコールトレーニングを導入する3つの理由 の記事でした。 オンコール対応できるエンジニア、強くてカッコいい・・・
私の方からは、メリークリスマス🎄🎅🔔 にふさわしい Elm Architecture による unidirectional なプレゼントをお届けします🎁
(2020/12/26 EDIT: タイトルを「なぜ MVVM は Elm Architecture に勝てないのか」から「なぜ MVVM + FRP は Elm Architecture に勝てないのか」に変更しました)
iOS開発における MVVM + FRP
2020年現在、多くのiOSアプリ開発の現場では、RxSwift等を用いた関数型リアクティブプログラミング (Functional Reactive Programming, FRP) によるMVVM (Model-View-ViewModel) 設計が主流だと思います。
MVVMは、テストしにくい UIViewController
からアプリの表示ロジックを別クラス (ViewModel) に切り出し、複雑なビュー構造(=可変参照と副作用の悪夢)から解放されて、コードの可読性の向上と、テストをよりシンプルに書くための基本的な設計パターンとなっています。
従来のiOS開発では、ViewController〜ViewModel 間のデータのやり取りについて、
- データの保持に「可変状態 (ViewModel内のvar変数など)」
- データの送信に「委譲(デリゲート)」や「コールバック」
を使った方法が考えられてきました。
// Before: 従来のViewModel public class ViewModel { // 可変状態 public var state: String // コールバック付き関数 public func doSomething(callback: (String) -> Void) { let result = state + " world" // 何か計算する callback(result) // コールバックで返す } } // Before: 従来のViewController class ViewController: UIViewController { let viewModel: ViewModel ... func viewDidLoad() { ... viewModel.state = "hello" // 1. 状態をセットする // 2. ビュー側から手動でメソッドを呼び出して、計算結果をコールバックで受け取る viewModel.doSomething(callback: { text in print(text) // 3. hello world が出力される }) } }
しかし、FRPの登場によって、これまでの pull方式(状態の更新とコールバックの呼び出しタイミングが異なる2ステップ)から push方式(状態の更新と同時にコールバックが自動的に呼び出される1ステップ)に一変しました。
可変状態として BehaviorRelay
(BehaviorSubject
)、コールバックとしてそのストリーム機能部分である Observable
が用いられるようになりました。
// After: FRPを使ったViewModel public class ViewModel { // 可変状態 public let state: BehaviorRelay<String> // ストリーム出力 public func doSomething() -> Observable<String> { return state.asObservable().map { $0 + " world" } } } // After: FRPを使ったViewController class ViewController: UIViewController { let viewModel: ViewModel ... func viewDidLoad() { ... // 1'. 先に購読(監視)して準備する viewModel.doSomething().subscribe(onNext: { text in print(text) // 3'. hello world が出力される }) // 2'. 状態をセットすると、3が自動的に呼ばれる(同じコールスタック内) viewModel.state.accept("hello") } }
一見すると、1と2の順序が逆転しただけに見えますが、After の2' を毎回呼び出す度に、3' 内の購読のクロージャが自動的に呼び出される のに対して、Before 1 の場合は何度呼び出しても、追加で 2 を呼ばないと最新の状態を用いた計算を実行することができません。 つまり、 FRPの購読機能(オブザーバーパターン)によって、メソッドを毎回手動で呼び出す手間が省ける ことがFRPの利点の一つです。
iOSのUI開発においては、ビュー側でViewModel内の表示用データの Observable
を購読することによって、データバインディングという形で「1回の購読だけでUIの連続更新が可能」になります。
以降の話では、「iOS開発における MVVM + FRP パターン」をまとめて「MVVM」と呼ぶことにします。
一般的なViewModel
上述の2つのコードは、ViewModel内部の状態がどちらも public
なので、外部から直接アクセスできてしまう=将来の状態の予測可能性が簡単に壊される懸念があります。
通常の開発では、内部状態を private
に隠蔽して、代わりに PublishRelay
等を用いた入力用のデータフローを追加することが一般的です。
// After 2: FRPを使ったViewModel (状態隠蔽) public class ViewModel { // private な可変状態 private let state: BehaviorRelay<String> = .init(value: "!!!") // public な入力 public let input: PublishRelay<String> // ストリーム出力 public func doSomething() -> Observable<String> { return input.asObservable() // 入力をトリガーに、現在の状態を取得 .withLatestFrom(state) { input, state in return input + "world" + state } } } class ViewController: UIViewController { let viewModel: ViewModel ... func viewDidLoad() { ... viewModel.doSomething().subscribe(onNext: { text in print(text) // hello world!!! が出力される }) viewModel.input.accept("hello") } }
この頻出パターンでは、状態が withLatestFrom
経由で取得された簡易的なデータフローとなっていますが、ここに(UI)アーキテクチャーを考える上での本質情報が隠されています。
それは、ViewModelの基本的な役割が 「入力ストリームと内部状態をもとに、新しいストリームを外部に出力する」 ということです。 「入力」と「内部状態」、そして「出力」という3つの要素は、まさに計算機理論の基礎的モデルであるステートマシン(状態機械)そのもの といえます。
複雑化しすぎたViewModel
しかし残念ながら、多くのiOS開発現場におけるMVVM設計は、このような単純な作りにはなっていません。 もちろん、業務が発展していくに従って、ビジネスロジックが複雑にならざるを得ない事情がありますが、私たちiOS開発者が FRPを過信しすぎて複雑なデータフローを構築してしまう ことにも大きな問題があります。
具体的な例を挙げると、ViewModelはしばしばこのように肥大化しがちです:
// After 2: FRPを使いすぎたViewModel public class ViewModel { // private な可変状態の集まり private let state1: BehaviorRelay<String> private let state2: BehaviorRelay<String> private let state3: BehaviorRelay<String> private let state4: BehaviorRelay<String> private let state5: BehaviorRelay<String> ... // public な入力ストリームの集まり public let input1: PublishRelay<String> public let input2: PublishRelay<String> public let input3: PublishRelay<String> public let input4: PublishRelay<String> public let input5: PublishRelay<String> ... // public な出力ストリームの集まり public let output1: Observable<String> public let output2: Observable<String> public let output3: Observable<String> public let output4: Observable<String> public let output5: Observable<String> ... // 初期化と同時にデータフローのグラフを構築 public init() { // output1 は input1 と state1 に依存 output1 = input1.asObservable() .withLatestFrom(state1) { input, state in return input + "world" + state } // output2 は、input2 と state1, state2, state3 に依存 output2 = input2.asObservable() .withLatestFrom(state1) { ($0, $1) } .withLatestFrom(state2) { ($0, $1, $2) } .withLatestFrom(state3) { ($0, $1, $2, $3) } .flatMap { ... } // output3 は、input3、input4 と output2 も使いつつ、state4 に依存 // 何なら追加で別の副作用も同時に行う output3 = Observable.combineLatest(input3, input4, output2) .map { ... } .withLatestFrom(state4) { ... } .flatMap { ... } .do(onNext: { ... }) // output4 は、以下略 ... // 結果的に、withLatestFrom, combineLatest等を多用した、 // 入力と状態が複雑に絡み合うカオスなデータフローのグラフが出来上がる } }
これはあたかも、多層なニューラルネットワークを頑張って一から手書きしているようなものです。
init()
内部のコードがFRPのパイプラインで埋め尽くされ、数百行のコードに膨れ上がることも少なくありません。
このようなFRPの過剰使用とコードの複雑化のことを、個人的に リアクティブ・スパゲティ と呼んでいます。 (もちろん、FRPが存在しなかった頃に比べれば、パイプライン化によって可読性は随分と高まった方なのですが)
なぜリアクティブ・スパゲティは起きるのか
リアクティブ・スパゲティが発生する原因は明確です。
「(いつの間にか)state2
、input2
、output2
が生え始めた」 からです。
個々の Observable
が存在することは、それぞれがデータフローを増やしてしまう要因になります。
そして、チーム全体を通してFRPをよく理解していないと、簡単にストリームの分岐や合流、余分に追加された状態とその手動ハンドリング(例:disposeBag
以外の Disposable
をViewModelが所有している)が大量に発生してしまい、循環的複雑度が爆発的に増加します。
よりコードの可読性を高く保ち、簡潔に書くためには、入力・内部状態・出力それぞれが一本化されてシンプルさを維持しなければなりません。
Elm Architecture
ここで、Elm というプログラミング言語に目を向けてみましょう。
細かい言語仕様についてはここでは触れませんが、フロントエンドエンジニアの方であれば、The Elm Architecture を知っている方も多いと思います。 プログラミング言語全般のUI設計思想に大きな影響を与え、JavaScript Redux、Swift Composable Architecture、Rust Yew、PureScript Halogen、Haskell Miso など、事例を挙げると枚挙に暇がありません。
ざっくり言うと、Elm Architecture は「入力 Msg
と現在状態 Model
から次の状態 Model
を出力する」という基本に忠実な Unidirectional UI設計のことです。
純粋関数である update
(または Redux でいう reducer
)関数を定義し、プログラム実行の際に使用します。
// Swiftで書いた例 enum Msg { case increment, decrement } typealias Model = Int // update : msg -> model -> model func update(msg: Msg, model: Model) -> Model { switch msg { case .increment: return model + 1 case .decrement: return model - 1 } }
プログラムの実行については、次のような形の関数を呼び出します:
/* sandbox : { init : model , view : model -> Html msg , update : msg -> model -> model } -> Program () model msg */ func makeProgram( init: Model, // 初期状態 view: Model -> Html<Msg>, // ビューのレンダリング update: (Msg, Model) -> Model // reducer ) -> Program<Model, Msg>
FRP 時代の Elm Architecture (〜v0.16)
ところで、Elm Architecture は v0.17 以前にはFRPを使っていた ことをご存知でしょうか?
Signal
と呼ばれる、おおよそ RxSwift.BehaviorRelay
(BehaviorSubject
) と同じデータ構造を使って、副作用を含むイベントのストリームをパイプライン処理していきます。
そのElm + FRP時代のオペレータの中でも特に有名なのが foldp
(fold from the past) と呼ばれる、過去を畳み込む関数です。
// foldp : (a -> state -> state) -> state -> Signal a -> Signal state func foldp<Input, State>( update: (Input, State) -> State, initial: State ) -> (Signal<Input>) -> Signal<State>
「過去を畳み込む」というと、なんだか中二心がくすぐられる思いがしますが、なんてことはない、RxSwift.scan
と同じ意味です。
実はこの foldp
は、前節の MVVM と同じく、「入力ストリームと内部状態をもとに、外部に新しいストリームを出力する」 という計算のエッセンスが随所に散りばめられています。
update: (Input, State) -> State
:畳み込み計算 (= reducer)initial: State
:初期状態Signal<Input>
:単一の入力ストリームSignal<State>
:単一の出力ストリーム
(NOTE: この出力ストリームはその後、 makeProgram
内で view
を使って Signal<Html>
に変換されて画面に出力されます)
(NOTE: (Signal<Input>) -> Signal<State>
の部分を Signal Function と呼び、 Arrow
と呼ばれる構造を持ちます= Arrowized FRP。これがいわゆる Mealy Machine の話へとつながります)
従来のFRPでは、「入力・内部状態・出力」のエッセンスを実現するために、FRPパイプラインを真面目に実装する必要がありました。
一方で、foldp
が教えてくれるのは、 複雑奇怪なパイプラインを作る代わりに update
関数一つを用意 すればそれで済むということです。
これが、Elm v0.17 で A Farewell to FRP になったきっかけとも言えます。
MVVM vs Elm Architecture
それでは早速、MVVM と Elm Architecture を比較していきましょう。
今回は話を簡単にするため、MVVMの場合の入力と出力のストリームがそれぞれ2つずつあると仮定します。
また、各 Observable
ストリームは無限時間存在するものとし、 onError
/ onCompleted
を行わないものとします。
すると、Elm Architecture (foldp
) によるストリームの一本化の場合、 MVVMのような Observable
を複数構成する代わりに、一本化された Observable
の型パラメータに入る「状態」と「アクション」の型を複数に細分化する構造を取る ようになります。
// アプリ全体の2アクション // Action ≅ Action1 + Action2 。足し算はEitherで書くことができる。 enum Action { case action1(Action1) // 子アクション1 case action2(Action2) // 子アクション2 }
// アプリ全体の2状態 // State ≅ State1 × State2 。掛け算はタプルで書くことができる。 struct State { var state1: State1 // 子状態1 var state2: State2 // 子状態2 }
通常、アクションは enum (直和型)、状態は struct (直積型)を使う場合が多いので、一旦その形にならうものとします。
直和型と直積型については、簡単に言うと、 「代数的データ型 = 型で足し算と掛け算ができる」 というものです。
足し算は Either
型、掛け算はタプル型 だと考えることができます。
代数的データ型 (Algebraic Data Type = ADT) の詳細はこちらをご参考下さい。
ここまでの話を一旦整理すると、
- MVVM では複数のObservableを入力・出力に持つ
- 複数を表現するこの場合、掛け算(タプル)で考える
- Elm (
foldp
) では、入力・出力に1つずつのObservable
のみを使い、その値を代数的データ型で細分化する
MVVM | Elm | |
---|---|---|
入力 | Obs<Action1> × Obs<Action2> |
Obs<Action> ≅ Obs<Aciton1 + Action2> |
出力 | Obs<State1> × Obs<State2> |
Obs<State> ≅ Obs<State1 × State2> |
(Obs
は Observableの略)
Observable の代数的性質
ここで天下り式になりますが、 Observable
の重要な性質として、以下のことが成り立ちます。
(ここでは、データフロー=川と呼ぶことにします)
Obs<A> = Aが流れる川 Obs<B> = Bが流れる川 Obs<A> × Obs<B> = Aが流れる川と、Bが流れる川 は、一つにまとめて、 Obs<A + B> = AまたはBが流れる川 に置き換えることができる(その逆も成り立つ)
この仮説は直感的にも正しそうに見えますね。 証明は、片方からもう一方に変換するコードを実装できるかどうかで決まります。
// Obs<A> × Obs<B> → Obs<A + B> func fromMvvmToElm<A, B>(a: Observable<A>, b: Observable<B>) -> Observable<Either<A, B>> { return Observable.merge(a.map(Either.left), b.map(Either.right)) } // Obs<A + B> → Obs<A> × Obs<B> func fromElmToMvvm<A, B>(aOrB: Observable<Either<A, B>>) -> (Observable<A>, Observable<B>) { return ( aOrB.compactMap { if case let .left(a) = $0 { return a } else { return nil } }, aOrB.compactMap { if case let .right(b) = $0 { return b } else { return nil } }, ) }
この相互の関係性から分かることとして、2つの関数を交互に呼ぶと
fromElmToMvvm(fromMvvmToElm(a, b)) = (a, b)
fromMvvmToElm(fromElmToMvvm(aOrB)) = aOrB
と、どんな入力値を代入しても元の入力値に戻ります。 この「相互変換して元に戻せる」性質のことを 同型 (isomorphic) といい、
Obs<A> × Obs<B> ≅ Obs<A + B>
と書くことができます(「≅」はイコールではなく、同型を意味します)
結局、何が言いたいかというと、
MVVM | Elm | |
---|---|---|
入力 | Obs<Action1> × Obs<Action2> |
Obs<Action> ≅ Obs<Action1 + Action2> |
MVVM と Elm の「入力」の構造については、どちらも同じことを言っているに等しい と結論付けることができます。
余談: Observable
は単なる「足し算の型の圏」から「掛け算の型の圏」への強モノイダル関手だよ
なお、余談ですが、Observable<Never>
についても思いを馳せると、面白いことに気づきます。
Observable<Never>
は(今回の前提においては)「無限に続く絶対に値を流さないストリーム」を意味しますが、実際に実装してみると:
let never: Observable<Never> = Observable.create { observer in // 何もしない、というかできない return Disposables.create() }
の書き方一通りしかありません。(Observable.never
と同じ)
一方で、Swiftの Void
(Unit型) もまた ()
ただ一つのみを値として持ちます。
つまり、 Observable<Never> ≅ Void
が成り立ちます。
ここで、おもむろに圏論(数学)という飛び道具を持ち出すと、1
= Void
, 0
= Never
とおいて、
μ: Obs<A> × Obs<B> → Obs<A + B>
ε: 1 -> Obs<0>
が同型射(逆方向の関数もある)であることから、Observable
が モノイダル圏 (型, +, 0)
から (型, ×, 1)
への強モノイダル関手 (strong monoidal functor, nLab) であることが分かります。
何を言っているのか良く分からないかもしれませんが、要するに Observable
は強かったのです。
出力ストリームの合成の限界
さて、入力に関して MVVM と Elm Architecture の構造は同じであることが分かりました。 それでは、出力についてはどうでしょうか?
MVVM | Elm | |
---|---|---|
出力 | Obs<State1> × Obs<State2> |
Obs<State> ≅ Obs<State1 × State2> |
結論を先に言ってしまうと、出力は 相互に変換して元に戻すことができません。
試しに、変換関数として次の実装を考えてみましょう。
// 1. Obs<A> × Obs<B> → Obs<A × B> func fromMvvmToElm<A, B>(a: Observable<A>, b: Observable<B>) -> Observable<(A, B)> { return Observable.combineLatest(a, b) { ($0, $1) } } // 2. Obs<A × B> → Obs<A> × Obs<B> func fromElmToMvvm<A, B>(ab: Observable<(A, B)>) -> (Observable<A>, Observable<B>) { return (ab.map { $0.0 }, ab.map { $0.1 }) }
一見すると、この対応関係は成り立ちそうに見えますが、残念ながら上手くいきません。 例えば、Rx marble diagram で適当なデータフローを考えてみると:
a : a0--a1----------a2--> b : b0------b1--b2------> // fromMvvmToElm(a, b) ab : a0--a1--a1--a1--a2--> b0 b0 b1 b2 b2 // fromElmToMvvm(fromMvvmToElm(a, b)) a' : a0--a1--a1--a1--a2--> b' : b0--b0--b1--b2--b2-->
a != a'
, b != b'
なので元に戻らないことが分かります。
他にも combineLatest
を zip
や withLatestFrom
などの他の合成オペレータに置き換えたり、 distinctUntilChanged
等を用いてフィルタリングしてもロジックが複雑化するのみで、同型であることを導くことは困難です。
2.の実装がとても自然なストリーム分解の導出に見える一方、1.の実装で 2つのObservableの(掛け算を使った)合成による不可逆性が発生している と言えます。
この原因の根本について、筆者は次のように考えます:
combineLatest
やzip
,withLatestFrom
等を使ったObservable
の掛け算の合成は、(時間的同期を取るために)内部で発行した値を一部メモリキャッシュするという「副作用」が発生し、これがFRPのストリームの計算結果に対しても不可逆性を生じさせている
もし、この仮説が正しいとするなら、次の点についても言えそうです:
fromElmToMvvm
は自然な導出(純粋なmap
のみを使っているので)- Elm Architecture から MVVM への出力の変換は容易
- MVVM への出力変換で、各々のストリームが前回の値を重複して発行してしまう問題があるが、
distinctUntilChanged
を使えば、MVVMのように差分更新のみを抽出することも可能
fromMvvmToElm
は、どのような掛け算的合成を行っても、不可逆な結果に終わる- MVVMからElmを構成することはできない
Q. combineLatest
を使えば、全ての出力をかき集められるのでは?
ところで、上述の 「MVVMからElmを構成することはできない」は、やや飛躍した結論に思われるかもしれません。
実際、数学がどうこうという謎めいた話を無視すれば、combineLatest
を使って散らばった各出力の Observable
を一点に集めることが可能だからです。
しかし、この単純な方法は「限られたケース」においてのみ可能であるだけで、一般的には成立しませんし、また非効率的です。 主に、次のような課題があります。
combineLatest
引数の各Observable
は subscribe 時点で 初期値を持っていなければならない- 初期値がないと、
combineLatest
のonNext
がなかなか始まらず、Elmにおける状態更新のタイミングを再現しない
- 初期値がないと、
combineLatest
は、掛け算的合流計算のために メモリキャッシュを消費 し、Elm に比べて非効率になりやすい- Reactive glitch(同じ上流元の同期的合流問題)
- 2つの異なる出力が、同じ1つの入力をトリガーとして派生した場合、タイミング問題が生じて、中間の変更状態が反映される
ちなみに Reactive glitch の根本問題は、 2つの出力が「同時に更新」される場合に、個々の Observable
に分解できない ことが原因です:
// aとbが両方同時に更新 ab : a0------a1--> b0 b1 // fromElmToMVVM(ab) a : a0------a1--> b : b0------b1--> // fromMvvmToElm(fromElmToMvvm(ab)) ab': a0-----a1-a1--> b0 b0 b1
一瞬だけ (a1, b0)
(場合によっては (a0, b1)
)という余計な中間状態が発生している ことが分かります。
この問題点として、もし ab'
をVirtual DOMに渡してUI差分レンダリングした場合、不要な計算が走ることにつながります。
Reactive glitch に対する解決策としては、FRPの中で トポロジカルソート を用いたQueueによる管理の方法が挙げられます。 詳しくは、こちらのURLをご参考下さい。
- Reactive programming (Glitches) - Wikipedia
- RxSwift
a.withLatestFrom(a)
同じ上流元の同期的合流問題 - Qiita - Understanding Reactive Glitches - Swift Talk - objc.io
なお、RxSwift や ReactiveSwift を始めとする、ほとんどのFRPライブラリは、Reactive glitch 問題に対応していません。 もし対応しているフレームワークがあれば、ぜひ教えて下さい。
まとめ
いかがでしたか?
この記事では、MVVMに対するElm Architectureの優位性について、FRP (Observable) の持つ数学的構造 に着目して仮説を立ててみました。 Virtual DOM (差分レンダリング) フレームワークの有無や良し悪しに関係なく、結論を導ける という点が、この話の一番の面白い点だと思います。
さらに学びたい方のために、Swiftで解説した Elm Architecture について、こちらのスライドをご参考いただければ幸いです。
- Reactive State Machine (Japanese) - Speaker Deck
- Elm Architecture in Swift - Speaker Deck
- SwiftUI 時代の Functional iOS Architecture
今回のブログを書くにあたり、参考にした文献・Webサイトはこちらになります。
- Elm: Concurrent FRP for Functional GUIs
- Asynchronous Functional Reactive Programming for GUIs
- Elm: Concurrent FRP for Functional GUIsを読んで - The curse of λ
おわりに
delyでは現在、 「クラシル」「TRILL」を一緒に開発しながら共に成長していけるメンバーを絶賛大募集 しています。
もし、この記事を読んで「私も strong (monoidal) になりたい」と思いましたら、ぜひ私が先日書いた入社ブログも合わせて読んでみて下さい。 会社のカルチャーや中の人の雰囲気、事業内容について紹介しています。
また、2021/01/21 19:00〜にクラシルiOSチームのオンライン雑談会を開催します。 こちらもぜひ奮ってご参加ください!
最後までお読みいただきありがとうございました!