ListView と GridView の UI の最適化

詳しくは、//build/ セッション「Dramatically Increase Performance when Users Interact with Large Amounts of Data in GridView and ListView (ユーザーが GridView と ListView で大量のデータを操作するときのパフォーマンスを大幅に向上させる)」をご覧ください。

ListViewGridView のパフォーマンスと起動時間を、UI の仮想化、要素の削減、項目の段階的な更新を通して向上させます。 データ仮想化の手法については、ListView と GridView のデータ仮想化」を参照してください。

コレクション パフォーマンスの主要な 2 つの要因

コレクションの操作は一般的なシナリオです。 フォト ビューアーには写真のコレクションがあり、リーダーには記事/書籍/ストーリーのコレクションががあり、ショッピング アプリには製品のコレクションがあります。 このトピックでは、アプリでコレクションの操作を効率よく行うために何ができるかについて説明します。

コレクションに関しては、パフォーマンスに 2 つの主要な要因があります。1 つは、項目を作成する UI スレッドによって費やされた時間で、もう 1 つは、生データ セットとそのデータのレンダリングに使用される UI 要素の両方で使われるメモリです。

スムーズなパン/スクロールを実現するには、UI スレッドで、項目のインスタンス化、データ バインディング、レイアウトの効率的でスマートなジョブを実行することが重要です。

UI の仮想化

UI の仮想化は、実行可能な最も重要な改善です。 これは、項目を表す UI 要素が必要に応じて作成されることを意味します。 1,000 項目のコレクションにバインドされている項目コントロールでは、すべての項目の UI を同時に作成しても、同時にすべてを表示することはできないため、リソースを無駄に使うことになります。 UI の仮想化は、ListViewGridView (およびその他の ItemsControl から派生した標準コントロール) によって自動的に実行されます。 数ページ先にある項目がスクロールされて表示されそうになると、フレームワークでその項目用の UI が生成されてキャッシュされます。 項目がもう一度表示される可能性が低い場合、フレームワークでメモリが解放されます。

カスタム項目パネル テンプレート (ItemsPanel をご覧ください) を用意する場合は、ItemsWrapGridItemsStackPanel などの仮想パネルを必ず使用してください。 VariableSizedWrapGridWrapGrid、または StackPanel を使用した場合、仮想化は得られません。 また、ListView イベント (ChoosingGroupHeaderContainerChoosingItemContainerContainerContentChanging) は、ItemsWrapGrid または ItemsStackPanel を使用したときにのみ発生します。

表示される可能性のある要素の作成はフレームワークで行う必要があるため、ビューポートの概念は UI の仮想化にとって非常に重要です。 一般に、ItemsControl のビューポートは論理コントロールの範囲です。 たとえば、ListView のビューポートは ListView 要素の幅と高さです。 一部のパネルでは、子要素に制限のないスペースを確保できます。たとえば、ScrollViewerGrid では、行または列のサイズが自動的に調整されます。 このようなパネルに仮想化された ItemsControl を配置すると、すべての項目を表示できるスペースが用意され、仮想化の意味がなくなります。 仮想化を復元するには、ItemsControl に幅と高さを設定します。

項目ごとの要素の削減

項目のレンダリングに使用される UI 要素の数を、妥当と思われる最小限の数に抑えます。

項目コントロールが最初に表示されるときに、項目でいっぱいのビューポートをレンダリングするために必要なすべての要素が作成されます。 また、項目がビューポートに近づくと、、フレームワークによって、キャッシュされた項目テンプレートの UI 要素がバインドされたデータ オブジェクトで更新されます。 テンプレート内のマークアップの複雑さを最小限に抑えることで、メモリと UI スレッドに費やされた時間が短縮され、特にパン/スクロール中の応答性が向上します。 問題のテンプレートは、項目テンプレート (ItemTemplate をご覧ください) と、ListViewItem または GridViewItem のコントロール テンプレート (項目コントロール テンプレート、または ItemContainerStyle) です。 要素の数を少しでも減らす利点は、表示される項目の数に応じて増えます。

要素の削減の例については、「XAML マークアップの最適化」をご覧ください。

