次の方法で共有


基礎

効率的な ItemsControl の作成

Charles Petzold

コードは MSDN コード ギャラリーからダウンロードできます。
オンラインでのコードの参照

目次

ItemsControl による散布図
期待外れのパフォーマンス
隠れたループ
値コンバータの使用
Freezable オブジェクトに注意
中継手段としてのプレゼンタ
カスタム データ要素
限界
DrawingVisual ソリューション

すべての Windows Presentation Foundation (WPF) プログラマには、遅かれ早かれ DataTemplate のすばらしさに気付かされる時がきます。それは、"えっ、コードをまったく書かなくても DataTemplate だけで棒グラフや散布図を作成できるの" という実体験と共にやってきます。

一般に、DataTemplate は、ItemsControl と組み合わせて作成されます。ListBox、ComboBox、Menu、TreeView、ToolBar、StatusBar など、ItemsControl から派生したクラスと組み合わされる場合もあります。簡単に言えば、項目のコレクションを持ったあらゆるコントロールに使用されます。DataTemplate が定義するのは、コレクション内の各項目をどのように表示するかです。DataTemplate は主に 1 つまたは複数の要素のビジュアル ツリーで構成され、これらの要素のプロパティとコレクション内の項目とが、データ バインドによって結び付けられます。コレクション内の項目に、何かのプロパティ変更通知が実装されている場合、ItemsControl は、項目内で起こった変化に対して動的に反応することができます。ほとんどの場合、プロパティ変更通知は、INotifyPropertyChanged インターフェイスを実装することによって実装されます。

しかし、喜びもつかの間、そのパフォーマンスに失望させられることがあります。表示するデータが多くなると、ItemsControl および DataTemplate のパフォーマンスが追いつかない、という状況に遭遇します。このコラムでは、こうしたパフォーマンスの問題を克服するために、何ができるかについて説明します。

ItemsControl による散布図

ItemsControl と DataTemplate を使用して散布図を作成してみましょう。最初に、データ項目を表すビジネス オブジェクトを作成します。図 1 は、DataPoint というシンプルなクラスです (一部省略)。DataPoint には、INotifyPropertyChanged インターフェイスが実装されています。つまり、DataPoint には、PropertyChanged イベントが定義されており、DataPoint のオブジェクトのプロパティが変化すると、常にこのイベントが発生することになります。

図 1 データ項目を表す DataPoint クラス

public class DataPoint : INotifyPropertyChanged
{
    int _type;
    double _variableX, _variableY;
    string _id;

    public event PropertyChangedEventHandler PropertyChanged;

    public int Type
    {
        set
        {
            if (_type != value)
            {
                _type = value;
                OnPropertyChanged("Type");
            }
        }
        get { return _type; }
    }

    public double VariableX [...]
    public double VariableY [...]
    public string ID [...]

    protected virtual void OnPropertyChanged(string propertyName)
    {
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}

VariableX プロパティと VariableY プロパティは、デカルト座標系における座標点の位置です (このコラムでは値の範囲は 0 ~ 1)。Type プロパティは、データ要素 (0 ~ 5 の値を持ち、6 種類の色を使って情報を表示する) をグループ化するために使用します。また、ID プロパティは、個々の座標点を文字列で識別するプロパティです。

次に示したのは DataCollection クラスです。実際のアプリケーションでは、もっと多くのプロパティが含まれることになるかと思いますが、この例では、ObservableCollection<DataPoint> 型の DataPoints プロパティが 1 つだけ定義されています。

public class DataCollection
{
    public DataCollection(int numPoints)
    {
        DataPoints = new ObservableCollection<DataPoint>();
        new DataRandomizer<DataPoint>(DataPoints, numPoints, 
                                          Math.Min(1, numPoints / 100));
    }

    public ObservableCollection<DataPoint> DataPoints { set; get; }
}

ObservableCollection には、項目がコレクションに追加された場合またはコレクションから削除された場合に発生する CollectionChanged プロパティがあります。

ここで紹介した DataCollection クラスは、そのコンストラクタの中ですべてのデータ項目を作成します。データ項目の作成には、テスト用にランダムなデータを生成する DataPointRandomizer クラスを使用します。また、DataPointRandomizer オブジェクトでは、タイマを設定します。タイマのティック イベントのハンドラ メソッドを 0.1 秒ごとに呼び出し、全体の 1% の座標点について、VariableX プロパティまたは VariableY プロパティを変化させます。したがって、すべての座標点が、平均して 10 秒ごとに変化することになります。

今度は、このデータを散布図として表示する XAML を作成しましょう。図 2 は、ItemsControl を含んだ UserControl です。このコントロールの DataContext は、コードの中で DataCollection 型のオブジェクトに設定することにします。ItemsControl の ItemsSource プロパティは、DataCollection の DataPoints プロパティにバインドされます。つまり、この ItemsControl には、DataPoint 型の項目が流し込まれることになります。

図 2 DataDisplay1Control.xaml ファイル

<UserControl x:Class="DataDisplay.DataDisplayControl"
             xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:data="clr-namespace:DataLibrary;assembly=DataLibrary">

    <ItemsControl ItemsSource="{Binding DataPoints}">
        <ItemsControl.ItemTemplate>
            <DataTemplate DataType="data:DataPoint">
                <Path>
                    <Path.Data>
                        <EllipseGeometry RadiusX="0.003" RadiusY="0.003">
                            <EllipseGeometry.Transform>
                                <TranslateTransform X="{Binding VariableX}" 
                                                    Y="{Binding VariableY}" />
                            </EllipseGeometry.Transform>
                        </EllipseGeometry>
                    </Path.Data>

                    <Path.Style>
                        <Style TargetType="Path">
                            <Setter Property="Fill" Value="Red" />
                            <Style.Triggers>
                                <DataTrigger Binding="{Binding Type}" Value="1">
                                    <Setter Property="Fill" Value="Yellow" />
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Type}" Value="2">
                                    <Setter Property="Fill" Value="Green" />
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Type}" Value="3">
                                    <Setter Property="Fill" Value="Cyan" />
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Type}" Value="4">
                                    <Setter Property="Fill" Value="Blue" />
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Type}" Value="5">
                                    <Setter Property="Fill" Value="Magenta" />
                                </DataTrigger>
                            </Style.Triggers>
                        </Style>
                    </Path.Style>

                    <Path.ToolTip>
                        <StackPanel Orientation="Horizontal">
                            <TextBlock Text="{Binding ID}" />
                            <TextBlock Text=", X=" />
                            <TextBlock Text="{Binding VariableX}" />
                            <TextBlock Text=", Y=" />
                            <TextBlock Text="{Binding VariableY}" />
                        </StackPanel>
                    </Path.ToolTip>
                </Path>
            </DataTemplate>
        </ItemsControl.ItemTemplate>

        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Grid Background="Azure" 
                      LayoutTransform="300 0 0 300 0 0" 
                      IsItemsHost="True" />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
    </ItemsControl>
</UserControl>

ItemsControl の ItemTemplate プロパティは DataTemplate に設定され、この DataTemplate の中で、それぞれの項目を表示するためのビジュアル ツリーを定義しています。このツリーは、Path 要素のみで構成され、その Data プロパティは EllipseGeometry に設定されています。これは、半径 0.003 単位の非常に小さなドットです。しかし、実際に見えるドットは、そこまで極小ではありません。ItemsControl の ItemsPanel プロパティは ItemsPanelTemplate に設定され、ItemsPanelTemplate には、すべてのドットを表示する単一セルの Grid が含まれています。この Grid には、拡大倍率 300 の LayoutTransform が割り当てられます。これによって、データ ドットの半径がほぼ 1 単位の大きさになります。

この操作は、VariableX プロパティと VariableY プロパティを、TranslateTransform の X プロパティと Y プロパティにデータ バインドすることによって強制的に行われています。一見奇妙な操作に見えますが、それには理由があります。VariableX プロパティと VariableY プロパティは 0 ~ 1 の範囲でしか変動できません。そのため、画面上で 300 平方単位の領域を占有するように Grid のサイズを増やす必要があるのです。

Path 要素の Fill プロパティは Style の中で Brush に設定されます。設定される Brush は、各 DataPoint オブジェクトの Type プロパティの値に依存します。Path オブジェクトには、各データ要素の情報を表示する ToolTip が割り当てられます。

期待外れのパフォーマンス

このコラムに付属するソース コードは、7 つのプロジェクトから成る Visual Studio ソリューションとして配布されています。この 7 つのプロジェクトは、6 つのアプリケーションと 1 つのライブラリで構成されます。ライブラリの名前は DataLibrary です。DataPoint、DataCollection、DataPointRandomizer など、共有されるコードの大部分は、このライブラリに置かれています。

