[C# WPF] なんとかしてWPFの描画を速くしたい「Canvas.Childrenへのオブジェクト追加」
最近WPFのパフォーマンスチューニングに勤しんでいます。
300,000個ほどのオブジェクトを描画するデスクトップアプリを作っている中で、役に立ったり効果のあった話をまとめていきます。
基本的には速度低下を招くよろしくない実装の確認や、対策の紹介などしていきます。
今回は描画するものが多い場合に「Canvasへオブジェクトを個別に配置していく場合」と、「Canvasへはひとつのオブジェクトを配置して、オブジェクト内のOnRender()ですべて描画する場合」の比較です。
やる前からどっちがいいかはわかりそう。。。
環境
- Visual Studio 2019
- .NET Framework 4.7.2
確認すること
以下の2通りの描画方法で実装した場合に、スクロール中の再描画はどちらが早いか。
- 「四角形を描画するControl」をCanvasへ200,000個追加する
- 「Controlをひとつだけ」Canvasに追加し、「Controlの中で200,000個の四角形を描画」する
検証コード(Canvas上に全部描く)
まずはいちばん雑な実装から。ふつうこんなことはしない。
描画領域内に200,000個のControlを配置しますが、とりあえずCanvas上に全部置きます。
ScrollViewerの表示外にもControlを描画します。
MainWindow.xaml
<Window x:Class="Sample_Performance_OnRender.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Sample_Performance_OnRender"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800"
Loaded="Window_Loaded"
>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
HorizontalScrollBarVisibility="Visible"
VerticalScrollBarVisibility="Hidden"
>
<Canvas x:Name="xCanvas1"
Width="100000"
/>
</ScrollViewer>
<ScrollViewer Grid.Row="1"
HorizontalScrollBarVisibility="Visible"
VerticalScrollBarVisibility="Hidden"
>
<Canvas x:Name="xCanvas2"
Width="100000">
<local:Squares x:Name="xSquares"/>
</Canvas>
</ScrollViewer>
</Grid>
</Window>
MainWindow.xaml.cs
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
namespace Sample_Performance_OnRender
{
/// <summary>
/// MainWindow.xaml の相互作用ロジック
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
var squares = new List<Square>();
// 適当なシードで乱数を生成する
var randH = new Random(17280489);
var randV = new Random(399594);
// Canvas1
for (var i = 0; i < 200000; ++i)
{
var s = new Square();
Canvas.SetLeft(s, randH.Next(100000));
Canvas.SetTop(s, randV.Next(300));
s.Width = 5;
s.Height = 5;
xCanvas1.Children.Add(s);
squares.Add(s);
}
// Canvas2
xSquares.Objects = squares;
xSquares.InvalidateVisual();
}
}
}
Square.cs
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Sample_Performance_OnRender
{
class Square : Control
{
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
drawingContext.DrawRectangle(Brushes.Crimson, new Pen(Brushes.Crimson, 1), new Rect(0, 0, Width, Height));
}
}
class Squares : Control
{
public List<Square> Objects;
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
if (Objects == null)
{
return;
}
foreach (Square s in Objects)
{
var rect = new Rect(Canvas.GetLeft(s), Canvas.GetTop(s), s.Width, s.Height);
drawingContext.DrawRectangle(Brushes.CadetBlue, new Pen(Brushes.CadetBlue, 1), rect);
}
}
}
}
結果(Canvasに全部描く)

