@
- 前言
- 一、VirtualizingStackPanel
- 1.1 虛擬化功能介紹
- 1、在Window中新增一個ListBox控制元件。
- 2、在設計檢視中用滑鼠選中ListBox控制元件並右健依次單擊“編輯其他模板”-“編輯項的佈局模板”-“編輯副本”。
- 3、檢視生成的模板程式碼。
- 1.2 虛擬化引數介紹
- 1.1 虛擬化功能介紹
- 二、CustomVirtualizingPanel
- 2.1 基礎知識
- 2.1.1 VirtualizingPanel
- 2.1.2 IScrollInfo
- 2.2 實戰案例
- 2.2.1 需求分析
- 2.2.2 程式碼實現
- 2.2.3 執行效果
- 2.1 基礎知識
前言
相信很多WPF開發者都碰到過這種情況,當在一個ItemsControl(或繼承自ItemsControl)控制元件中繫結一個集合的時候,如果集合中的條目過多,那麼介面就會變得卡頓甚至停止響應,特別是在容器或視窗大小發生改變時,介面的渲染就會給人一種慢半拍的感覺,體驗感非常差,這時我們就可以用虛擬化技術來解決這個問題。
UI虛擬化的核心思想就是隻渲染可視範圍內的控制元件,所以它通常會搭配ScrollViewer控制元件一起使用,透過ScrollViewer控制元件中的VerticalOffset、HorizontalOffset、ViewportWidth、ViewportHeight等引數可以計算出在可視範圍內應該顯示的控制元件,當控制元件不被顯示時將它從Panel中移出,這樣就可以保證同一時間只渲染了有限的控制元件,而不是渲染所有控制元件,從而達到效能提升的目的。
一、VirtualizingStackPanel
1.1 虛擬化功能介紹
VirtualizingStackPanel是WPF中的一個內建控制元件,它提供了UI虛擬化的功能,在ListBox、ListView、DataGrid等控制元件中它是預設佈局控制元件,我們可以透過檢視控制元件模板的方式來看看它是如何定義的。
1、在Window中新增一個ListBox控制元件。
2、在設計檢視中用滑鼠選中ListBox控制元件並右健依次單擊“編輯其他模板”-“編輯項的佈局模板”-“編輯副本”。
3、檢視生成的模板程式碼。
透過以上程式碼可以看出,ListBox有一個名為ItemsPanel的屬性,在該屬性中指定了一個Panel控制元件,ListBox在渲染時用該Panel來佈局子項,我們要實現虛擬化只需要在ItemsPanel中指定VirtualizingStackPanel控制元件即可。
1.2 虛擬化引數介紹
如果你自己實現一個繼承自ItemsControl的控制元件,並按1.1的步驟操作,你會發現還是無法實現虛擬化功能,原因是沒有開啟虛擬化功能(ListBox、ListView、DataGrid等控制元件是預設開啟的),要開啟ItemsControl控制元件的虛擬化功能我們還需要設定VirtualizingPanel.IsVirtualizing附加屬性,以下為示例:
<ItemsControl VirtualizingPanel.IsVirtualizing="True">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsItemsHost="True" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
VirtualizingPanel中除了IsVirtualizing引數以外還有很多其它引數可以控制更多的虛擬化細節,以下是引數說明:
- VirtualizingPanel.CacheLength="10"
作用:CacheLength 屬性指定了在虛擬化過程中,控制元件需要快取的專案數。這意味著在視口之外的區域中,皮膚會保留一定數量的專案以提高滾動平滑度。當使用者滾動檢視時,快取的專案可以更快地重新使用,從而減少重新建立和佈局的開銷。
值:10 表示視口外會快取 10 個專案。這是一個相對的值,具體數目可能會根據實際實現有所不同。 - VirtualizingPanel.CacheLengthUnit="Item"
作用:CacheLengthUnit 屬性定義 CacheLength 的單位。可以選擇 Pixel 或 Item,其中 Item 表示快取的長度以專案的數量為單位,Pixel 表示快取的長度以畫素為單位。
值:Item 表示快取的長度是以專案的數量為單位。這適用於專案大小固定或資料量較小的情況。 - VirtualizingPanel.IsContainerVirtualizable="True"
作用:IsContainerVirtualizable 屬性指示皮膚是否允許對其子項的容器進行虛擬化。設定為 True 表示皮膚可以對其容器進行虛擬化,從而最佳化效能,特別是在處理大量資料時。
值:True 表示啟用容器虛擬化。 - VirtualizingPanel.IsVirtualizing="True"
作用:IsVirtualizing 屬性指示皮膚是否啟用虛擬化。這是虛擬化的核心設定,設定為 True 表示皮膚會僅對視口內的專案進行渲染和處理,而不是一次性載入所有專案。
值:True 表示啟用虛擬化,減少不必要的控制元件例項化和佈局計算。 - VirtualizingPanel.IsVirtualizingWhenGrouping="True"
作用:IsVirtualizingWhenGrouping 屬性控制皮膚在分組時是否繼續進行虛擬化。當設定為 True 時,皮膚在分組資料時仍然會應用虛擬化策略,以保持效能最佳化。
值:True 表示即使在資料分組時,也保持虛擬化。 - VirtualizingPanel.ScrollUnit="Item"
作用:ScrollUnit 屬性定義滾動的單位。可以選擇 Item 或 Pixel,其中 Item 表示每次滾動一個專案,Pixel 表示每次滾動一定畫素。
值:Item 表示每次滾動一個專案的單位,而不是固定畫素數,這對於專案高度一致的情況尤其有效。 - VirtualizingPanel.VirtualizationMode="Recycling"
作用:VirtualizationMode 屬性指定虛擬化模式。Recycling 模式表示控制元件會重用已經不再可見的專案的容器,而不是銷燬它們。這種方式可以減少控制元件的建立和銷燬開銷,從而提升效能。
值:Recycling 表示啟用重用模式,使皮膚更高效地管理控制元件例項,適合動態資料變化的場景。
二、CustomVirtualizingPanel
2.1 基礎知識
要開發自己的虛擬化Panel我們需要繼承自VirtualizingPanel類,並實現IScrollInfo介面,VirtualizingPanel中提供了操作Panel子控制元件的相關的方法,IScrollInfo介面定義了ScrollViewer控制元件的自定義行為,我們實現了IScrollInfo就可以接管ScrollViewer控制元件的相關操作。
程式碼如下(示例):
public class CustomVirtualizingPanel : VirtualizingPanel, IScrollInfo
{
}
2.1.1 VirtualizingPanel
VirtualizingPanel中有一個名為“ItemContainerGenerator”的屬性,該屬性提供了對虛擬化Panel子控制元件建立及銷燬的方法,它的工作流程大致如下:
- 當ScrollViewer控制元件捲軸移動時,獲取捲軸的偏移量,透過偏移量和視口大小計算出Panel中應該顯示的子控制元件位置;
- 呼叫ItemContainerGenerator.GenerateNext();從指定位置生成Panel子控制元件;
- 呼叫ItemContainerGenerator.Remove();刪除可見範圍以外的Panel子控制元件;
2.1.2 IScrollInfo
public class CustomVirtualizingPanel : VirtualizingPanel, IScrollInfo
{
public ScrollViewer ScrollOwner { get; set; } //當前ScrollViewer控制元件
public bool CanVerticallyScroll { get; set; } //是否可以在垂直方向滾動
public bool CanHorizontallyScroll { get; set; } //是否可以在水平方向滾動
public double ExtentWidth { get; } //滾動內容的總寬度(包括可見部分和不可見部分)
public double ExtentHeight { get; } //滾動內容的總高度(包括可見部分和不可見部分)
public double ViewportWidth { get; } // ScrollViewer控制元件可以看到的那部分割槽域的寬度
public double ViewportHeight { get; } //ScrollViewer控制元件可以看到的那部分割槽域的高度
public double HorizontalOffset { get; } //水平捲軸的偏移量
public double VerticalOffset { get; } //垂直捲軸的偏移量
public void LineDown() { } //滑鼠點選捲軸下箭頭的操作
public void LineLeft() { } //滑鼠點選捲軸左箭頭的操作
public void LineRight() { } //滑鼠點選捲軸右箭頭的操作
public void LineUp() { } //滑鼠點選捲軸上箭頭的操作
public void MouseWheelDown() { } //滑鼠滾輪向下時的操作
public void MouseWheelLeft() { } //滑鼠滾輪向左時的操作
public void MouseWheelRight() { } //滑鼠滾輪向右時的操作
public void MouseWheelUp() { } //滑鼠滾輪向上時的操作
public void PageDown() { } //在捲軸上按鍵盤上下頁的操作
public void PageLeft() { } //在捲軸上按鍵盤上左頁的操作
public void PageRight() { } //在捲軸上按鍵盤上右頁的操作
public void PageUp() { } //在捲軸上按鍵盤上上頁的操作
public void SetHorizontalOffset(double offset) { } //設定捲軸水平偏移量
public void SetVerticalOffset(double offset) { } //設定捲軸垂直偏移量
public Rect MakeVisible(Visual visual, Rect rectangle) { return default; } //強制滾動Panel子控制元件(比如只有部分割槽域顯示在可視範圍內,點選之後完全滾動到可視範圍內)
}
2.2 實戰案例
2.2.1 需求分析
- CustomVirtualizingPanel應該具有高度的靈活性,以最小的代價滿足不同的虛擬化佈局需求,不需要每次都要重寫一個CustomVirtualizingPanel控制元件。
- 最好是可以透過屬性切換佈局,這樣可以實現佈局切換時的過渡效果。
2.2.2 程式碼實現
透過分析要想實現以上效果,最好的方法就是將CustomVirtualizingPanel中需要計算的關鍵部分抽象出來做成一個介面,當需要佈局計算的時候我們可以直接透過介面獲取到關鍵計算結果。
1) 定義介面
public interface IVirtualizingPanelBuilder
{
void Initialize(CustomVirtualizingPanel virtualizingPanel);
double GetItemWidth(Size availableSize);
double GetItemHeight(Size availableSize);
int CalculateItemsPerRowCount(Size availableSize);
int CalculateRowCount(Size availableSize);
Size CalculateExtent(Size availableSize);
ItemRange CalculateItemRange(Size availableSize);
}
2) 在CustomVirtualizingPanel類中新增屬性VirtualizingPanelBuilder
public IVirtualizingPanelBuilder VirtualizingPanelBuilder
{
get { return (IVirtualizingPanelBuilder)GetValue(VirtualizingPanelBuilderProperty); }
set { SetValue(VirtualizingPanelBuilderProperty, value); }
}
3) 實現VirtualizingPanelBuilder
public class VirtualizingStackPanelBuilder : DependencyObject, IVirtualizingPanelBuilder
{
/// <summary>
/// 虛擬皮膚
/// </summary>
private CustomVirtualizingPanel _virtualizingPanel;
/// <summary>
/// 初始化
/// </summary>
/// <param name="virtualizingPanel"></param>
public void Initialize(CustomVirtualizingPanel virtualizingPanel)
{
_virtualizingPanel = virtualizingPanel;
}
/// <summary>
/// 獲取Item高度
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
public double GetItemHeight(Size availableSize)
{
if (ItemHeight > 0)
return ItemHeight;
else if (_virtualizingPanel.Children.Count != 0)
return _virtualizingPanel.Children[0].DesiredSize.Height;
else
return _virtualizingPanel.CalculateChildSize(availableSize).Height;
}
/// <summary>
/// 獲取Item寬度
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
public double GetItemWidth(Size availableSize)
{
return availableSize.Width;
}
/// <summary>
/// 計算每行顯示的Item數
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
public int CalculateItemsPerRowCount(Size availableSize)
{
return 1;
}
/// <summary>
/// 計算行數
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
public int CalculateRowCount(Size availableSize)
{
return _virtualizingPanel.Items.Count;
}
/// <summary>
/// 計算滾動面積
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
public Size CalculateExtent(Size availableSize)
{
var height = GetItemHeight(availableSize);
var rowCount = CalculateRowCount(availableSize);
return new Size(availableSize.Width, height * rowCount);
}
/// <summary>
/// 計算可見區域內的Item範圍
/// </summary>
/// <param name="availableSize"></param>
/// <returns></returns>
public ItemRange CalculateItemRange(Size availableSize)
{
if (!this._virtualizingPanel.IsVirtualizing)
{
return new ItemRange(0, this._virtualizingPanel.Items.Count - 1);
}
var viewportHeight = _virtualizingPanel.ViewportHeight;
var offsetY = _virtualizingPanel.VerticalOffset;
var rowCount = this.CalculateRowCount(availableSize);
var itemHeight = this.GetItemHeight(availableSize);
var firstVisibleItemIndex = (int)Math.Floor(offsetY / itemHeight);
var lastVisibleItemIndex = (int)Math.Ceiling((offsetY + viewportHeight) / itemHeight) - 1;
if (lastVisibleItemIndex >= rowCount)
lastVisibleItemIndex = rowCount - 1;
return new ItemRange(firstVisibleItemIndex, lastVisibleItemIndex);
}
/// <summary>
/// Item高度
/// </summary>
public double ItemHeight
{
get { return (double)GetValue(ItemHeightProperty); }
set { SetValue(ItemHeightProperty, value); }
}
public static readonly DependencyProperty ItemHeightProperty =
DependencyProperty.Register("ItemHeight", typeof(double), typeof(VirtualizingStackPanelBuilder), new PropertyMetadata(ItemHeightPropertyChangedCallback));
public static void ItemHeightPropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((VirtualizingStackPanelBuilder)d)._virtualizingPanel?.InvalidateVisual();
}
}
4) 設定引數
<local:CustomVirtualizingPanel>
<local:CustomVirtualizingPanel.VirtualizingPanelBuilder>
<local:VirtualizingStackPanelBuilder ItemHeight="100" />
</local:CustomVirtualizingPanel.VirtualizingPanelBuilder>
</local:CustomVirtualizingPanel>
2.2.3 執行效果
為了能夠演示佈局切換的過渡效果,這裡除了上面的StackPanel佈局以外還實現了UniformGrid佈局,以下分別演示1億條資料佈局切換及非虛擬化狀態下的佈局切換過渡效果。
1) 虛擬化切換佈局
2) 非虛擬化切換過渡效果