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 虚拟化可视树结构 
    图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 内完成渲染:

    image 
    图2 虚拟化后可显示大量数据 TreeGrid

 

    上图表格中的大量数据,只生成了少量的可视元素,最终生成的可视树结构如下:

图2 TreeGrid 虚拟化后的可视树元素 
    图3 TreeGrid 虚拟化后的可视树元素

 

    由于每一列的单元格都是随着拖动横向滚动条而生成的,所以在拖动时有一定的延迟,没有原来感觉流畅。所以当列数较少时,则没有必要打开列虚拟化。目前暂时设定为,当列数超过 50 的时候,该表格会自动打开列虚拟化功能,提升渲染性能。

 

未来的改进


 

    其实,TreeGrid 作为 OEA 框架界面层的核心控件,主要是在提供 WPF 中的树型表格及一般表格功能。一般表格状态下的性能保障由虚拟化技术来实现。而在树型状态下,则主要是支持树节点的懒加载,只实例化已经开展的行,即只有展开树中的父行时,才会生成其对应的子行。如下图所示:

图4 树型表格的懒加载 
    图4 树型表格的懒加载

    树型表格状态下,暂时没有实现虚拟化。

    VirtualizingStackPanel 为了提高性能,它是根据 Item (项数)而不是 Pixel (象素)来计算滚动条信息。这导致了当每一行的高不统一时,竖向滚动条会计算出错,造成很差的用户体验。这也是为什么 ListBox 等控件在分组状态下,虚拟化会被关闭的原因:分组后每一项其实是 GroupItem 类型,而每个组的高度并不一致。

    而 TreeGrid 中,支持行虚拟化的 TreeGridRowsPanel 是继承自 VirtualizingStackPanel 来实现的。而表格行 TreeGridRow 类则继承自 HeaderedItemsControl 类型,它的总行高应该是本行的高度加上所有子行的高度,也不是一个定值,所以现在虚拟化功能也被关闭。而当行虚拟化关闭后,由于列虚拟化实现的机制依赖最外层的 ScrollViewer,所以也被关闭。也就是说,暂时不能只打开列虚拟化,而不打开行虚拟化。

    这些功能其实都是可以打开的,但是前提是必须让 TreeGridRowsPanel 继承自 VirtualizingPanel 而不是 VirtualizingStackPanel,并实现自定义行高的计算逻辑,相对复杂。考虑到目前树型表格状态下,使用懒加载在性能上已经没有什么问题,暂时就不实现虚拟化了。

    (另外,就算重写了行的虚拟化面板,来通过 TreeGridRow 计算出它所有子的高度,最后对需要显示的行进行实例化。也只能打开最外层 TreeGridRow 的虚拟化功能,而树可能有第二层、第三层……,这些层都无法实现虚拟化。如果要实现这些层的虚拟化,那就更复杂了……  :(  )

    其实,懒加载和虚拟化技术,本质上是一样的,都是把不需要显示的元素延后实例化。 :)

 

后话


    由于 TreeGrid 虚拟化技术的相关设计思路主要来自 DataGrid,有些代码甚至是直接拷贝自 DataGrid,所以代码就不贴在这了。下次更新 OEA 的时候,大家就可以在开源地址中下载到了。

    TreeGrid 表格实现虚拟化技术,涉及到重构整个控件内部的组织结构,是本阶段 TreeGrid 重构的一个首要内容。而下一篇文章,会说一下 TreeGrid  控件其它方面的相关重构。



本文转自BloodyAngel博客园博客,原文链接:http://www.cnblogs.com/zgynhqf/archive/2012/10/24/2737316.html,如需转载请自行联系原作者

相关文章
|
C# 虚拟化 索引
【WPF】UI虚拟化之------自定义VirtualizingWrapPanel
原文:【WPF】UI虚拟化之------自定义VirtualizingWrapPanel 前言 前几天QA报了一个关于OOM的bug,在排查的过程中发现,ListBox控件中被塞入了过多的Item,而ListBox又定义了两种样式的ItemsPanelTemplate。
2218 0
|
4月前
|
开发框架 前端开发 JavaScript
循序渐进介绍基于CommunityToolkit.Mvvm 和HandyControl的WPF应用端开发(5) -- 树列表TreeView的使用
循序渐进介绍基于CommunityToolkit.Mvvm 和HandyControl的WPF应用端开发(5) -- 树列表TreeView的使用
|
C# 数据格式 XML
WPF 资源(StaticResource 静态资源、DynamicResource 动态资源、添加二进制资源、绑定资源树)
原文:WPF 资源(StaticResource 静态资源、DynamicResource 动态资源、添加二进制资源、绑定资源树) 一、WPF对象级(Window对象)资源的定义与查找 实例一: StaticR...
8466 0
|
Linux API C#
WPF跨平台方案?
WPF跨平台方案?
460 1
|
C# 虚拟化 网络架构
WPF的UI虚拟化
原文:WPF的UI虚拟化 许多时候,我们的界面上会呈现大量的数据,如包含数千条记录的表格或包含数百张照片的相册。由于呈现UI是一件开销比较大的动作,一次性呈现数百张照片就目前的电脑性能来说是需要占用大量内存和时间的。
1004 0
|
C# 虚拟化 容器
WPF之UI虚拟化
原文:WPF之UI虚拟化 在WPF应用程序开发过程中,大数据量的数据展现通常都要考虑性能问题。有下面一种常见的情况:原始数据源数据量很大,但是某一时刻数据容器中的可见元素个数是有限的,剩余大多数元素都处于不可见状态,如果一次性将所有的数据元素都渲染出来则会非常的消耗性能。
1088 0
|
C# .NET 开发框架
WPF 4 目录树型显示
原文:WPF 4 目录树型显示      本篇将通过WPF4 制作简单的目录树型结构显示实例,完成本篇内容我们将作出下图所示的应用程序。      从图中我们可以看到程序主要分为两部分:左边显示本地驱动器的目录树型列表,右边显示被选中目录中的文件信息。
1184 0
|
大数据 C# 虚拟化
WPF 列表开启虚拟化的方式
原文:WPF 列表开启虚拟化的方式 正确开启虚拟化的方式 列表如ListBox,ListView,TreeView,GridView等,开启虚拟化 ScrollViewer设置CanContentScroll=True 直接在模板中,设置CanContentScroll="True" 如模板中未设置CanContentScroll属性,可以在列表添加属性ScrollViewer.CanContentScroll="True"。
998 0
|
C# Windows 开发工具
WPF的逻辑树和视觉树
原文:WPF的逻辑树和视觉树 这部分的内容来自于即将出版的新书《WPF Unleashed》的第三章样章。关于什么是逻辑树,我们先看下面的一个伪XAML代码的例子:            LabelText     在这样一个简单UI中,Window是一个根结点,它有一个子结点StackPanel。
1522 0
|
C# 虚拟化 自然语言处理
WPF中ItemsControl应用虚拟化时找到子元素的方法
原文:WPF中ItemsControl应用虚拟化时找到子元素的方法  wpf的虚拟化技术会使UI的控件只初始化看的到的子元素, 而不是所有子元素都被初始化,这样会提高UI性能。
1943 0

相关课程

更多