はじめに
みなさんWPFアプリケーションを作ってますでしょうか?
私は、同僚から「金輪際WPFには新機能は追加されないしカテゴリーもクラシックデスクトップですよ」と嫌味を言われながらもガリガリとコードを書いてます。本稿は、WPFで画面表示を高速化するために調べたメモです。
何で遅いのか
WPFは、.Netのバージョンが上がるたびに高速化されていて、PCも低価格のものでもメモリやGPUが、それなりのスペックになって来ているので、発表された当初は環境を選びましたが、今では普通に作れば、それなりの速度で動作します。
アプリケーションが遅い理由には、様々な原因がありますが、画面の表示が遅い場合には、大きく分けて「レイアウトの構造」と「データ数量」に大別出来ると思います。
原因1:レイアウトが複雑過ぎる
初めてWPFに触れた時に感じたのですが、レイアウトシステムが非常に多機能だということです。
沢山のシステムを調べた訳では無いのですが、Xamarinに対して非常に強いリクエストがあった機能が、XAML移植だったのを見てもWPFのUI記述能力は、非常に優秀なんじゃないかなと思います。
ただし、レイアウトシステムが多機能であるというのと表示が高速かというのは無関係です。
WPFのレイアウトシステムは、Arrange(配置)とMeasure(測定)というプロセスを通して画面を構築します。
子供のサイズによって、親のサイズが決まる場合も有りますので、Arrange(配置)とMeasure(測定)は、場合によってはビューツリーを何度も行ったり来たりを繰り返して最終的な位置とサイズを確定します。
レイアウト絡みの遅延は、Arrange(配置)とMeasure(測定)が、大量に繰り返えされることで起きます。
つまりは、レイアウトを無闇に深くせずネストを浅く簡素にすることで軽くなるということです。
また、Canvasは子供の位置やサイズで、親の位置やサイズが影響を受けないためArrange(配置)とMeasure(測定)が、親子で行き来しません。
作る手間や可変領域を考えないのであれば、Canvasに画面パーツを貼り付ければ高速に表示されます。
ただし、標準のCanvasは、Zオーダーを再計算するためPanel.Childrenの実装に問題を内包しているそうです。
参考:仮想化しないでも速いCanvasの作り方
Canvasを使うにしても大量のアイテムを表示するような場合には、工夫が必要になります。
原因2:データ数が多すぎる
仮想化には、『UIの仮想化』と『データの仮想化』の2種類の方法があります。
『UIの仮想化』とは、画面上に表示されているアイテムだけをインスタンス化して、表示範囲外のアイテムは、パネルから削除しておくという方法です。
表示領域に居るアイテムだけを相手にするため表示は極めて軽くなりますが、表示位置が移動するたびに、アイテムの生成と廃棄が行われるのでスクロールは少し重くなります。(※表示領域に偏って大量のアイテムが表示されな限り)
ただ、制御するアイテム数は減りますが、データはフルにメモリ上に専有され続けます。
『データの仮想化』は、データの総数は把握するものの表示されていないデータは、表示するまで外部記憶から取得しないという手法です。
WPFは、前者の『UIの仮想化』にしか対応していません。(ストアアプリは後者に対応するクラスが用意されています)メモリを圧迫するほどのデータサイズを対象にするのであれば、自前で仕組みを構築する必要があるでしょう。本稿は、データがメモリスワップを起こしまくるほど大量では無いが、表示アイテムの数が多すぎて表示が遅い場合をターゲットにしてます。
仮想化について
WPFでUIの仮想化を利用することは、VirtualizingStackPanelを利用することと同義です。
つまり同じサイズのアイテムを縦横に並べたリストに対してしか仮想化は適用されません。
箱や配線のアイテムを並べるようなブロックダイアグラムを仮想化して高速化するには、独自実装の仮想化Canvasを用意する必要があります。
仮想化Canvas
仮想化Canvasの素朴な機構イメージは、表示ボックスに交差して表示されるアイテムをインスタンス化し交差から外れたアイテムを廃棄する実装となります。新たに頑張って作らなくても誰か実装してそうですよね。
WPFの仮想化Canvasでネット検索すると下記のブログがヒットします。
参考:簡素な仮想化パネル
参考:Virtualized WPF Canvas
後者のVirtualCanvasは、MSの中の人がリファレンス実装として公開されてるコードのようで、機能的にも充実していてサンプルも非常に高速に動作します。
テストが足りていないそうなので利用する際には注意が必要ですが、このコードを下敷きにして、実装を進めると良さそうです。
VirtualCanvasの使い方
VirtualCanvasは、標準のCanvasとは互換性が無く使い勝手が異なります。
そのためItemsControlのPanelに設定するなど出来ず、MVVMとの相性も残念ながら悪いみたいです。
VirtualCanvasを改造する道もあるのですが、本稿ではBehaviorを使って、ViewModelと接続することにしました。
<ScrollViewer CanContentScroll="True"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ms.controls:VirtualCanvas>
<i:Interaction.Behaviors>
<local:VirtualCanvasSampleBehavior ItemsSource="{Binding ItemsList}" />
</i:Interaction.Behaviors>
</ms.controls:VirtualCanvas>
</ScrollViewer>
VirtualCanvasコントロールは、このように配置します。
VirtualCanvasの親は、ScrollViewerである必要があります。(内部で決め打ちで呼んでるっぽい)
public partial class MainWindowViewModel : Livet.ViewModel
{
private System.Collections.ObjectModel.ObservableCollection<TestEntity> _itemsList;
public System.Collections.ObjectModel.ObservableCollection<TestEntity> ItemsList
{
get { return this._itemsList; }
set
{
if ( this._itemsList == value ) {
return;
}
this._itemsList = value;
this.RaisePropertyChanged(() => this.ItemsList);
}
}
public MainWindowViewModel()
{
this.ItemsList = new System.Collections.ObjectModel.ObservableCollection<TestEntity>();
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < 100; j++)
{
this.ItemsList.Add(new TestEntity { Top = 110 * i, Left = 110 * j, Width = 100, Height = 100, Color = Colors.Red, Id = Guid.NewGuid().ToString("N"), });
}
}
}
}
ViewModelクラスでは、TestEntityクラスを100x100の10000個生成してます。
public partial class TestEntity
{
public double Top { get; set; }
public double Left { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public Color Color { get; set; }
}
TestEntityクラスは、こんな感じです。
public class VirtualCanvasSampleBehavior : Behavior<VirtualCanvas>
{
private VirtualCanvas hostControl;
public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(
"ItemsSource",
typeof(IEnumerable),
typeof(VirtualCanvasSampleBehavior),
new PropertyMetadata(null, ItemsSourceChanged));
public IEnumerable ItemsSource
{
get { return (IEnumerable)GetValue(ItemsSourceProperty); }
set { SetValue(ItemsSourceProperty, value); }
}
private static void ItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((VirtualCanvasSampleBehavior)d).ItemsSourceChanged(e);
}
private void ItemsSourceChanged(DependencyPropertyChangedEventArgs e)
{
foreach(var item in this.ItemsSource)
{
var piece = new SamplePiece {
Top = this.Top,
Left = this.Left,
Width = this.Width,
Height = this.Height,
Color = this.Color,
Bounds = new Rect(this.Left, this.Top, this.Width, this.Height),
};
this.hostControl.AddVirtualChild(piece);
}
}
protected sealed override void OnAttached()
{
base.OnAttached();
this.hostControl = (VirtualCanvas)this.AssociatedObject;
}
protected sealed override void OnDetaching()
{
base.OnDetaching();
}
}
VirtualCanvas に対しては、AddVirtualChildメソッドを使って、アイテムを追加してやる必要があります。
public class SamplePiece : IVirtualChild
{
private UIElement visual;
public event EventHandler BoundsChanged;
public SamplePiece()
{
}
public Rect Bounds { get; set; }
public double Top { get; set; }
public double Left { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public Color Color { get; set; }
public UIElement Visual
{
get { return this.visual; }
}
public void DisposeVisual()
{
this.visual = null;
}
public UIElement CreateVisual(VirtualCanvas parent)
{
if (this.visual == null)
{
var item = new Rectangle
{
Width = this.Width,
Height = this.Height,
Fill = new SolidColorBrush(this.Color),
SnapsToDevicePixels = true,
};
Canvas.SetTop(item, this.Top);
Canvas.SetLeft(item, this.Left);
this.visual = item;
}
return this.visual;
}
}
AddVirtualChildメソッドに指定できるクラスは、IVirtualChildインターフェイスから派生されたクラスになります。
UIElement クラスを生成するメソッドを持っているので、Entityとは切り離したいですよね。
ここは、工夫のしどころだと思います。
最後に
VirtualCanvas クラスは、使ってみると兎に角速いのが、いいです。
しかし、ItemsControlに使えないとか、アイテムの削除や移動が考慮されていないとか、実装サンプルレベルの所があるため、実際に使うとなると色々と手を入れる必要がありそうです。