我在上一篇中解释了为什么要使用 ItemsRepeaterVirtualizingLayout 来构建一个瀑布流布局控件。在这一篇中,我将与各位朋友一起参照文档,了解二者及相关类的工作机制。

文档链接

布局过程
实现自定义布局控件
ItemsRepeater 控件
ItemsRepeater 类
Layout 类
LayoutContext 类

ItemsRepeater, Layout 与 LayoutContext

我们在上一章了解到,ItemsRepeater 是一个显示集合内容的控件,几乎没有其它功能。它与 UWP 内置的 ListView 等一样,都有 ItemsSourceItemTemplate 属性,以便进行数据绑定与指定数据模板。与 ListView 不同的是,ItemsRepeater 本身没有一个内置的集合。因此我们无法使用类似 itemsRepeater.Add(something) 等方法指定要显示的数据,而必须通过数据绑定实现。有关数据绑定数据模板的内容,可以参照文档。

不过 ItemsRepeater 只是提供了组织与显示数据相关的机制,而如何排列内容,则交给 Layout 负责处理。ItemsRepeater 有一个 Layout 属性,可以指定任意的布局控件。Layout 是 Windows UI Library 中所有布局控件的基类,而在实际扩展实现中,应该继承自它的两个派生类 VirtualizingLayoutNonVirtualizingLayout. 其中前者表示支持 UI 虚拟化的布局控件,后者表示不支持 UI 虚拟化的布局控件。Windows UI Library 中内置了三种布局控件,分别为 StackLayout (类似 ListView), UniformGridLayout (类似 GridView), 以及 FlowLayout (类似 WrapPanel). 它们均支持 UI 虚拟化。

Layout 可以作为一种静态资源来使用。这种情况下,多个 ItemsRepeater 可能会共享同一个 Layout 实例,这就意味着 Layout 不能有直接依赖某一个 ItemsRepeater 实例的状态。但是我们以后会看到,这种状态往往是布局逻辑所需要的。为了解决这个问题,Windows UI Library 提供了 LayoutContext 类,根据布局控件是否支持 UI 虚拟化又分为 VirtualizingLayoutContextNonVirtualizingLayoutContext 两个派生类。这些类有几个功能:

  • 存储布局逻辑需要的,与具体 ItemsRepeater 实例相关的状态
  • 提供访问需要排列的控件的机制
  • 对于支持 UI 虚拟化的布局控件,提供 UI 虚拟化的基础支持

其中第一项是通过 LayoutState 属性实现的。这一属性类型为 object, 可以存储布局控件想要的任何内容。第二、三项根据是否支持 UI 虚拟化有所不同,我将在后面详述。

Layout 类有如下方法可供重写 (方法中的 LayoutContext 参数实际上根据是否支持 UI 虚拟化,取两个派生类之一):

  1. InitializeForContextCore(LayoutContext)
  2. UnitializeForContextCore(LayoutContext)
  3. ArrangeOverride(LayoutContext, Size)
  4. MeasureOverride(LayoutContext, Size)

如果是 VirtualizingLayout, 还有另一个方法可供重写:

  1. OnItemsChangedCore(VirtualizingLayoutContext, Object, NotifyCollectionChangedEventArgs)

方法 1 与方法 2 是替 LayoutContext 初始化与清除 LayoutState 属性。这两个方法一般都相当简单:

1
2
3
4
5
6
7
8
9
10
protected override void InitializeForContextCore(LayoutContext context)
{
// ExampleLayoutState 是自定义的一个状态类.
context.LayoutState = new ExampleLayoutState();
}

protected override void UninitializeForContextCore(LayoutContext context)
{
context.LayoutState = null;
}

如果布局逻辑不需要状态,也可以不重写这两个方法。剩下的几个方法中,方法 5 是为了使支持 UI 虚拟化的布局控件能在集合元素改变时,更新其状态,我们将在以后用到它时讨论;而方法 3, 4 则是实现布局逻辑的关键。在展开之前,我们必须先了解一下 UWP (也包括 WPF) 中的布局机制。

布局机制

