WPF中实现自定义虚拟容器(实现VirtualizingPanel)

时间:2023-12-24 23:16:49

WPF中实现自定义虚拟容器(实现VirtualizingPanel)

在WPF应用程序开发过程中,大数据量的数据展现通常都要考虑性能问题。有下面一种常见的情况:原始数据源数据量很大,但是某一时刻数据容器中的可见元素个数是有限的,剩余大多数元素都处于不可见状态,如果一次性将所有的数据元素都渲染出来则会非常的消耗性能。因而可以考虑只渲染当前可视区域内的元素,当可视区域内的元素需要发生改变时,再渲染即将展现的元素,最后将不再需要展现的元素清除掉,这样可以大大提高性能。在WPF中System.Windows.Controls命名空间下的VirtualizingStackPanel可以实现数据展现的虚拟化功能,ListBox的默认元素展现容器就是它。但有时VirtualizingStackPanel的布局并不能满足我们的实际需要,此时就需要实现自定义布局的虚拟容器了。本文将简单介绍容器自定义布局,然后介绍实现虚拟容器的基本原理,最后给出一个虚拟化分页容器的演示程序。

一、WPF中自定义布局 (已了解容器自定义布局的朋友可略过此节)

通常实现一个自定义布局的容器,需要继承System.Windows.Controls.Panel, 并重写下面两个方法:

MeasureOverride —— 用来测量子元素期望的布局尺寸

ArrangeOverride —— 用来安排子元素在容器中的布局。

下面用一个简单的SplitPanel来加以说明这两个方法的作用。下面的Window中放置了一个SplitPanel,每点击一次“添加”按钮,都会向SplitPanel中添加一个填充了随机色的Rectangle, 而SplitPanel中的Rectangle无论有几个,都会在垂直方向上布满容器,水平方向上平均分配宽度。

WPF中实现自定义虚拟容器(实现VirtualizingPanel)

实现代码如下:

SplitPanel
/// <summary>
/// 简单的自定义容器
/// 子元素在垂直方向布满容器,水平方向平局分配容器宽度
/// </summary>
public class SplitPanel : Panel
{
protected override Size MeasureOverride(Size availableSize)
{
foreach (UIElement child in InternalChildren)
{
child.Measure(availableSize); // 测量子元素期望布局尺寸(child.DesiredSize)
} return base.MeasureOverride(availableSize);
} protected override Size ArrangeOverride(Size finalSize)
{
if (double.IsInfinity(finalSize.Height) || double.IsInfinity(finalSize.Width))
{
throw new InvalidOperationException("容器的宽和高必须是确定值");
} if (Children.Count > )
{
double childAverageWidth = finalSize.Width / Children.Count;
for (int childIndex = ; childIndex < InternalChildren.Count; childIndex++)
{
// 计算子元素将被安排的布局区域
var rect = new Rect(childIndex * childAverageWidth, , childAverageWidth, finalSize.Height);
InternalChildren[childIndex].Arrange(rect);
}
} return base.ArrangeOverride(finalSize);
}
}

SplitPanel

SplitPanel的MeasureOverride 方法参数availableSize是容器可以给出的总布局大小,在方法体中只依次调用了子元素的Measure方法,调用该方法后,子元素的DesiredSize属性就会被赋值, 该属性指明了子元素期望的布局尺寸。(在SplitPanel中并不需要知道子元素的期望布局尺寸,所以可以不必重写MeasureOverride 方法,但是在一些比较复杂的布局中需要用到子元素的DesiredSize属性时就必须重写)

SplitPaneld的ArrangeOverride 方法参数finalSize是容器最终给出的布局大小,26行根据子元素个数先计算出子元素平均宽度,30行再按照子元素索引计算出各自的布局区域信息。然后31行调用子元素的Arrange方法将子元素安排在容器中的合适位置。这样就可以实现期望的布局效果。当UI重绘时(例如子元素个数发生改变、容器布局尺寸发生改变、强制刷新UI等),会重新执行MeasureOverride 和ArrangeOverride 方法。

二、虚拟容器原理

要想实现一个虚拟容器,并让虚拟容器正常工作,必须满足以下两个条件:

1、容器继承自System.Windows.Controls.VirtualizingPanel,并实现子元素的实例化、虚拟化及布局处理。

2、虚拟容器要做为一个System.Windows.Controls.ItemsControl(或继承自ItemsControl的类)实例的ItemsPanel(实际上是定义一个ItemsPanelTemplate) 

下面我们先来了解一下ItemsControl的工作机制:

当我们为一个ItemsControl指定了ItemsSource属性后,ItemsControl的Items属性就会被初始化,这里面装的就是原始的数据(题外话:通过修改Items的Filter可以实现不切换数据源的元素过滤,修改Items的SortDescriptions属性可以实现不切换数据源的元素排序)。之后ItemsControl会根据Items来生成子元素的容器(ItemsControl生成ContentPresenter, ListBox生成ListBoxItem, ComboBox生成ComboBox等等),同时将子元素容器的DataContext设置为与之对应的数据源,最后每个子元素容器再根据ItemTemplate的定义来渲染子元素实际显示效果。