この状態だとどちらも使い物にならない速度です。
遅さ加減も比較が難しいです。
もう少しまともな実装にして違いを確認します。
検証コード(ScrollViewerの表示領域内にだけ描画)
ScrollViewerの表示領域内にだけ描画をするように変えます。
1の実装はControlをListに格納しておいて、スクロールのタイミングでCanvasへ追加/削除を行うように変えます。
VisualHitTest使わないと死ぬとかどうしてもオブジェクトで扱いたい場合にはこうすると多少早くなります。
2の実装はOnRender()での描画時に表示領域内のオブジェクトだけ描くように変えます。
いずれもすべてのSquareオブジェクトを先頭から線形にチェックします。
MainWindow.xaml
<Window x:Class="Sample_Performance_OnRender.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Sample_Performance_OnRender"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800"
Loaded="Window_Loaded"
>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="0"
x:Name="xSV1"
HorizontalScrollBarVisibility="Visible"
VerticalScrollBarVisibility="Hidden"
ScrollChanged="XSV1_ScrollChanged"
>
<Canvas x:Name="xCanvas1"
Width="100000"
/>
</ScrollViewer>
<ScrollViewer Grid.Row="1"
x:Name="xSV2"
HorizontalScrollBarVisibility="Visible"
VerticalScrollBarVisibility="Hidden"
ScrollChanged="XSV2_ScrollChanged"
>
<Canvas x:Name="xCanvas2"
Width="100000">
<local:Squares x:Name="xSquares"/>
</Canvas>
</ScrollViewer>
</Grid>
</Window>
MainWindow.xaml.cs
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
namespace Sample_Performance_OnRender
{
public partial class MainWindow : Window
{
private List<Square> Objects = new List<Square>();
public MainWindow()
{
InitializeComponent();
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
// 適当なシードで乱数を生成する(四角形を適当な位置へ置くのに使用)
var randH = new Random(17280489);
var randV = new Random(399594);
// Canvas1
for (var i = 0; i < 200000; ++i)
{
var s = new Square();
Canvas.SetLeft(s, randH.Next(100000));
Canvas.SetTop(s, randV.Next(300));
s.Width = 5;
s.Height = 5;
Objects.Add(s);
}
UpdateViewport();
// Canvas2
xSquares.SV = xSV2;
xSquares.Objects = Objects;
xSquares.InvalidateVisual();
}
/// <summary>ScrollViewerの表示領域に合わせて,Canvas1.Childrenの中身を更新する</summary>
private void UpdateViewport()
{
var viewRect = new Rect(xSV1.HorizontalOffset, xSV1.VerticalOffset, xSV1.ViewportWidth, xSV1.ViewportHeight);
foreach (Square s in Objects)
{
var rect = new Rect(Canvas.GetLeft(s), Canvas.GetTop(s), s.Width, s.Height);
if (viewRect.IntersectsWith(rect))
{
// 表示領域内に入った場合はCanvasへ追加する
if (xCanvas1.Children.Contains(s) == false)
{
xCanvas1.Children.Add(s);
}
}
else
{
// 表示領域外へ入った場合はCanvasから削除する
if (xCanvas1.Children.Contains(s))
{
xCanvas1.Children.Remove(s);
}
}
}
}
private void XSV1_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
UpdateViewport();
}
private void XSV2_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
// スクロールされたら再描画する
xSquares.InvalidateVisual();
}
}
}
Square.cs
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Sample_Performance_OnRender
{
class Square : Control
{
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
drawingContext.DrawRectangle(Brushes.Crimson, new Pen(Brushes.Crimson, 1), new Rect(0, 0, Width, Height));
}
}
class Squares : Control
{
public ScrollViewer SV;
public List<Square> Objects;
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
if (SV == null || Objects == null)
{
return;
}
// ScrollViewerで表示されている領域
var viewRect = new Rect(SV.HorizontalOffset, SV.VerticalOffset, SV.ViewportWidth, SV.ViewportHeight);
foreach (Square s in Objects)
{
var rect = new Rect(Canvas.GetLeft(s), Canvas.GetTop(s), s.Width, s.Height);
// 四角形が表示領域内に含まれる場合のみ描画する
if (viewRect.IntersectsWith(rect))
{
drawingContext.DrawRectangle(Brushes.CadetBlue, new Pen(Brushes.CadetBlue, 1), rect);
}
}
}
}
}
結果(ScrollViewerの表示領域内にだけ描画)

どちらも最初の実装に比べてだいぶ早くなっているのがわかります。
とはいえ、修正後では1の実装よりも2の実装の方が明らかに早いです。
結論
大量のオブジェクトを描画したい場合には、Canvasに追加するオブジェクトを極力少なくして、ControlのOnRender()で描画するのが良さそうです。
この実装のデメリットはVisualHitTestが使えなくなるため、HitTestを自作する必要があることくらいでしょうか。
おしまい。



ディスカッション
コメント一覧
まだ、コメントがありません