在 UWP 中,布局过程分为两步: Measure 与 Arrange. 在 Measure 过程中,每一个控件将对自身进行度量,并得出自己想要的空间大小;在 Arrange 过程中,各级布局控件将根据自身的布局逻辑,决定最终分配给每一个控件的空间大小,并为各个控件安排位置。这是一个递归的过程:

  • 控制从最顶层 (或者中间某一层) 的控件开始。顶层控件告知第二层控件总共有多少空间可用,并要求其计算自己打算使用多少。
  • 第二层控件根据自身度量逻辑对可用的空间大致做个分配,并同样要求下层控件计算自己需要的空间。控制逐渐深入,直到最深层的控件完成度量操作,将期望的尺寸返回给次深一层。
  • 次深一层的控件再根据自己的度量逻辑计算自己 (连同其包含的所有下层控件) 需要多少空间,并向上返回。控制又逐渐上浮,回到顶层。
  • 此时 Measure 过程结束,进入 Arrange 阶段。由于可用的空间与控件实际需要的空间可能不一致,顶层控件需要确定实际分配给第二层控件的空间与位置,并让每个第二层控件采取类似的方式,进一步将空间分配给下层控件。
  • 控制又继续深入,直到最深层的控件将自己安排好,将实际使用的空间返回给次深一层。多数情况下,控件应该使用最终分配的全部空间。
  • 次深一层控件也向上层报告自己实际使用的空间,控制又上浮。直到回到顶层,整个过程结束。如果实际分配给一个控件的尺寸与其期望不一致,就会发生伸缩的现象,具体行为由相应控件的分配逻辑决定。

打个不太完整的比方:

项目经理: 小李啊,你们组开发一个功能 A 需要多长时间啊?
小李: 等我问问组员啊。
小李:这个功能可以分为 A1, A2, A3 三个模块,你们一人开发一个模块,要多长时间?
程序员 A: 需要一周。
程序员 B: 需要两周。
程序员 C: 需要一周。
小李: 好。*(小李根据自己的度量逻辑,得出需要两周完成任务)*
小李: 经理啊,我们要两周来完成这个功能!
项目经理:知道了。那小王啊,这里有个功能 B, 你看看你们组要多长时间。
小王: 这个功能我分成 B1, B2 两个模块,你们要多长时间?
程序员 D: 需要一周。
程序员 E: 需要五天,但是我这个需要等 B1 开发结束才能开始。
小王: 那我向经理汇报。*(小王根据自己的逻辑,得出需要 12 天时间)*
小王: 我们组要 12 天。
项目经理: 好。*(此时度量阶段结束,进入分配阶段)* 那小李负责带好团队,两周内完成开发。
小李: A, B, C, 你们按自己定的时间安排好工作。
程序员 A, B, C: 行。
小李: 安排下去了。
项目经理: 小王这边…… 这个 B 也不复杂啊,怎么需要这么长时间…… 我要你 10 天完成。
小王: 啊?好…… 好吧……
小王: 上面卡得紧啊…… D, 你得提前一天完成。E, 你与 D 密切联系,差不多了就开始,不要等他全搞完,在第十天完成开发。*(利用自己的分配逻辑处理伸缩问题)*
程序员 D, E: 行吧。
小王: 保证完成任务。
项目经理: OK. (整个布局流程结束)

不难看出,布局过程牵涉大量运算。所以,尽可能避免度量与分配过程,是优化性能的关键。

对控件的度量与分配,是通过调用控件的 Measure(Size) 方法与 Arrange(Rect) 方法完成的。这两个方法定义于 UIElement 类,因此在每一个控件中都存在。这两个方法会分别调用自身的 MeasureOverride()ArrangeOverride() 方法,并做一些其它处理,如设置状态标记等。 MeasureOverride()ArrangeOverride() 则是真正处理布局逻辑的方法,派生类通过重写这两个方法,实现自身的布局逻辑。

派生类重写的 MeasureOverride()Measure() 返回一个尺寸数据。但 Measure() 方法本身无返回值,而是将返回的值存储在控件的 DesiredSize 属性中,供上层控件访问。同样地,Arrange() 方法也不直接返回值,而是将其存储在 ActualSize 属性中。