对于Panel来说,ItemsControl会一次性生成所有子元素的子元素容器并进行数据初始化,这样就导致在数据量较大时性能会很差。而对于VirtualizingPanel,ItemsControl则不会自动生成子元素容器及子元素的渲染,这一过程需要编程实现。

接下来我们引入另一个重要概念:GeneratorPosition,这个结构体用来描述ItemsControl的Items属性中实例化和虚拟化数据项的位置关系,在VirtualizingPanel中可以通过ItemContainerGenerator(注意:在VirtualizingPanel第一次访问这个属性之前要先访问一下InternalChildren属性,否则ItemContainerGenerator会是null,貌似是一个Bug)属性来获取数据项的位置信息,此外通过这个属性还可以进行数据项的实例化和虚拟化。

获取数据项GeneratorPosition信息:

DumpGeneratorContent
/// <summary>
/// 显示数据GeneratorPosition信息
/// </summary>
public void DumpGeneratorContent()
{
IItemContainerGenerator generator = this.ItemContainerGenerator;
ItemsControl itemsControl = ItemsControl.GetItemsOwner(this); Console.WriteLine("Generator positions:");
for (int i = ; i < itemsControl.Items.Count; i++)
{
GeneratorPosition position = generator.GeneratorPositionFromIndex(i);
Console.WriteLine("Item index=" + i + ", Generator position: index=" + position.Index + ", offset=" + position.Offset);
}
Console.WriteLine();
}

DumpGeneratorContent

第7行通过ItemsControl的静态方法GetItemsOwner可以找到容器所在的ItemsControl,这样就可以访问到数据项集合,第12行代码调用generator 的GeneratorPositionFromIndex方法,通过数据项的索引得到数据项的GeneratorPosition 信息。

 

数据项实例化:

/// <summary>
/// 实例化子元素
/// </summary>
/// <param name="itemIndex">数据条目索引</param>
public void RealizeChild(int itemIndex)
{
IItemContainerGenerator generator = this.ItemContainerGenerator;
GeneratorPosition position = generator.GeneratorPositionFromIndex(itemIndex); using (generator.StartAt(position, GeneratorDirection.Forward, allowStartAtRealizedItem: true))
{
bool isNewlyRealized;
var child = (UIElement)generator.GenerateNext(out isNewlyRealized); // 实例化(构造出空的子元素UI容器) if (isNewlyRealized)
{
generator.PrepareItemContainer(child); // 填充UI容器数据
}
}
}

第10行调用generator 的StartAt方法确定准备实例化元素的数据项位置,第13行调用generator的GenerateNext方法进行数据项的实例化,输出参数isNewlyRealized为ture则表明该元素是从虚拟化状态实例化出来的,false则表明该元素已被实例化。注意,该方法只是构造出了子元素的UI容器,只有调用了17行的PrepareItemContainer方法,UI容器的实际内容才会根据ItemsControl的ItemTemplate定义进行渲染。

 

数据项虚拟化:

VirtualizeChild
/// <summary>
/// 虚拟化子元素
/// </summary>
/// <param name="itemIndex">数据条目索引</param>
public void VirtualizeChild(int itemIndex)
{
IItemContainerGenerator generator = this.ItemContainerGenerator;
var childGeneratorPos = generator.GeneratorPositionFromIndex(itemIndex);
if (childGeneratorPos.Offset == )
{
generator.Remove(childGeneratorPos, ); // 虚拟化(从子元素UI容器中清除数据)
}
}

VirtualizeChild

通过数据条目索引得出GeneratorPosition 信息,之后在11行调用generator的Remove方法即可实现元素的虚拟化。

 
通过几张图片来有一个直观的认识,数据条目一共有10个,初始化时全部都为虚拟化状态:
WPF中实现自定义虚拟容器(实现VirtualizingPanel)
 

实例化第二个元素:

WPF中实现自定义虚拟容器(实现VirtualizingPanel)
 

增加实例化第三、七个元素:

WPF中实现自定义虚拟容器(实现VirtualizingPanel)
 

虚拟化第二个元素:

WPF中实现自定义虚拟容器(实现VirtualizingPanel)

通过观察可以发现,实例化的数据项位置信息按顺序从0开始依次增加,所有实例化的数据项位置信息的offset属性都是0,虚拟化数据项index和前一个最近的实例化元素index保持一致,offset依次增加

 

三、实战-实现一个虚拟化分页容器

了解了子元素自定义布局、数据项GeneratorPosition信息、虚拟化、实例化相关概念和实现方法后,离实现一个自定义虚拟容器还剩一步重要的工作:计算当前应该显示的数据项起止索引,实例化这些数据项,虚拟化不再显示的数据项。

再前进一步,实现一个虚拟化分页容器:

