你这怎么一个月都不见动静,啥情况啊?

害,我还活着。就是整天考试 + 课设,顶不住了。今天算是闲下来了,我就给大家带来瀑布流系列的第三部分。

上一篇中,我们了解了 UWP 布局 API 的相关机制。通过上一篇中的讨论,我们不难发现,实现布局逻辑的关键实际上就是实现相应的度量与分配逻辑。在这一篇中,我将介绍我如何设计布局逻辑,以达到布局效果。

什么是瀑布流布局?

Imgur 上的瀑布流布局

瀑布流布局最明显的特征就是每个元素的宽度取定值,但高度不相等,给人一种错落有致的感受。这一特征确定了元素的度量特性:宽度确定,高度不确定。

在位置分配方面,如果仔细观察,我们会发现,瀑布流布局中,下一个元素会被放在当前最高度最低的一列,使得各个列尽可能高度平衡。换句话说,各个元素上沿的纵坐标符合升序排列。这就意味着,在瀑布流布局中,元素的位置与前面各个元素的高度有关。

然而,与简单的网格布局不同,前面元素的高度在编译时是完全无法确定的。而且,与列表布局不同,瀑布流布局有多列,考虑到不同列之间的元素有严格的相对位置关系,我们也不能通过估测的方式来确定前面元素的高度。因此,我们必须在运行时获知前面所有元素的尺寸,并依据这个尺寸确定下个元素的位置。

要想知道前面元素的尺寸,显然的方法是依次度量前面元素的尺寸。不过这样就意味着我们需要创建并度量前面的每一个元素,UI 虚拟化就被架空了。因此,我们需要将已经度量过的尺寸结果缓存下来,并根据这些缓存数据决策。

通过度量元素本身,我们就能够确定元素本身的尺寸。而根据前面各个元素的尺寸缓存,我们就能够确定元素的位置。确定了尺寸与位置,布局逻辑即告完成。

实现 UI 虚拟化

UI 虚拟化的核心就是按需创建,度量与分配 UI 元素,并将不显示在屏幕上的元素回收,以降低内存开销。因此,我们在布局时还必须确定,哪些元素会被显示在屏幕上。在上一篇我们了解到,VirtualizingLayoutContext 具有 RealizationRect 属性,可以获知当前屏幕显示 (以及缓冲区中) 的区域。我们需要得知区域中有哪些元素。有了上面提到的尺寸缓存,问题就变得简单了。我们可以根据各个元素的尺寸进行一次模拟布局,并依此计算落在区域中的元素。因为尺寸是已知的,这样的模拟布局不需要创建 UI 元素,更不需要对元素进行度量,因此性能影响要小得多。不过为了尽可能地减少运算,我这里还是将位置也缓存下来,并根据缓存的位置计算。

我将一个元素 “在显示区域内” 定义为 “元素的上沿在区域内”. 正如我们上面所说,元素上沿的坐标是自然排好序的,因此以元素上沿来判断元素是否在区域内,将能简化我们的逻辑:确定第一个上沿在区域内的元素与第一个上沿超出区域的元素,这二者之间的元素必然都在区域内。当然,这种判断逻辑意味着区域最上面可能会有一部分空白,但由于我们从 RealizationRect 属性中获得的区域包括缓冲区,因此这部分空白在多数情况下是不可见的,除非某个元素高度大于缓冲区的高度。缓冲区的默认高度是一个屏幕,是一个相当高的值,而元素过高本身也不是什么好的 UI 设计。

了解了屏幕上将会显示哪些元素,我们就可以将这些 UI 元素创建出来,并进行度量与分配,使其真正显示在屏幕上。在显示区域以外的元素,我们就可以进行回收,以便节约内存。

什么时候更新缓存?

不难发现,要实现这一布局逻辑,关键在于尺寸缓存与位置缓存,我们必须保证这些缓存在布局时处于最新的状态。什么时候需要更新这些缓存呢?在讨论之前,我们先考虑以下什么情况下需要对元素进行度量与分配。

我们在上一篇中提到,对于不支持 UI 虚拟化的控件,元素的度量与分配是一次性完成的。这意味着,只要 ItemsRepeater 本身的尺寸没有发生变化,元素的度量结果就始终有效;只要集合的内容没有发生改变,元素的位置分配结果就始终有效。因此,对于不支持 UI 虚拟化的控件,布局逻辑在且仅在视图尺寸发生变化或集合内容出现变更的时候被触发。我们只需要在布局逻辑中依次度量与分配每一个元素即可。

对于支持 UI 虚拟化的控件,情况则更为复杂一些。显然,与非虚拟化布局控件一样,如果视图尺寸或集合内容发生变化,元素的度量与/或分配结果自然也会失效。但由于在虚拟化布局控件中,UI 元素随着用户的滑动按需生成,因此,用户滑动也会触发度量与分配过程。当然,与非虚拟化布局控件不同,在虚拟化布局控件中,我们应该只度量与分配需要显示的元素,而非所有元素。

另外,对于任何布局控件,还有另外一种情况会使布局失效,即布局控件自身的属性发生了改变。这些属性可能包含行间距、列间距、内部留白等。总而言之,在支持 UI 虚拟化的布局控件中,触发布局逻辑的条件有以下四种:

  • 用户滑动视图,使得屏幕上要显示的元素发生改变
  • 集合的内容发生了改变
  • 用户调整窗口尺寸等,使得视图的大小发生变化
  • 布局控件属性发生了变化