有一些变化,会使已经完成的布局失效。例如:

  • 添加或移除一个下层控件,可能会使部分或全部其它下层控件的位置失效。
  • 更改控件自身或某个下层控件的尺寸,可能会使部分或其它下层控件的尺寸与位置失效。

由于尺寸会影响位置,所以一旦某个控件的尺寸发生改变,就需要一轮完整的布局。而如果只是位置发生改变,则不会触发度量过程,以减少重复计算。另外,如果布局逻辑能够确定在某种具体的更改发生时,哪些下级控件的哪些布局属性不受影响,就可以避免对这些控件进行重复的度量或布局操作,进而提升性能。

非虚拟化布局控件

上面 Layout 中的方法 3, 4 便是处理 Measure 与 Arrange 过程的逻辑。这两个方法各自接受一个 LayoutContext 参数与一个 Size 参数。前者是为了让布局逻辑能访问状态与要布局的控件,后者是上层控件传入的分配尺寸。这两个方法都返回一个 Size, 即上面描述的实际需要或使用的空间。

根据这个机制,我们在 MeasureOverride() 方法中至少要做以下几件事:

  • 将上层提供的可用空间按照排列需求分给自身包含的每个控件,并调用这些控件的 Measure() 方法,对其进行度量。
  • 读取每个控件的 DesiredSize 属性,确定它们期望的空间。根据排列需求确定自己以及包含空间一共需要的空间总量,并将其返回。

ArrangeOverride() 中则至少需要:

  • 将上层实际分配的空间划分给包含的各个控件,并根据排列需求为其确定位置。调用控件的 Arrange() 方法。
  • 不必理会下层控件的空间使用量,直接返回上层分配的空间量。这是文档所推荐的。

实际上,对于不需要支持 UI 虚拟化的布局,这就是我们所要做的全部工作。不支持 UI 虚拟化的控件,会一次性地初始化,度量,与分配每一个下层控件,不论它是否会出现在屏幕上。这也是非虚拟化布局控件性能较差的原因。

对于这类布局控件,在 NonVirtualizingLayoutContext 类中存在 Children 集合,里面存放着已经初始化好的下级控件。我们只需要遍历这个集合,按需要的布局逻辑调用其 Measure()Arrange() 方法即可。

虚拟化布局控件

对于支持 UI 虚拟化的布局空间来说,情况则要复杂得多。UI 虚拟化的本质是按需初始化,度量与分配每个控件。如果一个控件在视图边界 (以及边界周围的一部分缓冲区) 以外,就不要初始化它。如果以前已经初始化的控件现在滑出视图边界,则将其回收,释放内存资源。与非虚拟化的布局控件相比,UI 虚拟化会减少内存占用,并按需延迟布局计算。不过回收控件元素的后果是一些回收掉的元素,日后可能需要重新进行初始化与布局。用计算资源换内存空间,是一种常见的牺牲。

在虚拟化布局控件中,每个元素都有四种状态,分别是未初始化、等待度量、等待分配、准备就绪。未初始化,指的是与集合内某一项对应的控件还没有创建并初始化或已经被释放;等待度量指控件已经创建并初始化,但还没有经过度量过程,或度量已经失效;等待分配指控件已经完成度量,但还没有分配,或分配的位置已经失效;准备就绪指控件已经完成整个布局过程。

虚拟化布局控件中元素的四种状态及转移

使元素布局属性失效的过程,是由系统自动控制的,我们一般不需要直接干预。(如果确有需要,可以调用控件的 InvalidateMeasure()InvalidateArrange() 方法) 度量与分配的过程,调用控件的 Measure()Arrange() 即可。而创建与回收,则需要用到 VirtualizingLayoutContext 中的两个方法:

  1. GetOrCreateElementAt(Int32, ElementRealizationOptions)
    创建集合中某一项对应的元素。如果这个元素已经存在,就直接返回。
  2. RecycleElement(UIElement)
    回收一项元素。

在 UWP 中,被回收的元素实际上并没有被马上释放,对应的控件被暂时存储在一个回收池里,只有当内存紧张时,才真正释放回收池里面的元素。当有其它元素需要初始化时,系统默认并不会马上创建一个新控件,而是优先利用回收池中既有的控件,在其基础上修改内容。这样就避免了创建与初始化控件的性能开销。通过设置方法 1 中的第二个参数,可以强制系统创建一个新的元素。