1 つ目のアプリケーション プロジェクトには、DataDisplay1 という名前が付けられています。このプロジェクトには、他のアプリケーションとの間で共有される MainWindow クラスのほか、図 2 に示した DataDisplay1Control.xaml ファイルが含まれています。MainWindow には、散布図を表示するためのコントロールのほか、項目数を入力するための TextBox、コレクションの作成を開始するためのボタン、および、経過時間を表示する 3 つの TextBlock オブジェクトが含まれます。グラフは 3 つの処理段階を経て表示されますが、その各段階の経過時間が表示されます。

既定の項目数は 1,000 です。この程度の項目数なら、プログラムは何も問題なく動作するように見えます。しかし、項目数を 10,000 に設定すると、結果が表示されるまでに時間がかかるようになります。その挙げ句に表示されるのは図 3 のような結果で、いかにも人為的に生成したデータという印象です。

fig03.gif

図 3 DataDisplay1 の表示結果

このプログラムでは、3 種類の経過時間が表示されます。ボタンをクリックすると、MainWindow.xaml.cs の Click ハンドラが呼び出され、指定されたデータ要素数で DataCollection 型のオブジェクトの作成が開始されます。第 1 の経過時間は、このコレクションの作成時間を表します。次に、このコレクションが、ウィンドウの DataContext に設定されます。これが第 2 の経過時間です。第 3 の経過時間は、ItemsControl が結果のデータ要素を表示するのにかかる時間です。この第 3 の経過時間を計算するにあたっては、ちょうどよい方法がないので、とりあえず、LayoutUpdated イベントを使用することにしました。

実行してみると、表示の更新にかかる時間が、全体の所要時間の大部分を占めていることがわかります。私のコンピュータでは、3 回の試行結果の平均時間は 7.7 秒でした。かなり遅い感じがします。プログラムそのものには何も問題がないだけに気になります。特別なことは何もせず、WPF の機能を普通に使用しているだけです。何が起こっているのでしょうか。

プログラムによって DataContext プロパティが DataPointCollection 型のオブジェクトに設定されると、ItemsControl が ItemsSource プロパティのプロパティ変更通知を受け取ります。ItemsControl は、DataPoint オブジェクトのコレクションを列挙し、各 DataPoint オブジェクトについて、ContentPresenter オブジェクトを作成します。なお、ContentPresenter クラスは FrameworkElement から派生します。つまり、ここでいう ContentPresenter は、ContentControl の派生クラス (Button、Window など) が、そのコンテンツを表示する際に使用する ContentPresenter 要素と同じものです。

各 ContentPresenter オブジェクトについて、Content プロパティが、対応する DataPoint オブジェクトに設定され、ContentTemplate プロパティが ItemsControl の ItemTemplate プロパティに設定されます。次に、これらの ContentPresenter オブジェクトは、ItemsControl がその項目を表示するために使用するパネル (このケースでは単一セルのグリッド) に追加されます。

ここまでの処理は、きわめて高速に実行されます。しかし、いざ ItemsControl を表示する段階になると、途端に時間がかかるようになります。パネルには、新しい子が累積していくため、その MeasureOverride メソッドが呼び出されます。この呼び出しこそが、10,000 個の項目の実行に 7.7 秒もかかる原因です。

パネルの MeasureOverride メソッドは、その子である各 ContentPresenter の Measure メソッドを呼び出します。子の ContentPresenter が、そのコンテンツを表示するためのビジュアル ツリーをまだ作成していない場合は、ここで作成する必要があります。このビジュアル ツリーは、ContentPresenter が、その ContentTemplate プロパティに格納されたテンプレートに基づいて作成します。また、ビジュアル ツリー内の要素のプロパティと、対応する Content プロパティに格納されたオブジェクト (この例では DataPoint オブジェクト) のプロパティとの間にデータ バインドを確立するのも ContentPresenter の仕事です。その後、ContentPresenter は、このビジュアル ツリーのルート要素の Measure メソッドを呼び出します。

もし、このプロセスに関心があり、もっと詳しい情報が必要な場合は、DataLibrary の DLL に、SingleCellGrid という名前のクラスがあるので、そちらを参照してください。パネル内部の処理を詳しく調べることができます。DataDisplay1Control.xaml ファイルの Grid は、単に、data:SingleCellGrid と置き換えることができます。

ListBox に多数の項目が存在するとしても、実際に表示されるのが一部だけならば、前述の初期作業の大部分はバイパスされます。ListBox に標準で使用される VirtualizingStackPanel は、実際に表示する段階で初めて、子を作成するためです。しかし、項目を一度に表示する散布図では、このようなことは不可能です。