ListViewItemGridViewItem 用の既定のコントロール テンプレートには、ListViewItemPresenter 要素が含まれています。 このプレゼンターは、フォーカス状態や選択状態などの複雑なビジュアルを表示する、1 つの最適化された要素です。 カスタム項目コントロール テンプレート (ItemContainerStyle) が既に存在する場合、または今後、項目コントロール テンプレートのコピーを編集する場合は、ListViewItemPresenter を使うことをお勧めします。この要素を使うと、ほとんどの場合、パフォーマンスとカスタマイズ性の最適なバランスを得ることができます。 プレゼンターは、プロパティを設定することによってカスタマイズできます。 例として、項目が選ばれたときに既定で表示されるチェック マークを削除し、選ばれた項目の背景色をオレンジ色に変更するマークアップを次に示します。

...
<ListView>
    ...
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="ListViewItem">
                        <ListViewItemPresenter SelectionCheckMarkVisualEnabled="False" SelectedBackground="Orange"/>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ListView.ItemContainerStyle>
</ListView>
<!-- ... -->

SelectionCheckMarkVisualEnabledSelectedBackground に似た自己記述型の名前を持つ約 25 個のプロパティがあります。 このプレゼンターの種類ではユース ケースに合うように十分にカスタマイズできないことがわかった場合は、ListViewItemExpanded または GridViewItemExpanded コントロール テンプレートのコピーを代わりに編集できます。 これらは \Program Files (x86)\Windows Kits\10\DesignTime\CommonConfiguration\Neutral\UAP\<version>\Generic\generic.xaml にあります。 これらのテンプレートを使用すると、ある程度のパフォーマンスと引き換えにカスタマイズが増えるという意味であることに注意してください。

GridView と ListView の項目を段階的に更新する

データ仮想化を使う場合は、項目の読み込み中に一時的な UI 要素をレンダリングするようにコントロールを構成することで、ListViewGridView の高い応答性を維持できます。 一時的な要素は、データの読み込み時に実際の UI に徐々に置き換えられます。

また、データの読み込み元 (ローカル ディスク、ネットワーク、またはクラウド) に関係なく、ユーザーは、ListView または GridView をすばやくパン/スクロールできます。この間、スピードが速すぎて完全な忠実度で各項目をレンダリングすることはできませんが、スムーズなパン/スクロールは維持されます。 スムーズなパン/スクロールを維持するために、プレース ホルダーを使うだけでなく、項目を複数のフェーズでレンダリングすることを選ぶことができます。

これらの手法の例は、写真表示アプリでよく見られます。すべての画像が読み込まれて表示されていない場合でも、ユーザーは引き続きパン/スクロールしてコレクションを操作できます。 または、"映画" 項目の場合は、第 1 フェーズのタイトル、第 2 フェーズの評価、および第 3 フェーズのポスターの画像を表示できます。 ユーザーはできるだけ早く各項目に関する最も重要なデータを見ることができます。これは、すぐにアクションを実行できることを意味します。 その後、時間の許す限り、重要度の低い情報が入力されます。 これらの手法を実装するために使うことができるプラットフォームの機能を次に示します。

プレースホルダー

一時的なプレースホルダーのビジュアル機能は既定でオンになっており、ShowsScrollingPlaceholders プロパティを使って制御されます。 高速パン/スクロール中に、この機能を使用すると、滑らかさを維持しながら、さらに多くの項目がまだ完全に表示されていないという視覚的ヒントがユーザーに提供されます。 次のいずれかの手法を使う場合は、必要に応じて ShowsScrollingPlaceholders を false に設定して、プレースホルダーが表示されないようにすることができます。

x:Phase を使用した段階的なデータ テンプレートの更新