WPF中实现自定义虚拟容器(实现VirtualizingPanel)

这个虚拟化分页容器有ChildWidth和ChildHeight两个依赖属性,用来定义容器中子元素的宽和高,这样在容器布局尺寸确定的情况下可以计算出可用布局下一共能显示多少个子元素,也就是PageSize属性。为容器指定一个有5000个数据的数据源,再提供一个分页控件用来控制分页容器的PageIndex,用来达到分页显示的效果。

贴出主要代码:

计算需要实例化数据项的起止索引
/// <summary>
/// 计算可是元素起止索引
/// </summary>
/// <param name="availableSize">可用布局尺寸</param>
/// <param name="firstVisibleChildIndex">第一个显示的子元素索引</param>
/// <param name="lastVisibleChildIndex">最后一个显示的子元素索引</param>
private void ComputeVisibleChildIndex(Size availableSize, out int firstVisibleChildIndex, out int lastVisibleChildIndex)
{
ItemsControl itemsControl = ItemsControl.GetItemsOwner(this); if (itemsControl != null && itemsControl.Items != null && ChildWidth > && ChildHeight > )
{
ChildrenCount = itemsControl.Items.Count; _horizontalChildMaxCount = (int)(availableSize.Width / ChildWidth);
_verticalChildMaxCount = (int)(availableSize.Height / ChildHeight); PageSize = _horizontalChildMaxCount * _verticalChildMaxCount; // 计算子元素显示起止索引
firstVisibleChildIndex = PageIndex * PageSize;
lastVisibleChildIndex = Math.Min(ChildrenCount, firstVisibleChildIndex + PageSize) - ; Debug.WriteLine("firstVisibleChildIndex:{0}, lastVisibleChildIndex{1}", firstVisibleChildIndex, lastVisibleChildIndex)
}
else
{
ChildrenCount = ;
firstVisibleChildIndex = -;
lastVisibleChildIndex = -;
PageSize = ;
}
}

计算需要实例化数据项的起止索引

测量子元素布局期望尺寸及数据项实例化
/// <summary>
/// 测量子元素布局,生成需要显示的子元素
/// </summary>
/// <param name="availableSize">可用布局尺寸</param>
/// <param name="firstVisibleChildIndex">第一个显示的子元素索引</param>
/// <param name="lastVisibleChildIndex">最后一个显示的子元素索引</param>
private void MeasureChild(Size availableSize, int firstVisibleChildIndex, int lastVisibleChildIndex)
{
if (firstVisibleChildIndex < )
{
return;
} // 注意,在第一次使用 ItemContainerGenerator之前要先访问一下InternalChildren,
// 否则ItemContainerGenerator为null,是一个Bug
UIElementCollection children = InternalChildren;
IItemContainerGenerator generator = ItemContainerGenerator; // 获取第一个可视元素位置信息
GeneratorPosition position = generator.GeneratorPositionFromIndex(firstVisibleChildIndex);
// 根据元素位置信息计算子元素索引
int childIndex = position.Offset == ? position.Index : position.Index + ; using (generator.StartAt(position, GeneratorDirection.Forward, true))
{
for (int itemIndex = firstVisibleChildIndex; itemIndex <= lastVisibleChildIndex; itemIndex++, childIndex++)
{
bool isNewlyRealized; // 用以指示新生成的元素是否是新实体化的 // 生成下一个子元素
var child = (UIElement)generator.GenerateNext(out isNewlyRealized); if (isNewlyRealized)
{
if (childIndex >= children.Count)
{
AddInternalChild(child);
}
else
{
InsertInternalChild(childIndex, child);
}
generator.PrepareItemContainer(child);
} // 测算子元素布局
child.Measure(availableSize);
}
}
}
清理不再显示的子元素
/// <summary>
/// 清理不需要显示的子元素
/// </summary>
/// <param name="firstVisibleChildIndex">第一个显示的子元素索引</param>
/// <param name="lastVisibleChildIndex">最后一个显示的子元素索引</param>
private void CleanUpItems(int firstVisibleChildIndex, int lastVisibleChildIndex)
{
UIElementCollection children = this.InternalChildren;
IItemContainerGenerator generator = this.ItemContainerGenerator; // 清除不需要显示的子元素,注意从集合后向前操作,以免造成操作过程中元素索引发生改变
for (int i = children.Count - ; i > -; i--)
{
// 通过已显示的子元素的位置信息得出元素索引
var childGeneratorPos = new GeneratorPosition(i, );
int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPos); // 移除不再显示的元素
if (itemIndex < firstVisibleChildIndex || itemIndex > lastVisibleChildIndex)
{
generator.Remove(childGeneratorPos, );
RemoveInternalChildRange(i, );
}
}
}

清理不再显示的子元素

本文章摘自: http://www.cnblogs.com/talywy/archive/2012/09/07/CustomVirtualizingPanel.html  非常棒,非常感谢博主