OEA 中 WPF 树型表格虚拟化设计方案
最近用 OEA 做的仓库管理系统中,许多界面的都需要使用表格控件来显示数据。一是这些表格的列非常多,有的甚至达到了 200 列,而且一个模块的界面中可能同时显示好几个表格。这导致界面的速度比较慢,特别是较多数据需要展现时。经检测,表现虽然表格的行已经做了虚拟化,但是由于列非常多,最终还是造成可视树中的元素过多,而导致界面布局代码运行过慢。假设只有 30 行,一个单元格仅生成 5 个可视元素,200 列的单元格都会产生 3W 个可视元素,而布局系统的 Measure 方法需要对可视树中的每一个元素都调用其对应的 Measure 方法,可以想象,这当然会很慢。
那么,要解决上述的问题,只有同时实现表格的行、列虚拟化,才能有效地减少表格的可视元素,从而提高系统性能。还好,OEA 中的 TreeGrid 本身就是我们自己为 OEA 量身定制的控件,所以可以直接改造。
但是,要同时在一个表格控件中同时实现行、列虚拟化呢?我们得先看看如何在 WPF 中实现虚拟化。
WPF 虚拟化相关知识
我之前写过一篇文章《精通 WPF UI Virtualization》,里面引用了许多老外的文章,说明了要实现界面虚拟化需要做的几件事。这里我来汇总下:
- * 设置 ScrollViewer.CanContentScroll 为 True。默认为 False 时,ScollViewer 自己实现了滚动逻辑,在 Measure 时会把 Infinite 传给 Content 元素;而当该值被设置为 True时,ScrollViwer 认为它的 Content 元素自己实现了 IScrollInfo 并处理所有的滚动逻辑。
- * 从 VirtualizingPanel 继承出一个子类,并让这个新的 Panel(以下称为 UIVPanel) 实现 IScrollInfo。
- * 在 UIVPanel 中实现虚拟化逻辑,生成或销毁界面元素。
1. 要知道如何实现 IScrollInfo,则需要明白 IScrollInfo 的设计原理:
如果 UIVPanel 元素自己要处理滚动信息,它必须知道当前滚动条的 OffSet,并告知 ScrollViewer 需要的总大小是多少,这样才能正确地显示滚动条。由于 UIVPanel 元素的 Measure 方法被 ScrollViwer 调用时,参数只能传入和传出视窗的大小,那么,外围的 ScrollViewer 想要和 UIVPanel 交互更多的数据,例如传入 OffSet(VerticalOffSet 及 HorizontalOffSet)、获取 Extent(Height/Width),则只能通过 UIVPanel 本身的公有属性来交互,也就要求 UIVPanel 必须实现 IScrollInfo 中定义的所有属性及方法。(注意,IScrollInfo 中的所有方法,本质上只是期望设置新的 Offset,只是滚动的粒度不同而已。)
2. 实现 IScrollInfo 的 UIVPanel 与 ScrollViewer 交互的细节如下:
* ScollViewer 会在滚动条变更时,调用 UIVPanel 的 SetVerticalOffset 或者相关方法来变更 Offset 值,UIVPanel 则在 SetVerticalOffset 中调用 InvalidateMeasure 来重新测量自身。
* UIVPanel 的 MeasureOverride 方法中,参数是 ScrollViewer 传入的视窗大小,再获取其内部数据 VerticalOffset,最终计算出 IScrollInfo 中的 ExtentHeight/ExtentWidth(总高度/总宽度)。如果这个值有所变化,则应该调用 ScrollOwner.InvalidateScrollInfo 通知 ScrollOwner 来重新获取最新的总高度,以计算出滚动条最新的大小。
在与 ScrollViewer 交互完成的同时,UIVPanel 还应该根据提供的视窗大小,调用基类 VirtualizingPanel 中 ItemContainerGenerator 属性的一套元素生成方法,通过视窗大小、当前 Offset,来生成新的需要显示的容器,并移除不可见的容器,最终达到虚拟化的效果。
3. GeneratorPosition 类的含义:
(不知道 GeneratorPosition 类型的朋友,可以先看一下这篇文章中的《Implementing a VirtualizingPanel part 2: IItemContainerGenerator》代码。)
在使用 ItemContainerGenerator 来生成元素时,需要理解 GeneratorPosition 的含义。它中有两个属性:Index 及 Offset,它们的意义可以从 IndexFromGeneratorPosition 方法中理解出来:
Index 如果大于等于 0 时,则表示一个生成好的项容器在所有已经生成好的项容器中的索引。假设这个容器为 A,那么,在 A 的基础上,如果 Offset 是 0,则整个 GeneratorPosition 就表示项容器 A;而如果 Offset 非 0,则表示一个还没有生成的项容器 B,它距离 A 的相对位置正好是 Offset。
Index 若是 -1 时,OffSet 如果是正数表示目标容器到起点的偏移量,如果是负数则表示目标容器到终点的偏移量。
GeneratorPosition 类型的设计比较晦涩,不易理解。这跟 VirtualizingPanel.ItemContainerGenerator 中虚拟化的内部实现的数据结构是有关系的。虚拟化会把整个列表分割成多个小块,这些小块主要是两类:UnrealizedItemBlock(未实例化块)、RealizedItemBlock(已实例化块)。整个列表由这些块组合起来表示,假设一页能显示 30 条数据,则一个一万行的列表可能由以下小块组成:RealizedItemBlock 60,UnrealizedItemBlock 8000,RealizedItemBlock 150,UnrealizedItemBlock 1790,总和是一万。所有的块在 ItemContainerGenerator 中由一个双向链表存储在字段 _itemMap 中。_itemMap.Next 就是第一个块,也可以理解为起点或者终点。 UnrealizedItemBlock 与 RealizedItemBlock 类都继承自 ItemBlock。ItemBlock 中有两个重要属性:ItemCount、ContainerCount。ItemCount 表示本块代表了多少条数据,二者实现一致。而 ContainerCount 表示已经生成的容器的个数,对于 UnrealizedItemBlock 来说,永远返回 0; 而 RealizedItemBlock 返回它的 ItemCount 表示容器数就是项数。
所以,到现在已经能够看出,其实 GeneratorPosition 存储了某个 ItemBlock 的索引号,以及具体容器相对这个 ItemBlock 的偏移量。而操作 ItemContainerGenerator 都使用 GeneratorPosition,可以方便地和内部的数据结构交互。(这样设计的原因可能是出于性能的考虑?)
说完了 UIV 的相关知识,接下来,那我们就开始设计 TreeGrid 表格的虚拟化。
表格的虚拟化
由前面的内容可以看出,如果要在 WPF 中实现一个行列都支持虚拟化的 UIVPanel,只需要从 VirtualizingPanel 上继承下一个 UIVPanel 类型,并根据列的宽度来计算并生成相应的单元格就行了。但是如果这样设计的话,将会导致所有的单元格,都必须放在 UIVPanel 中。也就是说,TreeGrid 作为一个 ItemsControl,其中的所有单元格 TreeGridCell 都必须作为它的逻辑子容器。这样的设计虽然实现了界面虚拟化,但是并不可取。这是因为,开发人员对于 TreeGrid 的常见用法应该是:TreeGrid 中的每一项是一个表格行 TreeGridRow,而 TreeGridRow 又是一个 ItemsControl,行中其中的每一项才是横向排列的单元格 TreeGridCell。这样的场景导致 TreeGrid 的接口设计也应该是 TreeGrid -> TreeGridRow -> TreeGridCell 这样层级的接口,逻辑树、可视树也都应该是按这样的层次构建,易于使用、易于调试。
那么,在这样层次要求下,要如何实现只使用一个滚动条的虚拟化呢?还好,WPF 自带的 DataGrid 也带有行列虚拟化的功能,我们可以先看一下 DataGrid 是如何实现的。 下图是 DataGrid 打开行、列虚拟化功能后生成的可视树:
图1 DataGrid 虚拟化可视树结构
结合上面这个图,再查阅 DataGrid 源码,可以看出:
* 整个 DataGrid 表格中只有一个 ScrollViewer,表格作为一个 ItemsControl,内部每一项是一个 DataGridRow,其内部作为 ItemsHost 使用的面板是 DataGridRowsPresenter 类型。DataGridRowsPresenter 继承自 VirtualizingStackPanel,就间接继承 VirtualizingPanel 并实现 IScrollInfo 接口,为最外层的 ScrollViewer 提供滚动信息,提供 DataGridRow 行的虚拟化功能。
* 每一个 DataGridRow 中,使用一个继承自 ItemsControl 的 DataGridCellsPresenter 来生成每一个单元格的容器,而它则使用 DataGridCellsPanel 来作为 ItemsHost 面板。DataGridCellsPanel 也是一个继承自 VirtualizingPanel 的虚拟化面板。但是,它并没有实现 IScrollInfo。为了使用最外层 ScrollViewer 中的滚动条信息,它通过可视树往上查找到 DataGridRowsPresenter 来获取水平方向上的滚动条位置 HorizontalOffset,而通过这个值,来计算水平方向上需要显示的单元格,以实现虚拟化。
* 另外,需要额外说明下两个 ItemsControl 的数据源:DataGrid 的 ItemsSource 当然就是应用层指定的数据模型的列表,这样,每一个 DataGridRow 的 DataContext 就是其中的一个数据模型对象。而有意思的是,表格行内的 DataGridCellsPresenter,作为一个横向显示单元格的控件,它也是一个 ItemsControl,也需要设置它的 ItemsSource 数据源属性。由于每一个行的 DataContext,也应该是每一个单元格的 DataContext,所以 DataGridCellsPresenter.ItemsSource 应该被设置为一个数据模型对象列表,其中每一个元素都是 DataGridRow.DataContext 对象,列表的长度就是表格列的个数,这样就可以生成和列的个数一致的单元格个数。(内部实现上,MS 使用了一个实现 IList 接口的 MultipleCopiesCollection 集合类型,只需要设置 CopiedItem 及 Count 两个属性,即可表现出长为 Count、每个元素都是 CopiedItem 的行为。)
TreeGrid 的虚拟化
根据之前的分析,我们已经知道表格 DataGrid 实现虚拟化都需要哪些元素,元素之间是如何交互的。而我们的 TreeGrid 控件也是模仿这个结构进行的设计,添加了相应的 TreeGridRowsPanel、TreeGridCellsPresenter、TreeGridCellsPanel 类型。最终的表格控件,经测试,给 20000 行数据,300列,都能在 0.5s 内完成渲染:
图2 虚拟化后可显示大量数据 TreeGrid
上图表格中的大量数据,只生成了少量的可视元素,最终生成的可视树结构如下:
图3 TreeGrid 虚拟化后的可视树元素
由于每一列的单元格都是随着拖动横向滚动条而生成的,所以在拖动时有一定的延迟,没有原来感觉流畅。所以当列数较少时,则没有必要打开列虚拟化。目前暂时设定为,当列数超过 50 的时候,该表格会自动打开列虚拟化功能,提升渲染性能。
未来的改进
其实,TreeGrid 作为 OEA 框架界面层的核心控件,主要是在提供 WPF 中的树型表格及一般表格功能。一般表格状态下的性能保障由虚拟化技术来实现。而在树型状态下,则主要是支持树节点的懒加载,只实例化已经开展的行,即只有展开树中的父行时,才会生成其对应的子行。如下图所示:
图4 树型表格的懒加载
树型表格状态下,暂时没有实现虚拟化。
VirtualizingStackPanel 为了提高性能,它是根据 Item (项数)而不是 Pixel (象素)来计算滚动条信息。这导致了当每一行的高不统一时,竖向滚动条会计算出错,造成很差的用户体验。这也是为什么 ListBox 等控件在分组状态下,虚拟化会被关闭的原因:分组后每一项其实是 GroupItem 类型,而每个组的高度并不一致。
而 TreeGrid 中,支持行虚拟化的 TreeGridRowsPanel 是继承自 VirtualizingStackPanel 来实现的。而表格行 TreeGridRow 类则继承自 HeaderedItemsControl 类型,它的总行高应该是本行的高度加上所有子行的高度,也不是一个定值,所以现在虚拟化功能也被关闭。而当行虚拟化关闭后,由于列虚拟化实现的机制依赖最外层的 ScrollViewer,所以也被关闭。也就是说,暂时不能只打开列虚拟化,而不打开行虚拟化。
这些功能其实都是可以打开的,但是前提是必须让 TreeGridRowsPanel 继承自 VirtualizingPanel 而不是 VirtualizingStackPanel,并实现自定义行高的计算逻辑,相对复杂。考虑到目前树型表格状态下,使用懒加载在性能上已经没有什么问题,暂时就不实现虚拟化了。
(另外,就算重写了行的虚拟化面板,来通过 TreeGridRow 计算出它所有子的高度,最后对需要显示的行进行实例化。也只能打开最外层 TreeGridRow 的虚拟化功能,而树可能有第二层、第三层……,这些层都无法实现虚拟化。如果要实现这些层的虚拟化,那就更复杂了…… :( )
其实,懒加载和虚拟化技术,本质上是一样的,都是把不需要显示的元素延后实例化。 :)
后话
由于 TreeGrid 虚拟化技术的相关设计思路主要来自 DataGrid,有些代码甚至是直接拷贝自 DataGrid,所以代码就不贴在这了。下次更新 OEA 的时候,大家就可以在开源地址中下载到了。
TreeGrid 表格实现虚拟化技术,涉及到重构整个控件内部的组织结构,是本阶段 TreeGrid 重构的一个首要内容。而下一篇文章,会说一下 TreeGrid 控件其它方面的相关重构。
TAG: