WPFで速いXAMLを書くためのTips
表示も反応も速ければ速いほど良いものです。
ユーザーに「止まっている」という認識を抱かれてしまうとUXに関する評価は急激に下がります。
コードの可読性の維持は常に重要な課題ですが、高速化しなければならないときは徹底的にやりたいものです。
しかし、何から取り掛かるべきでしょうか。指針とすることができるいくつかの情報をここに書きたいと思います。
対象とするシナリオ
MVVMのプロジェクトで、ある編集可能なレコードやコンテンツを画面上に多数並べて表示する場合のいろいろな最適化を考えてみましょう。
ItemsControl系(ListBox等)にItemsSourceを設定するか、CanvasやGrid等に配置するのが一般的です。
シンプルに作ることはできますが、コンテンツの数が増えるとあっという間に遅くなります。
この種のものはXAMLが原因か、コードビハインドやViewModel以下の問題か。何から調べたら良いでしょうか。
パネルの使い方によって高速化する
まずはパネルの使い方の是非を確認してください。
Panel.ZIndexを極力使わない
デフォルト値以外のZIndexを持つ要素があると、どの程度の処理が追加されるか以下を中心に読み解くと良いでしょう。
http://referencesource.microsoft.com/#q=RecomputeZState
追加や削除の都度、ソートが行われます。WPFのパネルは接続した順番に表示できますので、ZIndexではなく接続順序やVisualTreeの構造で上下関係を維持するようにしてください。
1つのパネルに沢山のコントロールを配置しない
1つのパネルに1000配置するより、10のパネルに100ずつ配置して1つのパネルに乗せた方が高速です。
仮想化ができないときは分割を検討すると良いでしょう。
DataTemplateSelectorを適切に設定する
ListBox等でItemTemplateとTemplateSelectorがnullの場合、DefaultTemplateSelectorが使われますが、これは決して速くはありません。(しかし、実用性を損なうほど遅くはないことについて、WPFを作った方々に敬意を表したいと思います)
表示するコントロールがある程度決まっている場合などはDataTemplateSelectorを自前で用意すると良いでしょう。
出来る限り仮想化する
出来る限り仮想化しましょう。仮想化そのもののコストは多くの場合、VisualTree構築のコストよりも小さいものです。
例えばListBoxはデフォルトで仮想化スタックパネルを用いています。デフォルトのItemsPanelが何になっているか確認してください。
配置上の都合で仮想化されていない場合、仮想化することが必要になるかもしれません。
必要なら恐れずやりましょう。
Panel/VirtualizingPanelを継承する場合
- ItemsControl系の恩恵を受けることが出来ます。
- レイアウトと表示順序を決定するためのロジックを提供するのが目的であることを忘れないでください。
- パネル自体の速度は変わりませんが、パネル上で一度に表示する要素数をある程度制限できるなら選択肢としては十分にありです。
Panelを継承しない場合
- ItemsControlの支援を受けることはできませんが、余りある速度を手にするでしょう。
- VisualTreeの変更を最小限とすることを最優先して、パネルを設計してください。
XAMLの書き方によって高速化する
XAMLの書き方にも速くするヒントがあります。
StyleやTemplateはテーマを適用する場合などで必要になりますので、それらには言及しません。それら以上に効果が大きいものがあります。
LoadBamlが呼び出されるタイミングを可能な限り遅くする
無頓着だと陥りやすいのがLoadBamlのコストです。LoadBamlとは、コンパイルされたXAMLからVisualTreeを構築する処理などです。Bindingなどもこの過程で行われます。
LoadBamlは通常、コントロールにDataContextが設定されたとき、ContentPresenterの処理の一環としてDataTemplateを展開する際に呼び出されます。
ここのコストを下げる方法はいくつかあります。
コントロールをコンパクトにする
例えば、あるViewに編集機能があるとして、編集用のコントロールがあるとします。こういうものはXAML上に非表示で置きたくなります。
しかし、編集機能は編集が始まるまでは不要なはずです。編集操作が始まった時に、編集用コントロールがビジュアルツリーに追加されるようにしてください(XAMLに登場させないこと)。
これだけで初期表示のLoadBamlの時間は50%も削減されることがあります。
表示用コントロールと編集用コントロールを別々にすることで容易に実装できるはずです。
多数のアイテムを配置するコントロールの場合、編集用のコントロールを AdornerLayer に1つだけ配置しておくなどの工夫も良いでしょう。
LoadBamlの時間についてはViewModelの作りにも左右されます(後述します)。
可能な限りFreezeする
アニメーションしないブラシやペンはすべてフリーズしましょう。
<freezableElement PresentationOptions:Freeze="true"/>
色の名称を書ける色のブラシは既にフリーズしています。
色コードを指定する場合は忘れてはなりません。同一XAML内で使う色はResource化またはViewModel側で静的インスタンス化して共有すると良いでしょう。
Freezeのために初回だけ少し大きいコストを払いますが、変更されない前提で動くのでトータルでは高速です。
Bindingの仕方によって高速化する
BindingのModeに気を配ってください。
可能な限りBindingはOneTimeとする
以下のケースはOneTimeを選択してください。
- コマンドなど、DataContext設定時に1回だけ設定すれば十分なものををViewModelなどから受け取る場合、必ずOneTimeにします。
- INotifyPropertyChanged を実装しないCLRクラスのプロパティは常にOneTimeかOneWayToSourceにしないと、深刻なメモリーリークとなります。
OneWayにするかTwoWayにするかの指針
- 表示用コントロールでは全てOneWay/OneTimeになるように配慮してください。
- 編集用コントロールでは編集対象のみTwoWayとなるように配慮してください。
ターゲット(コントロール)側のDependencyPropertyのMetadataがOneWayかTowWayかのデフォルト値を持っており、その状態で正しく動きます。
デフォルトの動作に背かないことが肝要です。
ただし、ラジオボタン等はViewModel側をどのようにするかで様々なパターンが考えられます。専用のIValueConverterを用意するよりは、ViewModelにプロパティを用意するのが簡潔です。もちろんIValueConverterやコマンドによる実装も良いはずです。コマンドを用いる場合はOneWayにすると良いでしょう。
アプリケーションの設計による最適化
WPFが遅くなるのは、アプリケーションの元々の設計が悪いこともあります。XAML単体でいくら速く書いても改善しないときは設計自体を再確認します。
基本は
- .NET Framework自体のボトルネックを理解すること
- マルチスレッド化
- LoadBamlとレンダリングパスをどれだけ減らせるか
にかかっています。
ViewModelはDependencyObjectではなくINotifyPropertyChanged実装クラスにする
DependencyObjectにするとUIスレッド以外では生成も編集もできなくなります。これは後々巨大なデメリットとなりえます。
その重要性を理解するためには、ViewModelがDataContextに設定されると何が行われるか知る必要があります。
DataContextの設定タイミングが重要
ListBoxでVirtualStackPanelを使えないケースを考えてみます。
class FooViewModel : INotifyPropertyChanged
{
// 中略
public ObservableCollection<ItemViewModel> Items{ get; }
}
<ListBox ItemsSource="{Bindig Items,Mode=OneTime}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
ListBoxItemはContentPresenterを内包しています。このDataContextが変更されると、VisualTreeに構築するためにLoadBamlが動きます。
この中で、ListBoxの各要素のDataContextが設定されて、要素数と同じ回数LoadBamlが行われます。
つまり、ItemsSourceにアイテム実体を追加するのをなるべく後にした方が初期表示は早いということです。
ちなみに全部構築し終わったViewModelをDataContextに貼り付けると、バインドしたItemsSourceを全部展開するまで戻ってきません。これが遅い場合の解決方法はいくつかあります。
遅延読み込みを行いEmptyStateやロード中のSpinnerなどを用いる
- 軽量なEmptyStateや読み込み中表示を配置して、データを遅延ロードしながら追加してゆく方法は非常に有効です。
- 上記の例のFooViewModel.Itemsの読み込みはバックグラウンドスレッドで行いましょう。読み込みが終わったものからUIスレッド上でFooViewModel.Itemsに加えていくと、滑らかに追加されていきます。
読み込みとLoadBamlが並列化されるのでその間に複数回描画が走っても、トータルの時間が改善するケースすらあります。
ContentPresenterを後から追加するカスタムコントロール
- 読み込みが完了してからContentPresenterを追加するカスタムコントロールなども状況によっては活躍します。
どんなカスタムコントロールがあれば、いち早くLoadBamlとレンダリングパスを終えるのか、検討してください。
ともかく、重要なのはLoadBamlを後回しにすることです。
IValueConverterを使うかプロパティを増やすかの指針
- Bindingするにあたり、IValueConverterを使うとBox化する機会が増えます。
- しかし、プロパティを増やすとPropertyChangedイベントの解釈に時間を要します。
明確な指針はありませんが、下記のBox化対策が可能な値の変換で汎用性の高いものはIValueConverterを使うと良いでしょう。
Box化された定数を使用する
Box化された定数を使うことは有効です。
Microsoftも結構用いています。
http://referencesource.microsoft.com/#WindowsBase/Base/MS/Internal/KnownBoxes.cs
boolやVisibilityなどのように2値か3値程度の物は全部この対策をしても良いでしょう。
出来る限り処理を集約する
イベントの即時処理を極力減らし、BindingしたりDispatcherを用いて適切なタイミングで集約処理しましょう。
例えば、SizeChangedイベント内でプロパティの書き換え等を行うと、再度レイアウトパスが走ることもあります。すると描画にかかる時間はかなり増えることになります。
イベントは影響範囲を考慮して用いましょう。イベントの処理をBindingに置き換えできるかは常に意識してください。
CanExecuteを軽量にし、適切なタイミングで実行する
CanExecuteの結果はViewModelに保持している値を返せばよいくらいに軽量なものとするのが理想です。
しかし、何かのプロパティが変わる都度影響範囲全てに通知すると、それですらコストは非常に大きいものとなります。
そのため、ViewModelのプロパティが変わった時にCanExecuteChangedを発行すべきCommandをHashSetで収集し、DispatcherにCanExecuteChanged発行処理を投げるマネージャーなどを作ることができます。
こういったところをおろそかにしないことは重要です。
まとめ
以上が、WPFに関わり続けてきた経験から絞り出した最適化手法となります。
「○○をXAMLで実現する」というテーマではなく、「どこで折り合いをつけるとXAMLを用いたアプリケーションの速度が上がるのか」をまとめてみました。
何かの役に立てば幸いです。
山ほどReferenceSourceを読んで実装も大量に重ねたので、概ね正しい方針を書いていると思いますが、誤りや別解などありましたらご指摘ください。
おつきあい下さりありがとうございました。