zoukankan      html  css  js  c++  java
  • 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  控件其它方面的相关重构。

     

    欢迎转载,转载请注明:

    转载自 胡庆访http://zgynhqf.cnblogs.com/ ]

  • 相关阅读:
    Head first javascript(七)
    Python Fundamental for Django
    Head first javascript(六)
    Head first javascript(五)
    Head first javascript(四)
    Head first javascript(三)
    Head first javascript(二)
    Head first javascript(一)
    Sicily 1090. Highways 解题报告
    Python GUI programming(tkinter)
  • 原文地址:https://www.cnblogs.com/zgynhqf/p/2737316.html
Copyright © 2011-2022 走看看