默认状态下,系统会在每次布局结束以后,将没有通过 GetOrCreateElementAt() 方法获取到的元素全部回收。在某些情况下,我们可能希望根据自己的布局逻辑,手动控制创建与回收的过程。通过设置方法 1 的第二个参数,我们也可以避免系统自动回收,达到完全控制创建与回收的目的。

当用户的视图边界发生变化 (例如,用户滚动内容或改变窗口大小), 或集合发生改变时,系统就会重置布局的尺寸与位置,重新开始一轮布局。这时,我们就需要:

MeasureOverride() 中:

  • 确定哪些元素会出现在视图边界中。视图边界 (包括缓冲区) 可以通过 VirtualizingLayoutContext 对象中的 RealizationRect 属性获得。
  • 得到对应的元素。得到的元素可能是新创建的,也可能上一轮布局留下来的。
  • 确定元素的尺寸是否还有效。如果失效,确定对这些元素分配的空间,并对这些元素调用 Measure() 方法。
  • 回收不会出现在视图中的元素。
  • 返回需要的空间总量。这里的总量,指的是所有控件的空间占用量,而不只是屏幕上元素的空间占用量。稍后将详细讨论。

ArrangeOverride() 中:

  • 确定哪些元素会出现在视图边界中。
  • 得到对应的元素。
  • 确定元素的位置是否还有效。如果失效,确定位置并调用 Arrange() 方法。
  • 返回上层控件传入的值。

但在上面步骤的具体实现中,存在不少实际问题:

  • 不初始化并度量每个控件,怎么知道哪些元素会出现在屏幕上呢?如果视图边界一开始在最上方或最下方还好,我们可以从一端开始依次度量并判断。但如果是从中间开始呢?例如,用户快速拖动滚动框的滑块,到中间一个尚未初始化与度量的位置。
  • 多数布局中,每个元素的位置都与其它元素的位置与尺寸相关。如果不初始化与布局每个元素,应该如何确定某一个元素的位置呢?
  • 不初始化并度量每一个元素,就不知道一共需要或使用了多少空间。这种情况下怎么向上层交代呢?尤其是,如果布局控件在一个滚动框里面的话,我们返回的尺寸值会决定滑块的长度。
  • 与第一条相似。有些更改会使布局失效。如果发生了这样的更改,我们又该从哪里开始重新布局呢?

解决这些问题的一种途径是估测。例如,在 Windows UI Library 内置的 StackLayout 中,布局逻辑通过当前已经度量过的所有元素的高度的平均值,来估计起始元素,位置,与总高度等信息。

不过估计总是不准确的。下面举一个常见的问题:

  1. 用户快速拖动滑块,视图进入了未初始化的区域。假设新视图边界位于 y = 10000 处。
  2. 假设已初始化的元素的高均为 200. 布局逻辑根据已初始化元素的平均值,估测 y = 10000 对应第 51 个元素,并从这里开始初始化。
  3. 用户逐渐向上翻动,布局逻辑开始初始化并测量上面的元素。
  4. 发现上面元素的高几乎均为 200, 但有一个高为 1000.
  5. 当用户翻到 y = 0, 即视图最上方时,发现有 1000 - 200 = 800 个单位的内容,即 4 个元素,没有出现。

为解决这个问题,VirtualizingLayoutContext 类中提供了 LayoutOrigin 属性,用于纠正因估计错误导致的布局原点的偏差。在一轮布局中,如果布局逻辑发现因为估计错误,导致使用估计值前的布局原点与使用估计值后产生的布局原点不一致,就可以设置这一属性为新的原点值,系统会在下一轮布局中做出纠正。

不过有些情况下,我们难以做出有效的估测。例如,在我们希望实现的瀑布流布局中,每一个元素的位置都与前一个元素的位置与尺寸有严格的相关性。这种情况下,要通过估测来确定要显示哪些元素以及这些元素的位置是相当困难的。这就需要我们围绕这些 API, 进行详尽的设计。关于我的设计思路,我将在下一篇中讨论。