隠れたループ

ループは、コンピュータ プログラミングの基礎的な概念です。コンピュータの利用価値は、ループを使って、同じような処理を何度も繰り返すことができるという、その一点につきます。しかし、今、ループは、私たちのプログラミングの世界から消え去ろうとしています。F# に代表される関数型プログラミング言語にいたっては、ループは、もはや古いプログラミング スタイルとして隅に追いやられ、配列、リスト、セットなどに対する操作が主流になりつつあります。同様に、LINQ のクエリ演算子も、明示的なループではなく、コレクションに対する操作を実行するものです。

明示的なループが廃れつつある背景には、単にプログラミング スタイルの変化だけではなく、コンピュータのハードウェアの進化があります。今日、比較的新しいコンピュータであれば、当たり前のように、2 つまたは 4 つのプロセッサを搭載しています。今後数年のうちに、数百個ものプロセッサが並列動作するコンピュータが登場する可能性があります。並列処理におけるループの難しさはプログラミング言語や開発フレームワークによって吸収され、プログラマが特別な気配りをしなくても、そのパワーを容易に活かすことができるようになるでしょう。

しかし、そんな夢のような未来が来るまでは、はっきりと見えなくてもやはり、ループの存在を意識する必要があります。ループを避けて通ることはできません。

ItemsControl の ItemTemplate セクション内で定義されている DataTemplate は、隠れたループの中に存在します。この DataTemplate は、要素など、各種オブジェクトを作成するために呼び出されますが、その数は、数千個になることも考えられます。データ バインドの設定も同様です。

実際に、このループをコーディングしなければならないとしたら、DataTemplate の設計をかなり慎重に行う必要があります。DataTemplate を微調整することは、確かに面倒で時間のかかる作業かもしれませんが、パフォーマンスへの影響を考えると、それに見合うだけの価値は十分にあります。ほんのわずかな工夫で、パフォーマンスに大きな変化が見られることでしょう。具体的にどの部分に、どの程度の変化があるかを予測するのは難しいので、いくつかのアプローチを試してみましょう。

一般に、DataTemplate のビジュアル ツリーは単純な方が好ましいとされています。要素、オブジェクト、およびデータ バインドの数は最小限に減らしましょう。

DataDisplay1Control.xaml を開き、ToolTip の TextBlock 項目に対するデータ バインドを削除してみてください (左側の中かっこの前に任意の文字を挿入するだけでもかまいません)。前回の 7.7 秒からほんのわずか (10 分の数秒程度) ですが、処理時間が短縮されるはずです。

今度は、ToolTip セクション全体をコメント アウトします。すると、表示時間が 4.7 秒まで短縮されます。Path 要素の Fill プロパティに何か色を設定し、Style セクション全体をコメント アウトすると、今度は表示時間が 3.5 秒まで短縮されます。Path 要素から変換処理を削除すると、約 1 秒にまで縮まります。

もちろん、これではデータが表示されないので意味がありません。しかし、これらの項目がパフォーマンスにどのような影響を及ぼしているか、大体の感触がつかめたのではないでしょうか。マークアップを少しいじっただけですが、非常に大きな成果です。

以下は、同じ機能を保ちながら、パフォーマンスと可読性の観点から、元のコードを改良したものです。Path.ToolTip タグの内容を次の内容と置き換えてください。

<TextBlock>
    <TextBlock.Text>
        <MultiBinding StringFormat="{}{0}, X={1}, Y={2}">
            <Binding Path="ID" />
            <Binding Path="VariableX}" />
            <Binding Path="VariableY}" />
        </MultiBinding>
    </TextBlock.Text>
</TextBlock>

バインドの StringFormat オプションは .NET 3.5 SP1 の新機能です。これを利用すると、表示時間が 7.7 秒から 6.4 秒に縮まります。

値コンバータの使用

データ バインドでは、必要に応じて、値コンバータと呼ばれる小さなクラスを参照することもできます。このクラスには、IValueConverter または IMultiValueConverter インターフェイスが実装されています。値コンバータには、Convert および ConvertBack という名前のメソッドがあり、これらのメソッドによって、バインディング ソースとバインディング ターゲット間でデータの変換が実行されます。

