Elm
ElmDay 10

[Elm] Html.Lazyによるパフォーマンス最適化

More than 1 year has passed since last update.

Elm Advent Calendar 2016 の10日目の記事です。

今回は、Html.Lazy モジュールについて。

Html.Lazy とは

Virtual DOM のパフォーマンスがいくら良いとは言え、無駄な Virtual DOM を際限なく作り続けるとコストになります。そこで、Html.Lazy モジュールはこのコストを抑え、パフォーマンスを最適化する方法を提供します。

仕組み

一言で言うと「モデルが変わらなければビューも変わらない」という性質を利用します。Elm の関数はすべて純粋であることが保証されているため、引数が変わらなければ返す結果はいつも同じです。そこで、Elm ランタイムはHtmlを生成する関数の引数が前回と同じであるかを見て、同じであればロジックを丸ごとスキップして前回の値を使いまわします。

使い方

使い方は簡単です。ビューを生成する関数の頭にlazyとつけるだけです。

-- 最適化なし
view model =
  viewSomething model.something
-- 最適化あり
import Html.Lazy exposing (lazy)

view model =
  lazy viewSomething model.something

TodoMVC でも使われています。

lazyは特別なキーワードではなく、関数として定義されています。引数の数に応じてlazylazy2lazy3の3つが用意されています。

lazy : (a -> Html msg) -> a -> Html msg

lazy2 : (a -> b -> Html msg) -> a -> b -> Html msg

lazy3 : (a -> b -> c -> Html msg) -> a -> b -> c -> Html msg

Html.Lazy を使う上での注意点

注意は、引数の同一性を「参照の一致」によって判定していることです。lazy関数のドキュメントの一部を引用すると次のように書いてあります(強調筆者)。

During diffing, we can check to see if model is referentially equal to the previous value used, and if so, we just stop.

つまり、view関数内で動的に生成した値は参照が前回と一致しないため、引数に含めてはいけません。次の例は動的に新しいレコードを生成しているため、lazyは動作しません。

-- 無効
view model =
  lazy viewSomething { value = model.something }

ただし、以下は値が同じであれば参照が一致すると考えて大丈夫です。

  • 数値
  • 文字列
  • 型コンストラクタ(True, False, Just, Nothing, [] など。Just a は別参照になる。)

また、ビューを生成する関数も動的に生成されたもの(あるいはラムダ式)であってはいけません。

-- 無効
view foo bar baz oneMoreThing =
  let
    f a b c =
      viewSomething a b c oneMoreThing
  in
    lazy3 f foo bar baz

こうすると、oneMoreThingが変更されたことをlazy3が検知できなくなってしまいますので、こうした抜け道は無効になっています。

参照を一致させるための工夫

上のような注意事項があるので制限がきつい気がしますが、工夫することで何とかなることも多いです。以下の例では、関数を適切にリファクタリングすることでlazyを有効にする例です。

例1: 定数をlet .. in中に書かずにトップレベルに宣言する

定数を毎回生成するのは勿体ないです。

-- 無効
view model =
  let
    value = Just 1
  in
    lazy2 viewSomething model.something value 

定数をトップレベルに持っていくことで、参照は毎回同じになります。

-- 有効
view model =
  lazy2 viewSomething model.something value 

value =
  Just 1

例2: Msgを生成して渡さない

動的に生成した Msg を渡すと参照が新しくなってしまいます。

-- 無効
view person =
  lazy viewSomething (Click person.id)

viewSomething msg =
  div [] [ onClick msg  ]

次のように関数内で組み立てるか、

-- 有効
view person =
  lazy2 viewSomething Click person.id

viewSomething toMsg id =
  div [] [ onClick (toMsg id) ]

Html.mapを使って、ビューを生成した後で Msg を変換します。

-- 有効
view person =
  viewSomething
    |> Html.map (\_ -> Click person.id)

viewSomething =
  div [] [ onClick () ]

