夜雨聆风学习资料网

ARTICLE · 1079626

【Unity UGUI源码深度解析】04|CanvasUpdateRegistry源码解析:一帧中的布局、裁剪与图形重建如何调度

【Unity UGUI源码深度解析】04|CanvasUpdateRegistry源码解析:一帧中的布局、裁剪与图形重建如何调度

《UGUI源码深度解析》第 4 篇 · 界面小组工作日志基准:Unity 2022.3.62f2c1 / 本地 UGUI 1.0.0。人物与项目情节为虚构;源码机制以本地实现为准。

一、三个人同时改背包,谁先干活?

装备名变长,详情面板需要长高;面板长高,遮罩范围又跟着变化;最后,文字和背景才知道该准备什么网格。

阿澈问:“如果先画了,再排版,岂不是白忙一遍?”

这正是 CanvasUpdateRegistry 值得单独读一篇的原因。它不负责计算每个字的位置,却决定什么工作先发生。上一回的 Dirty,只是把需求交给了这个调度者。

构造函数把 PerformUpdate 挂到 Canvas.willRenderCanvases。这里应理解为渲染前更新入口,而不是死记“每帧严格执行一次”。主动刷新 Canvas 等操作会影响调用时机,查问题时必须结合栈和帧号。

二、不是一个大队列轮流抽签

注册表持有布局队列和图形队列,元素通过 ICanvasElement 暴露 Rebuild、完成通知与销毁检查。布局队列还会先排序:比较 Transform 的父节点数量,让浅层根节点排在深层之前。

“这就等于布局从父到子了?”

还不能这样概括。这里只是不同队列元素之间的排序。单个 LayoutRebuilder 内部如何遍历子树,是另一层算法;它的测量恰恰需要先了解孩子。队列排序与子树遍历不能混成一句话。

PerformUpdate 的布局阶段循环摘录如下:

for(int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)

这覆盖 Prelayout、Layout、PostLayout。随后发出 LayoutComplete,清空布局队列,再执行 ClipperRegistry.instance.Cull。最后处理 PreRender、LatePreRender,发出 GraphicUpdateComplete 并清空图形队列。

五个阶段不意味着每个组件必须执行五份逻辑。Graphic 主要在 PreRender 更新网格与材质,LayoutRebuilder 主要响应 Layout,ScrollRect 又有自己的阶段处理。阶段是协议,不是平均分工。

三、为什么裁剪夹在中间?

我把装备详情的矩形画在纸上。文字换行后,面板高度才能确定;高度确定,才知道哪些内容在视口里;可见性确定,再准备要提交的图形数据。

布局之后裁剪,避免拿旧矩形决定可见区域。裁剪之后更新图形,也让 Graphic 能根据 cull 状态跳过部分工作。这里存在依赖关系,不是源码作者为了整齐把函数排成三段。

但 Cull 不会删掉列表项,也不是完整 GPU 遮挡剔除系统。它调度已注册的裁剪组件;具体矩形求交、目标通知等工作仍由 RectMask2D 等类型完成。

四、重复登记能合并,迟到登记有风险

队列使用 IndexedSet 维护唯一性。图形注册最终调用 AddUnique,同一元素重复标脏通常不会在同一队列里排成一长串。布局根相同的请求也可以汇合。

阿澈想到了一个主意:“那我在重建时发现还有事,再登记一次?”

先看这个完整源码分支:

if(m_PerformingGraphicUpdate){    Debug.LogError(string.Format("Trying to add {0} for graphic rebuild while we are already inside a graphic rebuild loop. This is not supported.", element));    return false;}

图形重建期间不支持重新注册。写 OnPopulateMesh 时顺手修改另一个 Graphic 的尺寸或颜色,可能就撞到这里。标记还在不在、队列有没有接受,必须分开检查。

更容易读错的是布局注册:本版本对应的 m_PerformingLayoutUpdate 检查被包在注释中,不能写成“两个队列都严格禁止重建期间注册”。不过这也不是邀请我们依赖重入。新元素可能错过排序或部分阶段,末尾还会清队列,延迟到下一次稳定更新更容易推理。

五、待办里的对象已经销毁怎么办?

CleanInvalidItems 在更新前清理空条目与 IsDestroyed 返回 true 的元素。通过接口持有 Unity 对象时,普通判空不足以代替原生对象存活检查,这与第 2 篇的 IsDestroyed 接上了。

调用单个元素 Rebuild 时还有异常捕获,错误会记录对应对象,避免一个异常直接打断整个循环。然而“调度循环继续”不等于“坏掉的组件自动恢复”。异常前写入了一半状态,依然需要我们修复。

这也解释了排查布局错误时为何应先清 Console:一个尺寸没有更新,可能是前面的计算抛了异常,而不是排序不够智能。

六、按阶段看一次长名字

准备 VerticalLayoutGroup、自动换行 Text 和受控宽度,设置断点在 PerformUpdate、LayoutRebuilder.Rebuild、RectMask2D.PerformClipping、Graphic.Rebuild。改变文本,记录调用顺序与阶段值,观察高度是在图形提交之前确定的。

再对同一 Image 连续标脏,检查图形队列中的实例是否重复。最后在独立测试组件中尝试重建时注册,观察错误。

以上是可复现方案,Profiler 能辅助观察成本,但不能把一次断点暂停后的耗时当成真实性能。

阿澈把今天的结论写成一行:“先量房,再划范围,最后备料。”

顺序清楚了,下一篇换个问题:换了一块屏幕,整个背包为什么忽大忽小?我们去看 CanvasScaler。

本篇源码索引

  • CanvasUpdateRegistry:./com.unity.ugui@1.0.0/Runtime/UI/Core/CanvasUpdateRegistry.cs
  • LayoutRebuilder:./com.unity.ugui@1.0.0/Runtime/UI/Core/Layout/LayoutRebuilder.cs

相关学习资料