一般に、コンバータはさまざまなアプリケーションで汎用的に使用されます。たとえば、BooleanToVisibilityConverter は、true と false をそれぞれ Visibility.Visible と Visibility.Collapsed に変換する便利なコンバータです。ただし、コンバータは、その必要性が高いほど、場当たり的になる傾向があります。

DataTemplate を単純化し、データ バインドの数を減らすには、2 つのコンバータを作成します。図 4 に示したのは、DataLibrary DLL に含まれている IndexToBrushConverter というコンバータです。負以外の整数を Brush に変換します。このコンバータは、Brush 型の配列である、Brushes というパブリック プロパティを持ちます。整数は、単に、この配列のインデックスとして使用されます。

図 4 IndexToBrushConverter クラス

public class IndexToBrushConverter : IValueConverter
{
    public Brush[] Brushes { get; set; }

    public object Convert(object value, Type targetType, 
                          object parameter, CultureInfo culture)
    {
        return Brushes[(int)value];
    }

    public object ConvertBack(object value, Type targetType, 
                              object parameter, CultureInfo culture)
    {
        return null;
    }
}

コンバータは、XAML ファイルのリソース セクションで、x:Array グループを使用してインスタンス化できます。

<data:IndexToBrushConverter x:Key="indexToBrush">
    <data:IndexToBrushConverter.Brushes>
        <x:Array Type="Brush">
            <x:Static Member="Brushes.Red" />
            <x:Static Member="Brushes.Yellow" />
            <x:Static Member="Brushes.Green" />
            <x:Static Member="Brushes.Cyan" />
            <x:Static Member="Brushes.Blue" />
            <x:Static Member="Brushes.Magenta" />
        </x:Array>
    </data:IndexToBrushConverter.Brushes>
</data:IndexToBrushConverter>

これで、図 2 に示した Style セクション全体を、このコンバータを参照するバインドに置き換えることができます。

<Path Fill="{Binding Type, Converter={StaticResource indexToBrush}}">

DataLibrary にはもう 1 つ、DoublesToPointConverter というコンバータがあります。IMultiValueConverter インターフェイスを実装し、2 つの double 値 (X および Y) から Point を作成するというものです。このコンバータを使用すると、EllipseGeometry の Center プロパティを直接設定できるため、TranslateTransform は不要になります。

DataDisplay2 プロジェクトには、これらのコンバータを使用し、ToolTip に StringFormat のアプローチを使用しているにもかかわらず、処理時間は期待したほど短縮されません。ToolTip を使用した場合で 7.7 秒、使用しなかった場合で 4.4 秒、定数 Fill ブラシの使用で 3.4 秒という結果になりました。

通常は、コンバータを使用することで、パフォーマンスの向上を期待できます。それがなぜ今回はそうならないのでしょうか。理由は定かでありませんが、ボックス化とボックス化解除が関係していると考えれば納得できます。たとえば、DoublesToPointConverter では、引数の double 値と、戻り値の Point のそれぞれについて、ボックス化とボックス化解除が必要となります。

Freezable オブジェクトに注意

DataDisplay2 のパフォーマンスが本当に劣化するのか確認したい方は、Brush の x:Static メンバを変更してみてください。

<x:Static Member="Brushes.Red" />

これを SolidColorBrush オブジェクトに置き換えます。

<SolidColorBrush Color="Red" />

表示時間が一気に 20 秒を超えます。しかし、x:Static 要素も、SolidColorBrush 要素も、ほとんど同じように見えます。静的 Brushes.Red プロパティは、Color が Red に設定された SolidColorBrush を返します。

ただし、SolidColorBrush は Freezable から派生している点に注意してください。Brushes.Red プロパティからは、フリーズされたスレッド セーフな SolidColorBrush が返されます。この値は変更できません。ビジュアル コンポジション システムに渡した場合、このブラシは定数として扱われます。

これに対し、明示的な SolidColorBrush はフリーズされません。ビジュアル コンポジション システム内でも生き続け、Color プロパティの変更にも応答します。この潜在的な動的特性がシステムの処理を複雑にし、パフォーマンスの劣化を招いているのです。

結論としては、フリーズ可能なオブジェクトは、変更する予定がないのであれば、すべてフリーズした方がよい、ということです。これは、コード内で Freeze メソッドを呼び出すか、XAML で PresentationOptions:Freeze オプションを使用することによって行います。処理の対象が少ないと、違いを体感できないかもしれません。しかし、10,000 個のグラフィック オブジェクトに対し、フリーズされていないブラシを使用した場合、パフォーマンスに重大な影響を及ぼすことは明らかです。