x:Phase 属性{x:Bind} バインドを使って段階的なデータ テンプレートの更新を実装する方法を次に示します。

  1. これは、バインディング ソースがどのようになるかを示しています (これは、バインド先のデータ ソースです)。

    namespace LotsOfItems
    {
        public class ExampleItem
        {
            public string Title { get; set; }
            public string Subtitle { get; set; }
            public string Description { get; set; }
        }
    
        public class ExampleItemViewModel
        {
            private ObservableCollection<ExampleItem> exampleItems = new ObservableCollection<ExampleItem>();
            public ObservableCollection<ExampleItem> ExampleItems { get { return this.exampleItems; } }
    
            public ExampleItemViewModel()
            {
                for (int i = 1; i < 150000; i++)
                {
                    this.exampleItems.Add(new ExampleItem(){
                        Title = "Title: " + i.ToString(),
                        Subtitle = "Sub: " + i.ToString(),
                        Description = "Desc: " + i.ToString()
                    });
                }
            }
        }
    }
    
  2. これは、DeferMainPage.xaml に含まれるマークアップです。 グリッド ビューには、MyItem クラスの TitleSubtitle、および Description プロパティにバインドされた要素がある項目テンプレートが含まれます。 x:Phase の既定値は 0 であることに注意してください。 ここでは、最初はタイトルだけが表示された状態で項目がレンダリングされます。 その後、字幕要素はデータ バインドされ、すべてのフェーズが処理されるまで、すべての項目に対して表示されます。

    <Page
        x:Class="LotsOfItems.DeferMainPage"
        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:lotsOfItems="using:LotsOfItems"
        mc:Ignorable="d">
    
        <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
            <GridView ItemsSource="{x:Bind ViewModel.ExampleItems}">
                <GridView.ItemTemplate>
                    <DataTemplate x:DataType="lotsOfItems:ExampleItem">
                        <StackPanel Height="100" Width="100" Background="OrangeRed">
                            <TextBlock Text="{x:Bind Title}"/>
                            <TextBlock Text="{x:Bind Subtitle}" x:Phase="1"/>
                            <TextBlock Text="{x:Bind Description}" x:Phase="2"/>
                        </StackPanel>
                    </DataTemplate>
                </GridView.ItemTemplate>
            </GridView>
        </Grid>
    </Page>
    
  3. 今すぐアプリを実行し、グリッド ビューをすばやくパン/スクロールすると、新しい各項目が画面に表示されるときに、最初は濃い灰色の四角形としてレンダリングされ (ShowsScrollingPlaceholders プロパティが既定で true に設定されているためです)、次にタイトルが表示され、その後に字幕と説明が続くことがわかります。

ContainerContentChanging を使用した段階的なデータ テンプレートの更新

