从一次列表滚动卡顿出发,重新思考 UI系统中哪些结果值得缓存,以及矩形裁剪是否需要经过离屏绘制。

从一个滚动列表说起

在开发 UI系统时,我遇到了一个不太符合直觉的问题:一个包含六七十个控件的设置列表,滚动起来却有明显卡顿。

控件数量并不算多,界面也没有复杂的视觉效果。检查实现后,我开始把注意力放到每帧重复执行的工作上:没有变化的控件,仍然重新计算布局;没有变化的内容边界,仍然重新计算滚动范围;只是想把内容限制在一个矩形区域里,却先画进离屏纹理,再画回窗口。

这些做法在功能实现阶段都很自然。每帧重算可以少考虑状态同步,离屏纹理也容易实现容器裁剪。但当它们同时出现在一个有嵌套和滚动的界面里,额外工作就会不断累积。

这次让我印象最深的,是两个设计上的调整:让布局结果在有效时继续使用,以及让矩形裁剪直接发生在最终绘制目标上。

Dirty Flag:给计算结果一个有效期

原来的布局逻辑,每个逻辑帧都会根据控件配置重新计算矩形。无论位置、尺寸和父级环境有没有变化,都重新得到一遍相同的结果。

我后来用 Dirty Flag,也就是脏标记,记录缓存是否失效。位置或尺寸发生变化时,先标记结果需要更新;真正进入布局阶段后,再重新计算并清除标记。没有变化的控件,就继续使用上一次的结果。

修改布局配置 → 标记 dirty → 更新阶段重算 → 缓存恢复有效

这里的 dirty 表示“结果可能已经过期”。setter 不需要立刻完成所有关联计算,只需要保证下一次使用结果前,系统知道它需要被检查。

这也把更新的时机集中了起来。一个控件在同一轮操作里连续调整位置和尺寸,可以等到布局更新时统一计算,避免每改一个属性就重新算一遍。

布局和内容范围,需要分别失效

在 UI系统中,我把布局相关的失效分成两个层次:DirtyLayout 对应控件自身的矩形,DirtyContentBounds 对应容器的内容边界与滚动范围。

这两个结果有关联,但并不总是一起变化。一个子控件移动后,要先计算它的新矩形;如果实际边界发生了变化,父容器的内容范围才需要重新检查。滚动位置改变时,如果内容在容器内部的布局没有变化,也不必重新生成整套布局。

把它们分开后,我更容易判断一次修改究竟影响了什么。标记的划分应当对应缓存结果的依赖,而不是把所有属性变化都归入一个笼统的“需要更新”。

自己没变,依赖也可能变了

实现脏标记时,一个容易漏掉的情况是父控件尺寸变化。

例如,子控件按父级宽度的比例定位。子控件没有调用任何 setter,但父级变宽后,它原来的矩形就已经失效。如果只检查子控件自己的标记,这种变化会被忽略。

所以布局更新还要检查父级尺寸是否改变、是否已经针对这个父级环境计算过,以及是否收到强制更新要求。计算结束后,再比较新旧矩形,把实际发生的边界变化继续传给父容器。

这个过程让我意识到,缓存的有效性取决于完整的依赖关系。控件自身的配置只是其中一部分。

保留遍历,也能减少大量重算

当前 UI系统没有为每个控件保存父指针,子控件修改后无法立即反向通知父级。我保留了每个逻辑帧的一次轻量遍历:访问子控件,检查它是否需要更新,再把变化结果逐层返回给祖先容器。

因此,每帧访问 UI 树仍然是 O(N),N 是控件数量。减少的是其中的布局计算与内容范围重建。对当前规模来说,这种方式也更容易沿用已有的更新流程。

容器内容范围可以进一步延迟计算:先记住旧结果已失效,等到需要限制滚动位置时再重建。这样,“发现变化”和“计算新结果”就有了各自清楚的时机。

最难的部分,是别漏掉变化

加入几个标记本身并不复杂,真正需要仔细考虑的是所有修改入口。

比如,删除子控件后,被删除的对象已经无法在下一次遍历中报告变化。因此,容器还需要记录结构是否改变,让插入、删除和清空操作也能使内容范围失效。

如果绕过容器接口,直接修改底层列表,就可能漏掉这个通知。同样,复制一个控件到新的父级环境后,也不能直接沿用原对象的布局缓存和“已经有效”的状态。