中継手段としてのプレゼンタ

多くの一般的なアプリケーション アーキテクチャでは、ユーザー インターフェイスと実際のビジネス オブジェクト (この例では、DataPoint および DataCollection) との間に、ある種の中継手段を設けるのが慣例となっています。その慣例に従い、この中継手段を "プレゼンタ" と呼ぶことにします (ただし、従来のプレゼンタは、ここで紹介するものよりもずっと多くの処理を行います)。

このプレゼンタの役割は、ビジネス オブジェクトからの情報を、ユーザー インターフェイスへのバインドに適した形式にすることです。たとえば、DataPoint ビジネス オブジェクトの VariableX と VariableY は、double 型のプロパティです。そこで、この後、紹介する DataPointPresenter クラスには、Variable という Point 型のプロパティが実装されています。

パフォーマンス上の理由から、DataPointPresenter を DataPoint の派生クラスとして作成しました。Variable プロパティに加え、Brush (Brush 型) プロパティおよび ToolTip (文字列型) プロパティが定義されています。図 5 は、DataPointPresenter クラスを示しています (一部省略)。

図 5 DataPointPresenter クラス

public class DataPointPresenter : DataPoint
{
    static readonly Brush[] _brushes = 
    { 
        Brushes.Red, Brushes.Yellow, Brushes.Green, 
        Brushes.Cyan, Brushes.Blue, Brushes.Magenta 
    };
    Point _variable;
    Brush _brush;
    string _tooltip;

    public Point Variable [...]
    public Brush Brush [...]
    public string ToolTip [...]

    protected override void OnPropertyChanged(string propertyName)
    {
        switch (propertyName)
        {
            case "VariableX":
            case "VariableY":
                Variable = new Point(VariableX, VariableY);
                goto case "ID";

            case "ID":
                ToolTip = String.Format("{0}, X={1}, Y={2}",
                                        ID, VariableX, VariableY);
                break;

            case "Type":
                Brush = _brushes[Type];
                break;
        }
        base.OnPropertyChanged(propertyName);
    }
}

DataLibrary DLL には、DataCollectionPresenter というクラスも存在します。管理の対象が DataPointPresenter オブジェクトのコレクションである、という点を除けば、DataCollection とまったく同じです。

DataDisplay3 プロジェクトには、これらのプレゼンタ クラスが組み込まれています。DataTemplate の部分は次のように記述されています。

<DataTemplate DataType="data:DataPointPresenter">
    <Path Fill="{Binding Brush}"
          ToolTip="{Binding ToolTip}">
        <Path.Data>
            <EllipseGeometry Center="{Binding Variable}"
                             RadiusX="0.003" RadiusY="0.003" />
        </Path.Data>
    </Path>
</DataTemplate>

このアプローチは、コンバータよりもずっと効果的であり、表示時間も、ToolTip を含め、すべての機能を保った状態で、3.3 秒に縮まりました。ただし、残念なことに、大きな欠点があります。各種のブラシが DataPointPresenter クラスにハードコーディングされている点です。これは決して最適な状態ではありません。WPF プログラミングでは、コード ファイルを一切開くことなく、XAML だけで微調整できることが理想です。

Type の値が 5 を超える可能性があるのであれば、なおさら、ブラシは XAML ファイルに記述した方がよいでしょう。1 つ考えられる方法としては、ブラシの配列を Application XAML ファイルの Resources セクションに格納し、DataPointPresenter の 1 つ目のインスタンスから、それらにアクセスし、静的変数に格納できるようにすることです。この例の中でプレゼンタがいかに重要な役割を担っているか、おわかりいただけたと思います。この後は、プレゼンタを使用しないアプローチを取り上げていきます。

カスタム データ要素

散布図に小さなドットを描画する方法に関して、これまでは、Path 要素を使用し、その Data プロパティを EllipseGeometry に設定する方法を採用してきました。EllipseGeometry には、Point 型の Center プロパティがあるため、コンバータまたはプレゼンタを作成して、double 型の 2 つのプロパティから Point オブジェクトを取得する必要がありました。

Path と EllipseGeometry という組み合わせに代わるもう 1 つの方法は、FrameworkElement から派生したカスタム クラスを使用してドットを描画する方法です。カスタム クラスなので、Point 型の Center プロパティではなく、double 型の CenterX プロパティおよび CenterY プロパティを実装することができます。Brush 型の配列である Brushes プロパティのインデックスにも、Brush 型の Fill プロパティではなく、int 型の FillIndex プロパティを使用できます。