ContainerContentChanging イベントの一般的な戦略は、Opacity を使ってすぐに表示する必要がない要素を非表示にすることです。 要素がリサイクルされるときに、古い値が保持されるため、新しいデータ項目からそれらの値が更新されるまで、それらの要素を非表示にしたいと考えています。 イベント引数で Phase プロパティを使って、どの要素を更新して表示するかを決めます。 追加のフェーズが必要な場合は、コールバックを登録します。

  1. x:Phase と同じバインド ソースを使用します。

  2. MainPage.xaml に含まれるマークアップを次に示します。 グリッド ビューでは、その ContainerContentChanging イベントに対してハンドラーを宣言します。その中には、MyItem クラスの TitleSubtitle、および Description プロパティを表示するために使われる要素を持つ項目テンプレートが含まれます。 ContainerContentChanging を使う場合のパフォーマンス上の最大の利点を得るために、マークアップではバインドを使用せず、代わりにプログラムで値を割り当てます。 ここでの例外は、タイトルを表示する要素であり、フェーズ 0 であると見なされます。

    <Page
        x:Class="LotsOfItems.MainPage"
        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:lotsOfItems="using:LotsOfItems"
        mc:Ignorable="d">
    
        <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
            <GridView ItemsSource="{x:Bind ViewModel.ExampleItems}" ContainerContentChanging="GridView_ContainerContentChanging">
                <GridView.ItemTemplate>
                    <DataTemplate x:DataType="lotsOfItems:ExampleItem">
                        <StackPanel Height="100" Width="100" Background="OrangeRed">
                            <TextBlock Text="{x:Bind Title}"/>
                            <TextBlock Opacity="0"/>
                            <TextBlock Opacity="0"/>
                        </StackPanel>
                    </DataTemplate>
                </GridView.ItemTemplate>
            </GridView>
        </Grid>
    </Page>
    
  3. 最後に、ContainerContentChanging イベント ハンドラーの実装を以下に示します。 このコードは、RecordingViewModel 型のプロパティを MainPage に追加して、マークアップのページを表すクラスからバインディング ソース クラスを公開する方法も示しています。 データ テンプレートに {Binding} バインドが含まれていない限り、イベント引数オブジェクトをハンドラーの最初のフェーズで処理済みとしてマークし、データ コンテキストを設定する必要がないことを項目に通知します。

    namespace LotsOfItems
    {
        /// <summary>
        /// An empty page that can be used on its own or navigated to within a Frame.
        /// </summary>
        public sealed partial class MainPage : Page
        {
            public MainPage()
            {
                this.InitializeComponent();
                this.ViewModel = new ExampleItemViewModel();
            }
    
            public ExampleItemViewModel ViewModel { get; set; }
    
            // Display each item incrementally to improve performance.
            private void GridView_ContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args)
            {
                if (args.Phase != 0)
                {
                    throw new System.Exception("We should be in phase 0, but we are not.");
                }
    
                // It's phase 0, so this item's title will already be bound and displayed.
    
                args.RegisterUpdateCallback(this.ShowSubtitle);
    
                args.Handled = true;
            }
    
            private void ShowSubtitle(ListViewBase sender, ContainerContentChangingEventArgs args)
            {
                if (args.Phase != 1)
                {
                    throw new System.Exception("We should be in phase 1, but we are not.");
                }
    
                // It's phase 1, so show this item's subtitle.
                var templateRoot = args.ItemContainer.ContentTemplateRoot as StackPanel;
                var textBlock = templateRoot.Children[1] as TextBlock;
                textBlock.Text = (args.Item as ExampleItem).Subtitle;
                textBlock.Opacity = 1;
    
                args.RegisterUpdateCallback(this.ShowDescription);
            }
    
            private void ShowDescription(ListViewBase sender, ContainerContentChangingEventArgs args)
            {
                if (args.Phase != 2)
                {
                    throw new System.Exception("We should be in phase 2, but we are not.");
                }
    
                // It's phase 2, so show this item's description.
                var templateRoot = args.ItemContainer.ContentTemplateRoot as StackPanel;
                var textBlock = templateRoot.Children[2] as TextBlock;
                textBlock.Text = (args.Item as ExampleItem).Description;
                textBlock.Opacity = 1;
            }
        }
    }
    
  4. 今すぐアプリを実行し、グリッド ビューですばやくパン/スクロールすると、x:Phase と同じ動作が起こることがわかります。

異種コレクションでのコンテナー リサイクル

一部のアプリケーションでは、コレクション内のさまざまな種類の項目に対して異なる UI を用意する必要があります。 これにより、項目の表示に使用される視覚要素を再利用またはリサイクルするためにパネルを仮想化できない状況が発生する可能性があります。 パン時に項目の視覚要素を再作成すると、仮想化によって得られた多くのパフォーマンスのメリットが取り消されます。 しかし、少し計画すれば、仮想化パネルで要素を再利用できるようになります。 開発者には、シナリオに応じて、ChoosingItemContainer イベント、または項目テンプレート セレクターという 2 つのオプションがあります。 ChoosingItemContainer アプローチの方がパフォーマンスが向上します。

ChoosingItemContainer イベント

ChoosingItemContainer は、起動時やリサイクル時に新しい項目が必要になったときに、ListView/GridView に項目 (ListViewItem/GridViewItem) を提供できるようにするイベントです。 コンテナーに表示されるデータ項目の種類に基づいて、コンテナーを作成できます (以下の例を参照)。 ChoosingItemContainer は、項目ごとに異なるデータ テンプレートを使用するためのよりパフォーマンスが高い方法です。 ChoosingItemContainer を使って実現できるものとして、コンテナーのキャッシュがあります。 たとえば、5 つの異なるテンプレートがあり、1 つのテンプレートが他のものよりも 1 桁多く発生している場合、ChoosingItemContainer を使用すると、必要な比率で項目を作成できるだけでなく、適切な数の要素をキャッシュし、リサイクルに使用できる状態を保てます。 ChoosingGroupHeaderContainer は、グループ ヘッダーと同じ機能を提供します。

// Example shows how to use ChoosingItemContainer to return the correct
// DataTemplate when one is available. This example shows how to return different 
// data templates based on the type of FileItem. Available ListViewItems are kept
// in two separate lists based on the type of DataTemplate needed.
private void ListView_ChoosingItemContainer
    (ListViewBase sender, ChoosingItemContainerEventArgs args)
{
    // Determines type of FileItem from the item passed in.
    bool special = args.Item is DifferentFileItem;

    // Uses the Tag property to keep track of whether a particular ListViewItem's 
    // datatemplate should be a simple or a special one.
    string tag = special ? "specialFiles" : "simpleFiles";

    // Based on the type of datatemplate needed return the correct list of 
    // ListViewItems, this could have also been handled with a hash table. These 
    // two lists are being used to keep track of ItemContainers that can be reused.
    List<UIElement> relevantStorage = special ? specialFileItemTrees : simpleFileItemTrees;

    // args.ItemContainer is used to indicate whether the ListView is proposing an 
    // ItemContainer (ListViewItem) to use. If args.Itemcontainer, then there was a 
    // recycled ItemContainer available to be reused.
    if (args.ItemContainer != null)
    {
        // The Tag is being used to determine whether this is a special file or 
        // a simple file.
        if (args.ItemContainer.Tag.Equals(tag))
        {
            // Great: the system suggested a container that is actually going to 
            // work well.
        }
        else
        {
            // the ItemContainer's datatemplate does not match the needed 
            // datatemplate.
            args.ItemContainer = null;
        }
    }

    if (args.ItemContainer == null)
    {
        // see if we can fetch from the correct list.
        if (relevantStorage.Count > 0)
        {
            args.ItemContainer = relevantStorage[0] as SelectorItem;
        }
        else
        {
            // there aren't any (recycled) ItemContainers available. So a new one 
            // needs to be created.
            ListViewItem item = new ListViewItem();
            item.ContentTemplate = this.Resources[tag] as DataTemplate;
            item.Tag = tag;
            args.ItemContainer = item;
        }
    }
}

項目テンプレート セレクター

項目テンプレート セレクター (DataTemplateSelector) を使用すると、アプリで、表示されるデータ項目の種類に基づいて、実行時に異なる項目テンプレートを返すことができます。 これにより、開発の生産性は向上しますが、すべてのデータ項目にすべての項目テンプレートを再利用できるわけではないため、UI の仮想化はより難しくなります。

項目 (ListViewItem/GridViewItem) をリサイクルする場合、リサイクル キュー (リサイクル キューは、現在データを表示するために使用されていない項目のキャッシュです) に、現在のデータ項目で必要なものと一致する項目テンプレートがあるかどうかを、フレームワークで判断する必要があります。 リサイクル キューに適切な項目テンプレートを含む項目がない場合、新しい項目が作成され、適切な項目テンプレートがインスタンス化されます。 一方、リサイクル キューに、適切な項目テンプレートがある項目が含まれている場合、その項目はリサイクル キューから削除され、現在のデータ項目のために使用されます。 項目テンプレート セレクターは、使用されている項目テンプレートの数が少なく、異なる項目テンプレートを使用する項目のコレクション全体にわたって項目がフラットに分布しているような状況に適しています。

異なる項目テンプレートを使う項目が均一に分布していない場合、パン中に新しい項目テンプレートを作成することが必要になる可能性が高く、仮想化によって得られる多くの利点がなくなります。 さらに、項目テンプレート セレクターでは、特定のコンテナーが現在のデータ項目用に再利用できるかどうかを評価する際に、対象として検討される候補は 5 つだけです。 そのため、アプリで使用する前に、データが項目テンプレート セレクターでの使用に適しているかどうかを慎重に検討する必要があります。 コレクションがほぼ同種である場合、セレクターからはほとんど毎回 (状況によっては常に) 同じ種類が返されます。 ほぼ同種の中のまれな例外を処理するためにかかるコストに注意し、ChoosingItemContainer (または 2 つの項目コントロール) の使用が妥当であるかどうかを検討します。