クラシル開発ブログ

クラシル開発ブログ

なぜ MVVM + FRP は Elm Architecture に勝てないのか

こんにちは、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が存在しなかった頃に比べれば、パイプライン化によって可読性は随分と高まった方なのですが)

なぜリアクティブ・スパゲティは起きるのか

リアクティブ・スパゲティが発生する原因は明確です。 「(いつの間にか)state2input2output2 が生え始めた」 からです。

個々の 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' なので元に戻らないことが分かります。

他にも combineLatestzipwithLatestFrom などの他の合成オペレータに置き換えたり、 distinctUntilChanged 等を用いてフィルタリングしてもロジックが複雑化するのみで、同型であることを導くことは困難です。

2.の実装がとても自然なストリーム分解の導出に見える一方、1.の実装で 2つのObservableの(掛け算を使った)合成による不可逆性が発生している と言えます。

この原因の根本について、筆者は次のように考えます:

  • combineLatestzip, withLatestFrom 等を使った Observable の掛け算の合成は、(時間的同期を取るために)内部で発行した値を一部メモリキャッシュするという「副作用」が発生し、これがFRPのストリームの計算結果に対しても不可逆性を生じさせている

もし、この仮説が正しいとするなら、次の点についても言えそうです:

  • fromElmToMvvm は自然な導出(純粋な map のみを使っているので)
    • Elm Architecture から MVVM への出力の変換は容易
    • MVVM への出力変換で、各々のストリームが前回の値を重複して発行してしまう問題があるが、distinctUntilChanged を使えば、MVVMのように差分更新のみを抽出することも可能
  • fromMvvmToElm は、どのような掛け算的合成を行っても、不可逆な結果に終わる
    • MVVMからElmを構成することはできない

Q. combineLatest を使えば、全ての出力をかき集められるのでは?

ところで、上述の 「MVVMからElmを構成することはできない」は、やや飛躍した結論に思われるかもしれません。 実際、数学がどうこうという謎めいた話を無視すれば、combineLatest を使って散らばった各出力の Observable を一点に集めることが可能だからです。

しかし、この単純な方法は「限られたケース」においてのみ可能であるだけで、一般的には成立しませんし、また非効率的です。 主に、次のような課題があります。

  • combineLatest 引数の各 Observable は subscribe 時点で 初期値を持っていなければならない
    • 初期値がないと、combineLatestonNext がなかなか始まらず、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をご参考下さい。

なお、RxSwift や ReactiveSwift を始めとする、ほとんどのFRPライブラリは、Reactive glitch 問題に対応していません。 もし対応しているフレームワークがあれば、ぜひ教えて下さい。

まとめ

いかがでしたか?

この記事では、MVVMに対するElm Architectureの優位性について、FRP (Observable) の持つ数学的構造 に着目して仮説を立ててみました。 Virtual DOM (差分レンダリング) フレームワークの有無や良し悪しに関係なく、結論を導ける という点が、この話の一番の面白い点だと思います。

さらに学びたい方のために、Swiftで解説した Elm Architecture について、こちらのスライドをご参考いただければ幸いです。

今回のブログを書くにあたり、参考にした文献・Webサイトはこちらになります。

おわりに

delyでは現在、 クラシル」「TRILL」を一緒に開発しながら共に成長していけるメンバーを絶賛大募集 しています。

join-us.dely.jp

もし、この記事を読んで「私も strong (monoidal) になりたい」と思いましたら、ぜひ私が先日書いた入社ブログも合わせて読んでみて下さい。 会社のカルチャーや中の人の雰囲気、事業内容について紹介しています。

note.com

また、2021/01/21 19:00〜にクラシルiOSチームのオンライン雑談会を開催します。 こちらもぜひ奮ってご参加ください!

bethesun.connpass.com

最後までお読みいただきありがとうございました!