これを踏まえて作成したのが DataLibrary プロジェクトの DataDot クラスです。依存関係プロパティと、それらをラップする CLR プロパティで構成される、ごくありきたりの内容となっています。MeasureOverride は、次の 1 行で記述されています。

return new Size(CenterX + RadiusX, CenterY + RadiusY);

OnRender もかなりシンプルです。

if (Brushes != null)
    dc.DrawEllipse(Brushes[FillIndex], null, 
                   new Point(CenterX, CenterY), 
                   RadiusX, RadiusY);

DataDisplay4 プロジェクトの DataTemplate では、DataDot が次のように記述されています。

<data:DataDot CenterX="{Binding VariableX}" 
              CenterY="{Binding VariableY}"
              Brushes="{StaticResource brushes}"
              FillIndex="{Binding Type}"
              RadiusX="0.003" RadiusY="0.003">

"brushes" というリソース キーは、6 つの色を保持する x:Array 要素を参照しています。

このカスタム要素を使用した DataDisplay4 プロジェクトの場合、表示時間は ToolTip を使用した場合で 3.3 秒、ToolTip なしでは 2.5 秒となりました。DataDot クラスのありきたりな性質を考えれば、非常に高いパフォーマンス効果と言えます。

限界

DataTemplate から要素やオブジェクトをできるだけ減らし、データ バインドも最小限に抑え、できることはすべて行ったにもかかわらず、満足のいくパフォーマンスが得られないとしたら、そこが限界かもしれません。

ItemsControl と DataTemplate を組み合わせた方法は確かに強力な手段ですが、皆さんが目的とする用途にとって、おそらく最善のソリューションではない、ということです。WPF の実用性を否定するわけではまったくありません。皆さんは、今後も、WPF を使用して、ソフトウェアを開発していくことになるでしょう。ただ、内部的に FrameworkElement の派生オブジェクトが 10,000 個も作成されるというのは、リソースの使用効率上、最善ではない可能性があります。これに代わる方法としては、すべての処理を一手に担うカスタム クラスを FrameworkElement から派生して作成することが考えられます。つまり、10,000 個の要素を作成するというアプローチを捨て、1 つの要素に処理を集約します。

DataLibrary DLL の ScatterPlotRender クラスは、FrameworkElement から派生され、3 つの依存関係プロパティを持ちます。ObservableNotifiableCollection<DataPoint> 型の ItemsSource、Brush 型の配列である Brushes、そして、Brush 型の Background です。

以前、MSDN Magazine の 2008 年 9 月号で私が執筆したコラム「依存関係プロパティと通知」の中で、ObservableNotifiableCollection というクラスを紹介しました。このクラスのメンバには、INotifyPropertyChanged インターフェイスが実装されている必要があります。このクラスでは、コレクションにオブジェクトが追加されたときや、コレクションからオブジェクトが削除されたときだけでなく、コレクション内のオブジェクトのプロパティが変更された場合にも、イベントが発生します。ScatterPlotRender は、このしくみを利用して、DataPoint オブジェクトの VariableX プロパティと VariableY プロパティが変化したときの通知を受け取ります。

ScatterPlotRender クラスは、これらすべてのイベントを非常に単純な方法で処理します。ItemsSource プロパティが変化したときや、コレクションが変化したとき、または、コレクション内の DataPoint オブジェクトのプロパティが変化したとき、ScatterPlotRender は InvalidateVisual を呼び出します。これにより、散布図全体を描画する OnRender の呼び出しが生成されます。図 6 に、そのコードを示します。

図 6 OnRender

protected override void OnRender(DrawingContext dc)
{
    dc.DrawRectangle(Background, null, new Rect(RenderSize));

    if (ItemsSource == null || Brushes == null)
        return;

    foreach (DataPoint dataPoint in ItemsSource)
    {
        dc.DrawEllipse(Brushes[dataPoint.Type], null, 
            new Point(RenderSize.Width * dataPoint.VariableX,
                      RenderSize.Height * dataPoint.VariableY), 1, 1);
    }
}

VariableX および VariableY の値に、要素の幅と高さが乗算されている点に注目してください (要素の幅と高さは、XAML ファイルで設定)。DataDisplay5 プロジェクトの XAML ファイルでは、ScatterPlotRender オブジェクトが次のようにインスタンス化されます。

