優化 ListView 與 GridView 效能

註 更多細節請參閱 //build/ 會話「使用者在 GridView 與 ListView 中互動大量資料時大幅提升效能」。

透過 UI 虛擬化、元素縮減及項目逐步更新,提升 ListView 與 GridView 的效能與啟動時間。 關於資料虛擬化技術,請參見 WinUI 的 ListView 與 GridView 資料虛擬化。

收藏績效的兩個關鍵因素

操作集合是常見的情況。 照片瀏覽器有照片收藏,閱讀器收藏文章、書籍或故事,購物應用程式則收藏商品收藏。 這個主題說明你可以如何讓 WinUI 應用程式在操作集合時更有效率。

在集合管理上,效能有兩個關鍵因素:一是 UI 執行緒建立項目所花費的時間;另一個是原始資料集和用來渲染資料的 UI 元素所使用的記憶體。

為了讓平滑的平移和捲動變得順暢,UI 執行緒必須高效且巧妙地完成項目的實例化、資料綁定和佈局。

使用者介面虛擬化

UI 虛擬化是你能做的最重要改進。 這表示代表物品的 UI 元素是按需建立的。 對於綁定在 1000 件物品集合的物品控制項,同時建立所有物品的介面會浪費資源,因為它們無法同時顯示。 ListView 和 GridView (以及其他標準的 ItemsControl 衍生控制項)會幫你執行 UI 虛擬化。 當項目快要捲入視圖(幾頁外)時,框架會產生該項目的使用者介面並快取它們。 當這些物品不太可能再被顯示時,框架會重新奪回記憶。

如果你提供自訂物品面板範本(參見 ItemsPanel),務必使用虛擬化面板,例如 ItemsWrapGrid 或 ItemsStackPanel。 如果你使用 VariableSizedWrapGrid、 WrapGrid 或 StackPanel,就無法實現虛擬化。 此外,以下 ListView 事件僅在使用 ItemsWrapGrid 或 ItemsStackPanel 時才會被觸發: ChoosingGroupHeaderContainer、 ChoosingItemContainer 和 ContainerContentChanging。 對於 Windows App SDK 的自訂版面而言,若內建項目面板無法符合您的需求,則現代的對應實作方式是基於 VirtualizingLayout。

視窗的概念對 UI 虛擬化至關重要,因為框架必須建立可能被顯示的元素。 一般而言, ItemsControl 的視口是邏輯控制的範圍。 例如, ListView 的視窗就是 ListView 元素的寬度與高度。 有些面板允許子元素無限空間,例如 ScrollViewer 以及帶有自動大小列或欄的 格網 。 當虛擬化 的 ItemsControl 放在這樣的面板中時,它會佔用足夠空間顯示所有物品,這會破壞虛擬化。 透過在 ItemsControl 上設定寬度和高度來恢復虛擬化。

每個項目上元素的減少

將用來渲染物品的 UI 元素數量控制在合理最低。

當物品控制項首次顯示時,渲染充滿物品的視窗所需的所有元素都會被建立。 此外,當項目接近視窗時,框架會使用綁定的資料物件更新已快取的項目範本中的 UI 元素。 減少模板內標記的複雜度,能節省記憶體和介面執行緒上處理的時間,從而提升反應速度,尤其是在平移和捲動時。 這些範本包括項目範本(參見 ItemTemplate)以及 ListViewItem 或 GridViewItem 的控制範本(項目控制範本,或 ItemContainerStyle)。 即使元素數量略有減少,所帶來的效益都會隨著顯示項目數量的增加而被放大。

關於元素縮減的範例,請參見 WinUI 與 Windows App SDK 的優化 XAML 載入。

