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  控件其它方面的相关重构。

时间: 2025-01-12 07:04:32

OEA 中 WPF 树型表格虚拟化设计方案的相关文章

OEA 中 WPF 树型表格整体重构

为什么要重构       上两个月主要做了一件事情,那就是把 OEA 框架中的 TreeGrid 控件,从结构上重新设计,并大量重构现有代码.而花较大精力做这件事的原因,主要是因为: 业务中需要支持一系列新功能:整行编辑.上下箭头键进行导航.合计行.锁定列 等. 控件显示性能较差,需要支持列虚拟化. 和 OEA 元数据系统耦合,希望独立为单独的控件程序集,提高复用性. 不支持 xaml 声明的格式.原控件直接在后台用 OEA 代码生成,本质上作为一个 WinForm 控件来用. 整个 TreeG

技术总结:自动扩张WPF树型表格列宽

问题描述     今天测试人员提了一个易用性的BUG,主要是说系统目前使用的树型控件不支持自动扩张列的宽度.其实客户那边已经对这个问题提了多次,不过由于对WPF只是入门级,所以一直都没改.这两天项目比较闲,就花了些时间把这个问题改了.原问题如下:   图1 问题描述   背景     树型控件在GIX4系统中已经被大量使用.这个控件是一年前其它同事在网上搜索到,再引入的.     一开始的时候,要解决这个问题,想到的最直接的方案是这样的:找到第一列中的Expander控件(加号:),然后监听它的

oracle中的树型查询

oracle         在项目开发中树型结构是经常被使用的,通常情况下我们对查询的结果集使用一些免费的js文件就能达到很好的显示效果,比如         但是有时候我们需要对下拉列表也提供树型结构(比如论坛的板块跳转),这个时候我们就可以使用oracle9i以上版本提供的CONNECT BY 子句了.  SELECT kindid,kindname,fatherid      FROM kn_kind      CONNECT BY PRIOR kindid=fatherid     

树型结构在ASP中的简单解决

解决|树型结构 树型结构在我们应用程序中还是很常见的,比如文件目录,BBS,权限设置,部门设置等.这些数 据信息都采用层次型结构,而在我们现在的关系型数据库中很难清淅表达.那么要在程序中遇到树型 结构问题该如何处理呢? 最近笔者通过一个ASP权限管理的程序轻松解决了一这问题,现在将其整理出来以飨读者. 首先,要将层次型数据模型转化为关系型数据模型.也就是说如何在我们的ACCESS,SQL SERVER ,ORACLE等关系型数据库中设计这个数据结构. 拿个实例来讲吧,譬如下面一个数据: 文档管理

Rafy 领域实体框架 - 树型实体功能(自关联表)

在 Rafy 领域实体框架中,对自关联的实体结构做了特殊的处理,下面对这一功能进行讲解.   场景 在开发数据库应用程序时,往往会遇到自关联表的场景.例如,分类信息.组织架构中的部门.文件夹信息等,都是不限制层级的.如下图中操作系统的文件夹: 在开发这类程序时,往往是设计一张表,表中的一个可空的外键直接引用这张表本身.对应的实体如下图: 而针对这样的场景,许多ORM框架都不做默认的处理,开发者往往每次都要做重复的工作:建立类似结构的表,编写关系处理代码,编写查询代码--而这种场景经常会出现,所以

一道 SQL 题 ... (关于树型结构的在关系表中的存储及其应用处理)

树型结构 相关讨论连接:http://expert.csdn.net/Expert/TopicView1.asp?id=1477009原题:表:Tree (ID [Integer],ParentID [Integer],Remark [varchar]) INSERT INTO Tree (ID,ParentID)        SELECT 1,0    UNION ALL       SELECT 2,1    UNION ALL       SELECT 3,1    UNION ALL 

.NET中C#实现C/S架构下的TREEVIEW只需要输入表名,父ID,节点ID,节点名就可以得到树型结构

treeview|架构|树型结构 调用时如下:         /// <param name="newTreeView">树型控件名称</param>        /// <param name="TreeViewName">一层的功能名称</param>        /// <param name="TableName">数据库中的表名</param>       

VC程序中树型控件节点拖动的完美实现

Visual C++中提供的MFC类CtreeCtrl(树型控件)用来显示具有一定层次结构的数据项时 方便.直观,所以它已经被广泛地应用在各种软件中,如资源管理器中的磁盘目录就用的是 树型控件,我们在编程中也会经常用到这个控件,但是这个控件也有缺陷,那就是它并不直 接支持拖动节点等高级特性,这使得程序员在编程时使用它受到了很大限制,同时又给软件 用户带来了一些不便.为此,本实例通过从 CTreeCtrl 中派生了一个类 CXTreeCtrl ,实现 树型控件中节点的拖动.这个类具有如下的功能:⑴

VC中实现带有背景位图的树型控件

当前许多应用程序都在使用树型控件时为其添加了背景位图,增强的控件的魅力,然而对于Visual C++编程爱好者来说,使用Visual C++MFC提供的树型控件(CTreeCtrl)本身就是一个难点,至于如何使该控件能够带有背景位图,那就更加是一个令人困惑的问题了.本实例对CTreeCtrl类进行了增强,不仅使它带有背景位图,而且解决了在点击树型控件时背景位图闪动的问题,另外,通过在对话框中使用该控件来显示三级目录,演示了树型控件的基本使用方法.下图为程序编译后的运行效果图: 图一.带背景图的树