视图滑动

我们首先考虑视图滑动的情况。不难想到,这种情况不会造成任何既有的尺寸与位置失效。但可能会出现之前未度量/分配过 (或先前度量/分配已失效) 的新元素。这种情况下,对每个元素,我们需要以下的逻辑:

  • 如果元素的布局信息在位置缓存内,则根据位置缓存确定它是否在显示区域内。
    • 如果在,判断是否还有对应的 UI 元素 (没有被回收).
      • 元素存在,则对其进行分配,将其显示出来。这代表上一轮被显示出来,这一轮仍然显示的元素。
      • 若不存在,创建对应的 UI 元素,对其进行度量与分配,将其显示出来。这代表上一轮没有显示,这一轮新显示的元素。
    • 如果不在,判断对应的 UI 元素是否已被回收。
      • 已经回收,则无需进行任何操作。这代表上一轮未被显示,这一轮仍不显示的元素。
      • 尚未回收,则将其回收。这代表上一轮被显示出来,这一轮不显示的元素。
  • 如果不在位置缓存内,则
    • 判断其是否还在尺寸缓存中。
      • 如果在,则无须计算尺寸。这意味着元素的位置缓存失效。
      • 如果不在,则
        • 创建对应的 UI 元素。
        • 对其进行度量,将度量结果缓存下来。这意味着元素首次被度量,或元素的位置与度量缓存都失效。
    • 计算其位置,将位置结果缓存下来。
    • 根据刚刚计算的位置确定其是否在显示区域内。
      • 如果在,对刚创建并度量的 UI 元素进行分配,将其显示出来。
      • 如果不在,将刚创建的元素回收。

这种情况下,经过这些逻辑,我们便完成了更新缓存与显示元素的任务。这是最常发生的情况。

集合变更

集合变更分为以下几种:

  1. 添加/移除某个元素
    这种情况下,任何既有/剩余元素的尺寸仍然有效,但从变更位置起后续的所有元素的位置将失效。
  2. 替换某个元素
    等同于移除原来的元素,再在原位置添加新元素。
  3. 移动某个元素
    等同于移除原位置的元素,再将元素添加到新位置。
  4. 清空整个集合
    全部元素的尺寸与位置都失效。

我们上一篇中提及了 VirtualizintLayoutContext 中可供重写的 OnItemsChangedCore() 方法。这个方法便是用于处理集合改变时要执行的逻辑。我们需要重写这个方法,在方法内维护好缓存的状态。此外,我们还需要启动新一轮布局,以反映集合元素的变化。

视图尺寸变化

视图尺寸变化分为纵向变化与横向变化。其中纵向变化相对简单些,所有既有元素的布局信息都继续有效,本质上与视图滑动一致。而当视图尺寸发生横向变化时,由于列的数量与宽度都可能发生变化,因此元素的尺寸与位置都将失效。

为了判断是否发生了视图尺寸的横向变化,我们需要将上一次布局时视图的尺寸记录下来。在下一次布局时,与记录的值进行比较。如果发生了变化,则说明所有元素的尺寸与位置缓存都将失效。

布局控件属性变化

本人在瀑布流布局控件中定义了五个属性。这五个属性与我在本系列最开始提到的 Windows Community Toolkit 中 StaggeredPanel 控件的属性是一致的。

  • ColumnSpacing
    各列之间的留白
  • RowSpacing
    一列中上下两个元素间的留白
  • HorizontalAlignment
    指定各列横向如何对齐
  • DesiredColumnWidth
    列的目标宽度
  • Padding
    布局的内部留白

需要注意的是,如果 HorizontalAlignment 属性取 Stretch 值,控件将调整各列的宽度,使得全部宽度被填满。有关这个调整的细节,我将在下一篇中详述。

不难发现,如果 RowSpacing 发生改变,则各个元素的位置将会失效,但元素尺寸不会发生变化。如果 HorizontalAlignment 发生改变,则各个元素的位置将会失效;且如果值的变化在 Stretch 与非 Stretch 值之间,元素的尺寸也会失效。当其它属性发生改变时,元素的位置与尺寸都会失效。同样地,当这些属性发生变化时,我们也需要重新进行一轮布局,以反映变化。

在注册控件的这些依赖属性时,我们可以指定一个回调方法。当属性的值发生变化时,回调方法就会被执行,我们便可以清除相应的缓存。关于依赖属性的详细信息,可以参考文档

当这一切都结束

通过上面的一系列逻辑,我们其实是完成了以下任务:

  1. 如果一个元素没有有效的尺寸缓存,那么度量这个元素,并将结果缓存下来。
  2. 如果一个元素没有有效的位置缓存,那么计算它的位置,并将结果缓存下来。
  3. 如果一个元素在视图内,则将其显示出来;否则,将元素回收。
  4. 如果集合发生了改变、视图尺寸发生了改变、布局控件的属性发生了改变,那么就清除受影响而无效的位置与尺寸缓存。
  5. 通过前三项,当下次布局时,被清除的缓存又会被重新计算,进而得到更新。

通过这五项工作,我们可以确信,每一个元素都有正确的尺寸与位置,并正确地被显示与回收。在下一篇中,我将讲述我如何具体编码实现这样的逻辑。此外,我还将介绍一些杂项内容,例如,如何将此类 UWP 控件打包为 NuGet 包,以便其他开发人员使用。