ListViewItem 與 GridViewItem 的預設控制範本包含 ListViewItemPresenter 元素。 此呈現器是一個優化的單一元素,能夠顯示複雜的焦點、選取及其他視覺狀態。 如果您已經有自訂的物品控制範本(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>
<!-- ... -->

大約有 25 個屬性具有類似 SelectionCheckMarkVisualEnabled 和 SelectedBackground 的自描述名稱。 如果呈現器類型不夠具備自訂功能來滿足你的使用情境,你可以編輯或複製ListViewItemExpanded或GridViewItemExpanded控制範本來進行修改。 在 WinUI 應用程式中,查看 generic.xaml 隨 Windows App SDK 套件附帶的預設範本檔案。 請注意,使用這些範本意味著要犧牲部分效能,以換取客製化的提升。

逐步更新 ListView 和 GridView 項目

如果你使用資料虛擬化,可以透過設定控制項來渲染仍在下載項目的臨時 UI 元素,來保持 ListView 和 GridView 的高響應度。 隨著資料載入,暫時元素會逐步被實際的使用者介面取代。

此外,無論你從哪裡載入資料(本地磁碟、網路或雲端),使用者都可以快速平移或滾動清單 檢視 或 網狀圖 ,導致無法在保持平滑平移與滾動的同時,完整呈現每個項目。 為了保持平移和滾動的流暢,你可以選擇將物品分成多個階段渲染,並使用佔位符。

這些技術常見於照片瀏覽應用程式:即使並非所有影像都已載入並顯示,使用者仍能平移、捲動並互動收藏。 或者,對於電影物品,你可以在第一階段顯示標題,第二階段顯示分級,第三階段展示海報圖片。 使用者能盡早看到每項最重要的資料,這代表他們能立即採取行動。 然後,隨著時間允許,補充較不重要的資訊。 以下是你可以用來實作這些技術的平台功能。

佔位符

預設是開啟臨時佔位視覺功能,並透過 ShowsScrollingPlaceholders 屬性來控制。 在快速平移與滾動時,此功能會讓使用者視覺上提示還有更多項目尚未完全顯示,同時保持流暢度。 如果你使用以下其中一種技巧,如果你不希望系統渲染出佔位符,可以將 ShowsScrollingPlaceholders 設為 false。

使用 x:Phase 進行漸進式資料範本更新

這個 x:Phase 屬性在 WinUI 中持續運作,仍然是逐步渲染物品內容的好方法。

以下是如何使用 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 類別的標題、副標題和描述屬性。 請注意 x:相位 預設為 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 類別的標題、副標題和描述屬性。 為了最大化使用 ContainerContentCchanging 的效能提升,我們不在標記中使用綁定,而是以程式化方式指派數值。 例外是顯示標題的元素,我們認為它處於第 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 事件處理器的實作。 這段程式碼也展示了我們如何在 MainPage 中加入 ExampleItemViewModel 類型的屬性,以從代表標記頁面的類別中暴露綁定來源類別。 只要你的資料範本中沒有任何 {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 一樣的行為。

異質集合的容器回收

在某些應用程式中,你需要為收藏中不同類型的項目設計不同的介面。 這可能導致虛擬化面板無法重複使用或回收用來展示物品的視覺元素。 在平移時重現物件的視覺元素,會抵銷虛擬化帶來的許多效能優勢。 不過,稍加規劃可以讓虛擬化面板重用這些元素。 開發者根據情境有幾個選項: ChoosingItemContainer 事件,或是物品範本選擇器。 ChoosingItemContainer 方法效能較佳。

選擇物品容器事件

ChoosingItemContainer 是一個事件,允許你在啟動或回收時,將項目(ListViewItem 或 GridViewItem)提供給 ListView 或 GridView 。 你可以根據容器將顯示的資料項目類型建立容器,如下範例所示。 ChoosingItemContainer 是使用不同資料範本來處理不同項目的效能較高的方法。 容器快取是可以使用 ChoosingItemContainer 來實現的功能。 舉例來說,如果你有五個不同的範本,其中一個範本出現的頻率比其他的高出一個數量級,那麼 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 is not null, 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)時,框架必須判斷回收清單中可用項目是否有符合當前資料項目所需範本的項目模板。 如果回收排隊中沒有符合適當物品範本的項目,則會建立一個新項目,並實例化該項目範本。 反之,若回收隊列包含符合相應項目模板的項目,則該項目會從回收隊列中移除,並用於當前的資料項目。 物品範本選擇器適用於僅使用少量物品範本,且在使用不同物品範本的物品集合中呈現固定分布的情況。   當使用不同項目模板的項目分布不均時,通常需要在平移時重新建立新的項目模板,這會抵銷虛擬化帶來的許多好處。 此外,項目範本選擇器在評估特定容器是否可重複用於當前資料項目時,僅考慮五個可能的候選選項。 所以你應該仔細考慮你的資料是否適合搭配項目範本選擇器使用,再用在 WinUI 應用程式中。 如果你的收藏大多是同質性的,那麼選擇器大部分或全部時間返回的都是相同類型。 只需注意您為打破一致性所付出的代價,並考慮是使用ChoosingItemContainer,還是使用兩個物品控制項更為合適。