虚拟化 WrapPanel





5.00/5 (28投票s)
WPF 的虚拟化 WrapPanel。
本文的最新代码可以在 这里 找到;如果您想为代码做出贡献,请在此处发布一条消息并附上您的 CodePlex 帐户。
引言
这是一个虚拟化 WrapPanel,类似于默认的 WPF WrapPanel
,但它应该用在绑定到大量数据的 ListBox
/ListView
中,因为它只生成用户可见的元素,从而提高性能。
描述
在网上搜索了一个实现后,我下载了一个付费解决方案的试用版,但它不起作用(至少对我需要的功能不起作用),所以我决定自己写一个。我花了一整天时间来实现它,于是我想:“为什么不分享出去,让其他人不必经历同样的麻烦呢?”
我编写这个面板是为了实现一个自定义的打开文件对话框,该对话框像 Windows XP 默认打开文件对话框一样排列文件(固定高度和垂直方向,以便换行到新列),但您可以使用它来显示任何换行内容(如果数据集很小,它与默认的 WrapPanel
没有区别)。
实现
首先,虚拟化面板必须处理滚动,所以我遵循了 Ben Constable 关于如何实现 IScrollInfo
接口的 教程 中的说明。该接口是 ScrollViewer
在 ListBox
/ListView
中与您的面板通信的方式;它会向面板询问总区域(可以是固定的,也可以由子项的大小确定),并将可见区域(视口)作为参数传递给面板的 MeasureOverride
方法。它还会告诉面板用户何时以任何方式使用了滚动条。其次,您必须使用 VirtualizingPanel
的 ItemContainerGenerator
来处理子项的生成/销毁,如 Dan Crevier 的 教程 中所示。
最大的问题是,在那些示例中,项的大小是预先知道的,要么通过大小计算规则,要么通过从属性获取值。在 WrapPanel
中,只有当项通过 ItemContainerGenerator
实例化后,您才能知道子项的大小,但同时,UI 的虚拟化要求只实例化可见的子项。当您尝试计算面板的范围(extent)时,而大小又由子项决定,这就成了一个问题。
为了解决这个问题,我首先遍历集合中的所有项,当找到一个新类型时,我就为该类型生成容器并将其映射到类型。当用户显式设置 ItemTemplate
或使用 ItemTemplateSelector
时,同样的规则也适用,如下面的方法所示。
private void RealizePossibleDataTemplates()
{
var virtualFrame = new Rect(new Size(10000, 10000));
var template = _itemsControl.ItemTemplate;
if (template != null)
{
Type type = template.DataType as Type;
var realizedTemplate = (FrameworkElement)template.LoadContent();
realizedTemplate.HorizontalAlignment = HorizontalAlignment.Left;
realizedTemplate.VerticalAlignment = VerticalAlignment.Top;
realizedTemplate.Arrange(virtualFrame);
_realizedDataTemplates.Add(type, (FrameworkElement)realizedTemplate);
return;
}
var templateSelector = _itemsControl.ItemTemplateSelector;
if (templateSelector != null)
{
foreach (var item in _itemsControl.Items)
{
var dt = templateSelector.SelectTemplate(item, _itemsControl);
if (!_loadedDataTemplates.ContainsKey(dt))
{
var realizedTemplate = (FrameworkElement)dt.LoadContent();
realizedTemplate.HorizontalAlignment = HorizontalAlignment.Left;
realizedTemplate.VerticalAlignment = VerticalAlignment.Top;
realizedTemplate.Arrange(virtualFrame);
_loadedDataTemplates.Add(dt, realizedTemplate);
}
}
_useTemplateSelector = true;
return;
}
int count = _itemsControl.Items.Count;
for (int i = 0; i < count; i++)
{
object currentItem = _itemsControl.Items[i];
Type currentItemDataType = currentItem.GetType();
if (!_realizedDataTemplates.ContainsKey(currentItemDataType))
{
var currentPos = _generator.GeneratorPositionFromIndex(i);
using (_generator.StartAt(currentPos, GeneratorDirection.Forward, false))
{
var child = _generator.GenerateNext() as ListBoxItem;
child.HorizontalAlignment = HorizontalAlignment.Left;
child.VerticalAlignment = VerticalAlignment.Top;
_generator.PrepareItemContainer(child);
child.Arrange(virtualFrame);
_realizedDataTemplates.Add(currentItemDataType,
(FrameworkElement)VisualTreeHelper.GetChild(child, 0));
}
_generator.Remove(currentPos, 1);
}
}
}
当需要计算特定项的大小时,我使用这个
private Size GetItemSize(object item)
{
FrameworkElement realizedTemplate = null;
if (_useTemplateSelector)
{
var templateSelector = _itemsControl.ItemTemplateSelector;
var template = templateSelector.SelectTemplate(item, _itemsControl);
realizedTemplate = _loadedDataTemplates[template];
}
else
{
Type itemDataType = item.GetType();
if (!_realizedDataTemplates.ContainsKey(itemDataType))
throw new ArgumentException("invalid item");
realizedTemplate = _realizedDataTemplates[itemDataType];
}
realizedTemplate.DataContext = item;
realizedTemplate.UpdateLayout();
return new Size(realizedTemplate.ActualWidth, realizedTemplate.ActualHeight);
}
之后,一切就相当直接了。所有需要做的就是“虚拟化”所有项,通过存储大小并计算位置(WrapPanel
特定的逻辑)。
使用代码
要使用该控件,只需将其设置为已绑定的 ListBox
或 ListView
上的 ItemsPanel
。
<ListBox ItemsSource="{StaticResource boundCollection}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingWrapPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
关注点
我之所以想使用一个虚拟化的 wrappanel,是因为当我在一个普通的 WrapPanel
中拥有一个大集合时,使用键盘箭头键导航很慢。后来我发现这是因为面板用来查找最近子项的计算是一个 O(n) 算法(n 是面板中的项数),所以编写一个虚拟化面板在这方面并没有起到帮助(尽管它有助于大型数据集的加载时间)。因此,在虚拟化 WrapPanel 中,我处理 KeyPress
事件以使用我自己的导航逻辑。如果您想使用默认面板的键盘导航,只需删除 'OnKeyDown
' 方法。
观察
您不能将此面板用作独立的布局控件,它必须用在 ListBox
/ListView
中,并且 ItemsSource
属性设置为一个集合。希望有人觉得这有用。
更新
在测试了虚拟化 WrapPanel 的第一个版本后,我发现性能不可接受,因为大小计算使用了 'UIElement.UpdateLayout()
' 方法,当元素数量非常大时,这反而导致了比普通 WrapPanel
更大的性能问题。于是我决定检查虚拟化 StackPanel 的内部工作原理,以便了解它是如何如此快速地计算面板范围(extent)的。猜猜怎么着?它对项的大小一无所知。
正如本文第一部分所示,我在生成容器之前预先计算了项的大小,这对于实现基于像素的虚拟化面板是必要的。所谓的基于像素,是指面板的视口和范围都以像素为单位进行测量。
虚拟化 StackPanel 使用了一种不同的方法:它使用基于项的度量单位来测量视口和范围。例如:如果面板的方向是垂直的,并且项的数量是 957,那么范围的高度就设置为 957。在每次测量(滚动垂直偏移量改变)之后,视口的高度动态设置为可见项的数量。有些人可能已经注意到,当您使用向下滚动按钮时,虚拟化 StackPanel 会将其视口移动一个项。这是因为 LineDown()
方法将垂直偏移量增加了 1。因此,无论项的高度(以像素为单位)是多少,它总是会移动到下一个项。当项的高度(或水平方向面板的宽度)发生大值变化时,这可能会导致滚动条出现一些奇怪的效果,因为可见项的数量(以及因此的视口尺寸)会发生变化。除此之外,这种度量方法使得面板的性能极高,与项的数量无关。
在理解了虚拟化 StackPanel 的实现后,我心想:“那么,我只需要使用非基于像素的度量来实现虚拟化 WrapPanel。”这时我发现了一些复杂的问题,让我思考是否真的能用这种方式实现虚拟化 WrapPanel。
WrapPanel
可以向两个方向增长,而StackPanel
只向垂直或水平方向增长。此外,在与面板方向正交的方向上的增长取决于在面板方向上的项的大小,因此,除非我知道最后一行的所有项的大小,否则我无法知道何时应该换行到新的一行/列,但如果我想对两个方向的面板进行虚拟化,这是不可能的。- 范围的大小与行/列的数量(我将称之为“段”)相同。在
StackPanel
中,这不是问题:由于每个段只允许一个项,所以范围的宽度/高度就是项的数量。在WrapPanel
中,段的数量高度依赖于项的大小,由于项的大小不是固定的并且可以在任何方向上改变,因此无法计算WrapPanel
中的段数。
话虽如此,我发现了一种方法,通过使用以下技巧/限制来开发非基于像素的虚拟化 WrapPanel。
- 面板的范围永远无法确定。相反,我在每次
MeasureOverride
后估算段的数量,使用以下表达式:(项数/每段平均项数)。由于我在每次滚动后更新每段的平均项数,所以面板的范围是动态的。如果面板方向上的项大小是固定的,则范围将是静态的。 - 项的段/段索引只能按顺序计算,所以如果您正在查看前面的项,然后跳转到一个跳过一个或多个未实现段的段,面板将使用估算来知道哪个项是第一个可见项。如果您返回到一个已实现的段并按顺序返回,这将得到纠正。例如:如果面板知道项 12 在段 1 中,并且面板估算每段 10 个项(段 0 是第一个),如果您跳转到段 9,面板将显示第 100 个项作为第一个可见项(只有当从段 1 到 9 之间恰好有 10 个项/段时,这才是正确的)。但是,如果您返回到段 1 并访问所有段直到达到段 9,那么面板将正确存储所有项的段,因此 up to 段 10 将不再进行估算。
- 通常,
ScrollViewer
中的WrapPanel
会允许垂直和水平滚动,但由于我只能虚拟化一个方向,这个WrapPanel
只会在与面板方向正交的方向上滚动。(意思是,您不应该显式设置VirtualizingPanel
的高度/宽度)。
虽然这个问题可能看起来很复杂,但有了这些数据结构,它就变得简单多了。
class ItemAbstraction
{
public ItemAbstraction(WrapPanelAbstraction panel, int index)
{
_panel = panel;
_index = index;
}
WrapPanelAbstraction _panel;
public readonly int _index;
int _sectionIndex = -1;
public int SectionIndex
{
get
{
if (_sectionIndex == -1)
{
return _index % _panel._averageItemsPerSection - 1;
}
return _sectionIndex;
}
set
{
if (_sectionIndex == -1)
_sectionIndex = value;
}
}
int _section = -1;
public int Section
{
get
{
if (_section == -1)
{
return _index / _panel._averageItemsPerSection;
}
return _section;
}
set
{
if (_section == -1)
_section = value;
}
}
}
class WrapPanelAbstraction : IEnumerable<ItemAbstraction>
{
public WrapPanelAbstraction(int itemCount)
{
List<ItemAbstraction> items = new List<ItemAbstraction>(itemCount);
for (int i = 0; i < itemCount; i++)
{
ItemAbstraction item = new ItemAbstraction(this, i);
items.Add(item);
}
Items = new ReadOnlyCollection<ItemAbstraction>(items);
_averageItemsPerSection = itemCount;
_itemCount = itemCount;
}
public readonly int _itemCount;
public int _averageItemsPerSection;
private int _currentSetSection = -1;
private int _currentSetItemIndex = -1;
private int _itemsInCurrentSecction = 0;
private object _syncRoot = new object();
public int SectionCount
{
get
{
int ret = _currentSetSection + 1;
if (_currentSetItemIndex + 1 < Items.Count)
{
int itemsLeft = Items.Count - _currentSetItemIndex;
ret += itemsLeft / _averageItemsPerSection + 1;
}
return ret;
}
}
private ReadOnlyCollection<ItemAbstraction> Items { get; set; }
public void SetItemSection(int index, int section)
{
lock (_syncRoot)
{
if (section <= _currentSetSection + 1 && index == _currentSetItemIndex + 1)
{
_currentSetItemIndex++;
Items[index].Section = section;
if (section == _currentSetSection + 1)
{
_currentSetSection = section;
if (section > 0)
{
_averageItemsPerSection = (index) / (section);
}
_itemsInCurrentSecction = 1;
}
else
_itemsInCurrentSecction++;
Items[index].SectionIndex = _itemsInCurrentSecction - 1;
}
}
}
public ItemAbstraction this[int index]
{
get { return Items[index]; }
}
}
正如您可以想象的,我不得不重写 90% 的代码,虽然我仍然会提供旧面板供下载,但我绝对推荐使用新面板,因为它速度更快,即使有局限性,这个面板对我创建的文件对话框来说也工作得非常完美,我希望它也能帮助其他人。