Chapter 08

モデルの差分更新

eagle
eagle
2021.12.26に更新

動機付け

前回までで、モデルとメッセージの分離を行い、テストが容易なupdate関数を切り出しました。
次はsetModel関数のパフォーマンスについて考えます。
まずはsetModel関数を再掲載します。

let setModel nextModel =
    model <- nextModel
    this.labelCount.Text <- $"現在のカウント数は %d{model.Count} です。"
    this.numericUpDownStep.Value <- model.Step |> decimal
    this.buttonIncrement.Text <- $"+%d{model.Step}"
    this.buttonDecrement.Text <- $"-%d{model.Step}"

基本的にこの関数はモデルのCountが変わってもStepが変わっても、どちらにせよすべてのコントロールを再描画します。
それどころか、モデルが一切変わっていなかったとしてもsetModel関数が呼ばれれば再描画が行われます。
モデルのStepが変わったときはlabelCountコントロールを更新する必要はありませんし、モデルのCountが変わったときも同様です。

現時点のように小規模なアプリケーションではほとんど影響はありませんが、大規模になってくるとこのことが問題になってくるかもしれません。
というのも、差分検出よりも画面描画のほうが一般にコストが高いことが多いからです。
パフォーマンスが要求される場合、モデルの差分を計算し、必要な部分だけを再描画するようにしなければならないでしょう。

差分比較

まず、model変数の型をModelからModel optionに変更します。
そして、model変数の初期値を最初はNoneとします。

- let mutable model = initialModel
+ let mutable model = None

次に、コンストラクタでモデルの初期状態initialModelをセットします。

- do setModel model
+ do setModel initialModel

dispatch関数に関しては今まで通りの挙動としますが、model変数がまだ初期化されていない状態、すなわちNone値の場合は呼び出されても何もしないこととします。

let dispatch msg =
    model
    |> Option.map (update msg)
    |> Option.iter setModel

さて、次は本題のsetModel関数です。これを書き直しましょう。

model変数がNoneのときは明らかに画面の更新が必要です。
Someのときはモデルの各フィールドに変更がある場合はそれに対応するコントロールのみを更新します。

let setModel nextModel =
    // Count diff
    match model with
    | Some prevModel when prevModel.Count = nextModel.Count -> ()
    | _ ->
        this.labelCount.Text <- $"現在のカウント数は %d{nextModel.Count} です。"

    // Step diff
    match model with
    | Some prevModel when prevModel.Step = nextModel.Step -> ()
    | _ ->
        this.numericUpDownStep.Value <- nextModel.Step |> decimal
        this.buttonIncrement.Text <- $"+%d{nextModel.Step}"
        this.buttonDecrement.Text <- $"-%d{nextModel.Step}"

    model <- Some nextModel

最終的に次のようなコードになります。

MainForm.fs
module MainForm

open App.Designer

type Model =
    { Count: int
      Step: int }

type Msg =
    | Increment
    | Decrement
    | SetStep of int

let initialModel =
    { Count = 42
      Step = 1 }

let update msg model =
    match msg with
    | Increment ->
        { model with
              Count = model.Count + model.Step }
    | Decrement ->
        { model with
              Count = model.Count - model.Step }
    | SetStep nextStep ->
        { model with
              Step = nextStep }

type View() as this =
    inherit MainForm()

    let mutable model = None

    let setModel nextModel =
        match model with
        | Some prevModel when prevModel.Count = nextModel.Count -> ()
        | _ ->
            this.labelCount.Text <- $"現在のカウント数は %d{nextModel.Count} です。"

        match model with
        | Some prevModel when prevModel.Step = nextModel.Step -> ()
        | _ ->
            this.numericUpDownStep.Value <- nextModel.Step |> decimal
            this.buttonIncrement.Text <- $"+%d{nextModel.Step}"
            this.buttonDecrement.Text <- $"-%d{nextModel.Step}"

        model <- Some nextModel

    do setModel initialModel

    let dispatch msg =
        model
        |> Option.map (update msg)
        |> Option.iter setModel

    let subscriptions =
        [| this.buttonIncrement.Click
           |> Observable.subscribe (fun e -> dispatch Increment)

           this.buttonDecrement.Click
           |> Observable.subscribe (fun e -> dispatch Decrement)

           this.numericUpDownStep.ValueChanged
           |> Observable.subscribe (fun e -> dispatch (SetStep(this.numericUpDownStep.Value |> int))) |]

    override this.Dispose(disposing) =
        if disposing then
            subscriptions
            |> Array.iter (fun subscription -> subscription.Dispose())

        base.Dispose disposing

なお、差分更新するまでもない小規模なアプリケーションでは、保守性の観点から次のようにsetModel関数をなるべくシンプルに保っておいたほうが良いでしょう。

let setModel nextModel =
    this.labelCount.Text <- $"現在のカウント数は %d{nextModel.Count} です。"
    this.numericUpDownStep.Value <- nextModel.Step |> decimal
    this.buttonIncrement.Text <- $"+%d{nextModel.Step}"
    this.buttonDecrement.Text <- $"-%d{nextModel.Step}"

    model <- Some nextModel

パフォーマンスに大きく影響するような処理があれば、その部分だけ個別に対応するなどの工夫があっても良いかもしれません。

アダプティブデータ

本書では使用しませんが、このような計算を行うためのライブラリとして、FSharp.Data.Adaptiveなどが存在するので参考にしてみてください。