这些问题都指向同一件事:维护缓存时,必须能解释每一种变化如何被发现。只要存在一个未被追踪的修改入口,界面就可能继续使用过期结果。

图像裁剪:直接限制最终绘制区域

布局之外,另一个调整是容器的矩形裁剪。

原来我用 sf::RenderTexture 实现它:先把子控件画到与容器大小相同的离屏纹理里,再把这张纹理作为 Sprite 画到窗口。超出纹理范围的内容自然不会显示,思路很直观。

但列表中的容器和输入框都经过这样的路径时,会增加中间渲染目标、切换绘制目标的工作,以及最后的纹理合成绘制。频繁创建这些资源也会带来额外分配开销。

重新看这个需求后,我发现这里主要需要的是:允许内容继续按照自己的坐标绘制,但只让容器范围内的像素写入目标。

SFML 3 的 sf::View::setScissor() 可以表达这个限制。设置矩形裁剪区域后,子控件直接绘制到最终 RenderTarget,区域外的像素不会修改目标。官方文档:View::setScissor

对于这种轴对齐的矩形裁剪,绘制路径因此可以简化为:

设置有效裁剪区域 → 直接绘制子控件 → 恢复原来的裁剪状态

离屏纹理仍然适合需要中间图像的用途,比如整体后处理或复用已经渲染的内容。在这次 UI系统的矩形裁剪中,我可以省掉这个中间步骤。

绘制位置与可见范围,是两件事

实现滚动时,我把绘制偏移和裁剪范围分开考虑。

drawOffset 负责把控件的局部坐标转换到最终绘制坐标,clipArea 表示当前层级允许写入的区域。滚动改变了子控件画在哪里,容器的可见窗口则仍由裁剪范围决定。

这在嵌套容器中尤其有用。子容器的有效裁剪范围,应当是它自身范围与父级有效范围的交集。即使内容没有超出子容器,只要已经超出父容器,也不能继续显示。

求交前,两个矩形必须转换到同一种坐标空间。局部坐标、经过 view 变换的坐标和目标像素坐标混在一起,很容易出现“位置看起来对,裁剪却偏了一块”的问题。

像素矩形,还需要转换成比例

setScissor() 接收相对于整个渲染目标尺寸的归一化矩形。假设求交后的像素区域为 (x, y, width, height),目标尺寸为 (W, H),转换关系是:

position = (x / W, y / H)
size     = (width / W, height / H)

这些除法应使用浮点运算。转换前还需要把区域限制在目标范围内,并处理空交集与零尺寸目标。如果绘制使用了 view 缩放或平移,则先把控件范围映射到目标像素空间。官方文档:裁剪矩形的坐标定义

scissor 只负责限制像素写入,不会自动取消控件遍历或布局计算,也不会替我们跳过所有不可见对象的绘制调用。这些仍是各自独立的优化问题。

临时绘制状态,要有明确的生命周期

嵌套裁剪还有一个容易忽略的细节:画完子容器后,需要恢复进入它之前的 view。

如果裁剪状态一直留在渲染目标上,后续控件也会被截掉。层级越深,手动设置与恢复就越容易遗漏。

我用 ScopedScissor 把这段状态变化放进一个作用域:进入时保存原 view 并设置新的裁剪范围,离开时自动恢复。这样代码的嵌套关系也对应着裁剪状态的嵌套关系,读绘制流程时更容易确认当前范围。

这次实践改变了我看待 UI 更新的方式

以前实现界面时,我更关注“结果能不能正确画出来”。每帧重算、每个容器单独做离屏绘制,都是容易把功能跑通的选择。

这次整理后,我开始更关注结果的使用周期:一个布局矩形能有效多久,一次修改会影响哪些容器,以及绘制这个效果究竟需不需要一张中间图像。

Dirty Flag 让我保留仍然有效的计算结果,scissor 让我把矩形裁剪直接放到最终绘制路径中。两者分别减少了更新阶段和绘制阶段的重复工作,也让我更清楚地看到 UI系统中的依赖关系。

以后再增加缓存,我会先想清楚它依赖什么、如何失效、在哪里重建。再增加绘制步骤,我也会先确认这个步骤是否提供了当前效果真正需要的东西。

这些判断比某个具体接口更值得留下。它们决定了功能逐渐变多之后,UI系统还能不能保持清楚、可靠,并且避免无谓的工作。