後者のコードからはそもそもlazyが消えていますが、もちろん定数であるビューもトップレベルに置けばlazyと同じように前回の値が使いまわされるため、意味的には同じです。

lazy が有効かどうかを確認する

lazy を正しく使えているかを確認する効率的なやり方があればいいのですが、今のところそのような方法はないと思います。愚直ですが手っ取り早いのは、「ビュー関数内にDebug.logを仕掛けて、意図せず関数が呼ばれていないかをチェックする」です。別の方法としては、「ブラウザのプロファイル機能を使って、(以下同様)」です。後者の方がコードを弄らなくていいのと、潰すべき残りの関数が明らかになるというメリットがありそうです。

Reactとの比較

Reactにも同様の仕組みがあり、同様の注意点があるようです。

shouldComponentUpdate() による手動のチューニング

ReactのコンポーネントにはshouldComponentUpdate()という関数が用意されています。デフォルトはtrueを返しますが、この関数をオーバーライドして適切にfalseを返すことで、レンダリングをスキップします。

まさにHtml.Lazyと同様に「前回の状態と同じであればレンダリングしない」という戦略のためにあるのですが、その判定ロジックはユーザーにゆだねられています。

PureComponent を使う

その後、Reactに追加されたPureComponentという仕組みを使うと、shouldComponentUpdate()を自動化することが出来ます。PureComponentは、外部から与えられる引数propsと自身の状態stateの変化を見ることで、レンダリングが必要かどうかを自動的に判定します。

判定ロジックはドキュメントを見ると次のように書いてあります(強調筆者)。

React.PureComponent's shouldComponentUpdate() only shallowly compares the objects.

どの程度まで「浅く」なのか微妙に分からなかったので調べると、PureComponentの前身であるshallowCompareと同じ挙動をすると書いてありました。以下に引用します(強調筆者)。

shallowCompare performs a shallow equality check on the current props and nextProps objects as well as the current state and nextState objects.
It does this by iterating on the keys of the objects being compared and returning true when the values of a key in each object are not strictly equal.

propsstateの各キーに対して"strictly equal"とあることから、コンポーネントに与えた引数に対する判定ロジックはElmのHtml.Lazyと同じと考えて良さそうです。

使用上の注意

PureComponentのドキュメントによると、使用者は以下のことに注意するべしと書いてあります。

  • 更新前後のオブジェクトを比較できるように、Immutableなデータ構造を検討する
  • PureComponentの子供はすべて"pure"である必要がある

これらは両方ともElmでは解決済みの問題と言えるでしょう。ElmではすべてのデータがImmutableであり、全ての関数がビューを含めて"pure"です。ビューに状態を持てないので、例えば"時計コンポーネント"を置くためには現在時刻をモデルから引くしか方法がなく、ある意味で面倒です。しかし、この性質のおかげで「誤ってPureComponentの下に置かれた"時計コンポーネント"が動かない」などというトラブルとは無縁でいられます。これは一種のトレードオフと言えるでしょう。

実際にHtml.Lazyを使ってみた

現在会社で取り組んでいるアプリ(OSS)でパフォーマンスが気になってきたので、実際に使ってみました

参照なんてそうそう一致しないだろうと高をくくっていたのですが、やってみると意外と使える部分が多かったです。というのは、あるビューでlazyが使えなくても(一番大きなModelを渡している関数が結構ある)末端のビューに近づくほど引数の粒度が小さくなっていくため、途中のどこかで参照が一致する事が多くありました。

効果は時間が足りずまだきちんと測れてませんが、プロファイルで見ると不要な処理がかなり減っていました。以下はあるオブジェクトを画面上でドラッグした時の様子です。

before-after.png

体感としても心なしか軽くなった気がします。

まとめ

Html.Lazy は注意点さえ押さえれば、簡単に信頼性のあるパフォーマンス最適化が可能です。ぜひ使いこなしましょう。

参考文献