WPF性能优化之UI虚拟化

@
目录


前言

相信很多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附加属性,以下为示例:

xml 复制代码
<ItemsControl VirtualizingPanel.IsVirtualizing="True">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel IsItemsHost="True" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
</ItemsControl>

VirtualizingPanel中除了IsVirtualizing参数以外还有很多其它参数可以控制更多的虚拟化细节,以下是参数说明:

  1. VirtualizingPanel.CacheLength="10"
    作用:CacheLength 属性指定了在虚拟化过程中,控件需要缓存的项目数。这意味着在视口之外的区域中,面板会保留一定数量的项目以提高滚动平滑度。当用户滚动视图时,缓存的项目可以更快地重新使用,从而减少重新创建和布局的开销。
    值:10 表示视口外会缓存 10 个项目。这是一个相对的值,具体数目可能会根据实际实现有所不同。
  2. VirtualizingPanel.CacheLengthUnit="Item"
    作用:CacheLengthUnit 属性定义 CacheLength 的单位。可以选择 Pixel 或 Item,其中 Item 表示缓存的长度以项目的数量为单位,Pixel 表示缓存的长度以像素为单位。
    值:Item 表示缓存的长度是以项目的数量为单位。这适用于项目大小固定或数据量较小的情况。
  3. VirtualizingPanel.IsContainerVirtualizable="True"
    作用:IsContainerVirtualizable 属性指示面板是否允许对其子项的容器进行虚拟化。设置为 True 表示面板可以对其容器进行虚拟化,从而优化性能,特别是在处理大量数据时。
    值:True 表示启用容器虚拟化。
  4. VirtualizingPanel.IsVirtualizing="True"
    作用:IsVirtualizing 属性指示面板是否启用虚拟化。这是虚拟化的核心设置,设置为 True 表示面板会仅对视口内的项目进行渲染和处理,而不是一次性加载所有项目。
    值:True 表示启用虚拟化,减少不必要的控件实例化和布局计算。
  5. VirtualizingPanel.IsVirtualizingWhenGrouping="True"
    作用:IsVirtualizingWhenGrouping 属性控制面板在分组时是否继续进行虚拟化。当设置为 True 时,面板在分组数据时仍然会应用虚拟化策略,以保持性能优化。
    值:True 表示即使在数据分组时,也保持虚拟化。
  6. VirtualizingPanel.ScrollUnit="Item"
    作用:ScrollUnit 属性定义滚动的单位。可以选择 Item 或 Pixel,其中 Item 表示每次滚动一个项目,Pixel 表示每次滚动一定像素。
    值:Item 表示每次滚动一个项目的单位,而不是固定像素数,这对于项目高度一致的情况尤其有效。
  7. VirtualizingPanel.VirtualizationMode="Recycling"
    作用:VirtualizationMode 属性指定虚拟化模式。Recycling 模式表示控件会重用已经不再可见的项目的容器,而不是销毁它们。这种方式可以减少控件的创建和销毁开销,从而提升性能。
    值:Recycling 表示启用重用模式,使面板更高效地管理控件实例,适合动态数据变化的场景。

二、CustomVirtualizingPanel

2.1 基础知识

要开发自己的虚拟化Panel我们需要继承自VirtualizingPanel类,并实现IScrollInfo接口,VirtualizingPanel中提供了操作Panel子控件的相关的方法,IScrollInfo接口定义了ScrollViewer控件的自定义行为,我们实现了IScrollInfo就可以接管ScrollViewer控件的相关操作。

代码如下(示例):

csharp 复制代码
public class CustomVirtualizingPanel : VirtualizingPanel, IScrollInfo
{
}

2.1.1 VirtualizingPanel

VirtualizingPanel中有一个名为"ItemContainerGenerator"的属性,该属性提供了对虚拟化Panel子控件创建及销毁的方法,它的工作流程大致如下:

  1. 当ScrollViewer控件滚动条移动时,获取滚动条的偏移量,通过偏移量和视口大小计算出Panel中应该显示的子控件位置;
  2. 调用ItemContainerGenerator.GenerateNext();从指定位置生成Panel子控件;
  3. 调用ItemContainerGenerator.Remove();删除可见范围以外的Panel子控件;

2.1.2 IScrollInfo

csharp 复制代码
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 需求分析

  1. CustomVirtualizingPanel应该具有高度的灵活性,以最小的代价满足不同的虚拟化布局需求,不需要每次都要重写一个CustomVirtualizingPanel控件。
  2. 最好是可以通过属性切换布局,这样可以实现布局切换时的过渡效果。

2.2.2 代码实现

通过分析要想实现以上效果,最好的方法就是将CustomVirtualizingPanel中需要计算的关键部分抽象出来做成一个接口,当需要布局计算的时候我们可以直接通过接口获取到关键计算结果。
1) 定义接口

csharp 复制代码
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

csharp 复制代码
public IVirtualizingPanelBuilder VirtualizingPanelBuilder 
{     
	get { return (IVirtualizingPanelBuilder)GetValue(VirtualizingPanelBuilderProperty); }     
	set { SetValue(VirtualizingPanelBuilderProperty, value); } 
}

3) 实现VirtualizingPanelBuilder

csharp 复制代码
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) 设置参数

xml 复制代码
<local:CustomVirtualizingPanel>     
  <local:CustomVirtualizingPanel.VirtualizingPanelBuilder>         
    <local:VirtualizingStackPanelBuilder ItemHeight="100" />     
  </local:CustomVirtualizingPanel.VirtualizingPanelBuilder> 
</local:CustomVirtualizingPanel>

2.2.3 运行效果

为了能够演示布局切换的过渡效果,这里除了上面的StackPanel布局以外还实现了UniformGrid布局,以下分别演示1亿条数据布局切换及非虚拟化状态下的布局切换过渡效果。
1) 虚拟化切换布局

2) 非虚拟化切换过渡效果

相关推荐
Macbethad3 小时前
工业设备数据记录程序技术方案
wpf·信息与通信
zzyzxb16 小时前
WPF 中隧道事件和冒泡事件
wpf
闲人编程17 小时前
API限流、鉴权与监控
分布式·python·wpf·限流·集群·令牌·codecapsule
TA远方18 小时前
【WPF】桌面程序使用谷歌浏览器内核CefSharp控件详解
wpf·浏览器·chromium·控件·cefsharp·cefsharp.wpf
Macbethad1 天前
工业设备数据采集主站程序技术方案
wpf
关关长语2 天前
HandyControl 3.5.x 版本 ListViewItem不显示问题
windows·wpf
Macbethad2 天前
工业设备维护程序技术方案
wpf
Macbethad2 天前
工业设备配方管理系统技术方案
wpf
喵叔哟2 天前
7.日志系统深入
wpf
清风徐来Groot2 天前
WPF布局之Grid
wpf