<data:ScatterPlotRender Width="300" Height="300"
                        Background="Azure" 
                        ItemsSource="{Binding DataPoints}"
                        Brushes="{StaticResource brushes}" />

brushes リソース キーが参照するのは、フリーズされた SolidColorBrush オブジェクトの配列です。

このアプローチの長所は、画面に対して散布図をきわめて高速に出力できることです。WPF 要素を視覚的に表示する方法としては、OnRender メソッドでの描画が最も高速です。短所は、VariableX または VariableY プロパティが変化したときに要素全体を再描画する必要があり、しかも、それが 0.1 秒おきに起こる点です。これまでのバージョンの CPU 使用率は、私のコンピュータでは約 10% でした。それが、今回のバージョンは、30% まで上昇しています。アプリケーションで頻繁にデータを更新する場合、描画方法をもっと工夫する必要がありそうです。この点については、次のセクションで取り上げます。

もう 1 つ大きな欠点は、ToolTip がないことです。このクラスで ToolTip を実装することも不可能ではないですが、かなり面倒なのは間違いありません。次のバージョンでは、ToolTip を実装することにします。

DrawingVisual ソリューション

FrameworkElement から派生したクラスは、通常、自分自身を OnRender メソッドで描画しますが、ビジュアルの子のコレクションを保持することによって視覚的な表現を与えることもできます。これらの子は、OnRender メソッドによって描画されたものよりも、常に手前に表示されます。

"ビジュアルの子" とは、Visual から派生したすべてのものを指します。たとえば、FrameworkElement や Control がそうです。ただし、FrameworkElement の派生クラスで、比較的軽量なビジュアルの子を、DrawingVisual オブジェクトの形式で作成することもできます。

FrameworkElement の派生クラスで DrawingVisual オブジェクトを作成する場合、これらのオブジェクトは VisualChildren コレクションに格納するのが一般的です。ビジュアルの子を保持する際のオーバーヘッドは、ある程度、そこで処理されることになります。ただし、VisualChildrenCount および GetVisualChild をオーバーライドすることは必要です。

ScatterPlotVisual クラスは、各 DataPoint の DrawingVisual オブジェクトを作成するように設計されています。DataPoint オブジェクトのプロパティが変化した場合は、その DataPoint に関連付けられた DrawingVisual を変更するだけで済みます。

ScatterPlotRender クラスと同様、ScatterPlotVisual クラスにも、ItemsSource、Brushes、Background という 3 つの依存関係プロパティが定義されています。さらに、このクラスには、ItemsSource コレクションと同期される VisualChildren コレクションがプロパティとして定義されています。ItemsSource コレクションに項目が追加されると、新しいビジュアルが VisualChildren コレクションに追加されます。ItemsSource コレクションから項目が削除されると、対応するビジュアルが VisualChildren コレクションから削除されます。ItemsSource コレクションに含まれる項目の VariableX プロパティまたは VariableY プロパティが変更された場合は、VisualChildren コレクション内の対応する項目も変更する必要があります。

このような同期のしくみを実現するため、VisualChildren コレクションには、DrawingVisual 型のオブジェクトは格納されません。VisualChildren コレクションが実際に保持するのは、DrawingVisualPlus 型のオブジェクトです。DrawingVisualPlus クラスは、ScatterPlotVisual クラスの内部に、次のように定義されています。

class DrawingVisualPlus : DrawingVisual
{
    public DataPoint DataPoint { get; set; }
}

この追加のプロパティによって、特定の DataPoint オブジェクトに対応する DrawingVisualPlus オブジェクトを、VisualChildren コレクションから容易に探し出すことができます。

この方法を実装した DataDisplay6 プロジェクトが、これまで紹介した中で最良のアプローチです。DataDisplay5 と比べると、起動時の生成時間が若干延びますが、かろうじて気付く程度の差です。一方、表示の更新にかかるオーバーヘッドは大幅に削減されます。

ただし、データ要素の数を一気に 100,000 個に増やすと、また WPF の動作が鈍くなってきます。このレベルのグラフィック出力になると、ちょっとよい方法が浮かびません。より高性能のコンピュータを購入することぐらいでしょうか。

ご意見やご質問は mmnet30@microsoft.com まで英語でお送りください。

Charles Petzold は MSDN Magazine の記事を長期にわたって担当している寄稿編集者です。最新の著書には『The Annotated Turing: A Guided Tour through Alan Turing's Historic Paper on Computability and the Turing Machine』(Wiley、2008 年) があります。彼の Web サイトは www.charlespetzold.com です。