源码位置:Library\PackageCache\com.unity.ugui@bb329a87fcdc\Runtime\UGUI\UI\Core
CanvasUpdateRegistry.cs
CanvasUpdateRegistry
把UI 重建分成 5 道工序按顺序走。前 3 道管布局,后 2 道管图形。
public enum CanvasUpdate
{
Prelayout = 0, // 布局前
Layout = 1, // 正式布局
PostLayout = 2, // 布局后PreRender = 3, // 渲染前
LatePreRender = 4, // 渲染前的最后一道MaxUpdateValue= 5 //它自己不是工序,只是拿来当循环上界的("一直干到 5 为止")。
}
谁想进我的队列,谁就必须实现这 5 个方法。public interface ICanvasElement
{
void Rebuild(CanvasUpdate executing); // 干活:按当前工序重建
Transform transform { get; } // 你是哪个物体上的
void LayoutComplete(); // 通知:布局做完了
void GraphicUpdateComplete(); // 通知:图形做完了bool IsDestroyed(); // 你还活着吗
}
private static CanvasUpdateRegistry s_Instance; // 唯一的那个实例private bool m_PerformingLayoutUpdate; // 正在做布局?
private bool m_PerformingGraphicUpdate; // 正在做图形?
private string\[\] m_CanvasUpdateProfilerStrings = ...; // Profiler 里显示的名字
private const string m_CullingUpdateProfilerString = "ClipperRegistry.Cull";
private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<...>();
private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<...>();
IndexedSet<T>就是带索引的去重集合(Set) ,和普通HashSet<T>核心一样:同一个元素只能存一份,重复 Add 不会新增多条记录
HashSet<T>:去重 ;不支持下标索引访问 ,不能set[0]、set[1];只能枚举。IndexedSet<T>(UGUI 自己实现的,不是 .NET 原生类):
- 底层一般同时维护一个
List<T>+ 一个HashSet<T>- 去重(HashSet 负责判重)
- 支持
[index]按索引取元素- 可以按顺序遍历、随机访问,同时保证不重复
- 内部是 UGUI 为布局、裁剪队列量身定做的容器
这就是"一帧改 100 次只重建 1 次"的源码依据------队列去重。
Canvas.willRenderCanvases += PerformUpdate;
"Unity,你每次要渲染 UI 之前,记得叫我一声 ,我要跑
PerformUpdate()开工。"
+=是 C# 的事件订阅语法------不是赋值,是"把我的方法挂到你的广播列表上"。UI 的所有重建,时机是统一的 :不是"改一次就重建一次",而是"渲染前统一处理一次"。这就是延迟 + 批量的设计0。
一个 C# 的经典坑想判断的是:这个 UI 元素(ICanvasElement)还活着、没被 Destroy,可以安全更新
我们分两个 "空",这是最关键的:
- C# 托管引用空(普通的 null) 变量压根没指向任何东西,
element == null。这个是 C# 原生的空。- Unity 特有的 "假空" 你调用
Destroy(graph)之后:
- C# 这边
element这个引用不是 null(还有一个 C# 壳子)- 但是底层 C++ 的 UI 对象已经删掉了,一碰就报错 MissingReference
因为 Unity 的对象(GameObject/Component)被销毁后,C# 对象引用不是真的 null ,而是一个"壳"------直接
== null判断会失效。Unity 自己重载了
==运算符来处理这个。但问题是:当你的变量类型是
ICanvasElement(接口)时,C# 不会去走那个重载,只会做普通的引用判空------判断就不准了。所以必须先强转回
Object类型,再用!= null,这样才能走到 Unity 的重载逻辑。
(开工前先扔垃圾)为什么
i--(倒着遍历)?因为要一边遍历一边删。
正着删会出问题:你删了第 3 个,后面的元素全往前挪一位,你的下标就错位了,会漏掉元素。
倒着删就没这问题------后面被删的元素不影响前面还没处理的下标。这是个通用编程技巧。
整个类的心脏private void PerformUpdate()
{// ────── 第①步:布局开始 ──────
//告诉 Profiler"布局这段开始计时了"
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);//扔垃圾
CleanInvalidItems();m_PerformingLayoutUpdate = true;
// 按层级深浅排序,父节点少的排前面
m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
// ────── 第②步:跑布局的三道工序 ──────
// Prelayout = 0, Layout = 1, PostLayout = 2,
for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
{
UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStringsi);for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
{
var rebuild = m_LayoutRebuildQueuej;
try
{
if (ObjectValidForUpdate(rebuild))
rebuild.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, rebuild.transform);
}
}
UnityEngine.Profiling.Profiler.EndSample();
}双层循环,顺序是"工序在外、包裹在内"
工序 0(Prelayout):包裹1 → 包裹2 → 包裹3 → ...
工序 1(Layout) :包裹1 → 包裹2 → 包裹3 → ...
工序 2(PostLayout):包裹1 → 包裹2 → 包裹3 → ...注意 :不是"包裹1走完三道工序再处理包裹2",而是所有包裹一起走完第一道,再一起走第二道。
为什么?因为工序之间有依赖------必须先让所有元素都"收集完需求",才能开始"分配空间"。
包裹 = 一批 UI 元素打包成的一个分组单元。
// ────── 第③步:布局收尾 ──────
for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
m_LayoutRebuildQueuei.LayoutComplete();m_LayoutRebuildQueue.Clear();
m_PerformingLayoutUpdate = false;
UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout);**逐个发"布局做完了"的通知,然后清空队列。** 因为队列是去重的,处理完就得清干净,不然下一帧会重复处理。
// ────── 第④步:裁剪(夹在中间!)──────
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Render);// now layout is complete do culling...
UnityEngine.Profiling.Profiler.BeginSample(m_CullingUpdateProfilerString);
ClipperRegistry.instance.Cull();
UnityEngine.Profiling.Profiler.EndSample();位置很关键 :夹在
m_PerformingLayoutUpdate = false和m_PerformingGraphicUpdate = true之间。为什么必须在这儿?
- 布局刚算完 → 拿到最新的位置和大小 → 才能准确判断谁在视口外
- 赶在顶点生成之前剔除 → 否则顶点白算
注意:它虽然在布局之后执行,但被
BeginSample(Render)包着 → Profiler 里 Cull 的耗时算在 Render 曲线里。m_PerformingGraphicUpdate = true;
// ────── 第⑤步:跑图形的两道工序 ──────
for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)
{
UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStringsi);
for (var k = 0; k < m_GraphicRebuildQueue.Count; k++)
{
try
{
var element = m_GraphicRebuildQueuek;
if (ObjectValidForUpdate(element))
element.Rebuild((CanvasUpdate)i);
}
catch (Exception e)
{
Debug.LogException(e, m_GraphicRebuildQueuek.transform);
}
}
UnityEngine.Profiling.Profiler.EndSample();
}// ────── 第⑥步:图形收尾 ──────
for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
m_GraphicRebuildQueuei.GraphicUpdateComplete();m_GraphicRebuildQueue.Clear();
m_PerformingGraphicUpdate = false;
UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Render);
}发完工通知 → 清空 → 摘牌 → Profiler 计时结束。
代表什么
**父布局先算,子布局后算。** 因为父的尺寸决定子能分到多少空间,必须先算上层。
隐藏开销 :
ParentCount是 O(深度) 的循环,而排序要比较 O(n log n) 次 → 层级越深、元素越多,光排序就不便宜。这又是一个"层级要浅"的论据。
队列是 IndexedSet(去重) → 一帧改 100 次只重建 1 次 → 批量刷新优于逐帧刷新
顺序是:布局三档 → 裁剪 → 图形两档 → 裁剪夹在中间是依赖关系逼出来的(要用最新矩形判断、要在顶点生成前剔除)
布局排序、图形不排序 → 布局有依赖(父决定子),图形无依赖(位置已定)
Graphic.cs
| 问题 | 答案 | 源码出处 |
|---|---|---|
| ① 改 color 走哪条脏路径? | 顶点脏 SetVerticesDirty() |
color setter |
| ② 顶点脏/材质脏是两条队列吗? | 同一条队列 ,靠两个布尔位区分;布局脏走 LayoutRebuilder | 三个 SetXxxDirty() |
③ Rebuild 只在 PreRender 档干活? |
是,switch 只有 case PreRender |
Rebuild() |
| ④ 被剔除的元素还生成顶点吗? | 不生成 ,cull 检查直接 return,脏标记欠着等 OnCullingChanged 补做 |
Rebuild() + OnCullingChanged() |
| ⑤ 描边/阴影怎么叠上去? | IMeshModifier 链在同一份顶点数据上加三角形,不是多画一层 |
DoMeshGeneration() |
⑥ OnEnable 会全量标脏吗? |
会,SetAllDirty() |
OnEnable() |
public virtual Color color { get { return m_Color; }
set
{
// SetColor 内部:先对比新值和旧值,不一样才赋值,返回true
if (SetPropertyUtility.SetColor(ref m_Color, value))
SetVerticesDirty(); }
}
set 里调用了 SetVerticesDirty() ------ 改颜色走的是"顶点脏"这条路,不是材质脏。
改颜色 = 顶点脏 = 要重新生成网格(重算顶点色)= 必然
SetMesh= 原生层必然重新拼批次。Color.Lerp 做呼吸渐变(问题来了)
呼吸灯渐变很贵的根源:每帧都命中脏标记,触发网格重建
两种改色的两种方案 本质差异
修改 Graphic.color(顶点色,会 SetVerticesDirty)
Color lerpCol = Color.Lerp(c1, c2, t);
image.color = lerpCol;
material.SetColor(Uniform,不碰顶点、不标记 VerticesDirty)
需要一个简单自写 UI Shader,增加一个
_Tint变量,不用顶点色,用 Uniform 控制整体颜色
方案 1 Graphic.color方案 2 mat.SetColor数据落点 顶点色(vertex color,写进 mesh 顶点) Shader Uniform(每次 drawcall 传一个 vec4) 是否 dirty 顶点 是 → SetVerticesDirty()→UpdateGeometry→OnPopulateMesh+canvasRenderer.SetMesh()否 是否重算 Canvas batch 是(触发 Canvas.BuildBatch)否 是否打断合批 否(仍用 defaultGrap hicMaterial,靠顶点色区分,可合批) 是(一旦材质实例不共享,就拆成独立 drawcall) 每帧 CPU 开销 重建该 Graphic 的 mesh + BuildBatch 一次 SetColor,几乎为 0 "Uniform 比顶点色便宜",但代价从 CPU 重建转移到了 DrawCall
注意:直接
image.material.SetColor(...)会污染全场景
Graphic.material默认返回的是共享材质 (defaultGraphicMaterial或图集材质)。直接 SetColor 会让所有用到它的 UI 一起变色,改到磁盘 material asset 上还会落盘污染。必须先实例化一份:而方案 1 反而不打断合批(材质还是默认的,颜色靠顶点色区分)。所以:
- 1 个呼吸灯 :方案 1 的真实开销只是重建 4 个顶点的 quad,通常 < 0.05ms ,大概率根本不值得优化------先开 Profiler 看
Canvas.BuildBatch再决定。- N 个同步呼吸灯(同色) :方案 2 完胜------共享同一份材质实例 ,一次
SetColor全变,仍是 1 个 drawcall,零重建。这才是方案 2 最香的场景。- N 个异步呼吸灯(各闪各的):方案 2 会变成 N 个 drawcall,这时候该走下面的方案 3。
更彻底的做法:把呼吸搬进 Shader,CPU 每帧零开销
既然走 uniforms 了,就别每帧从 C# 推数据了------让 GPU 自己算:
// 关键 frag 片段(注意保留原 UI shader 的 stencil/clipping 代码) _BreathSpeed ("Speed", Float) = 2.0 _BreathMin ("Min", Float) = 0.4 _BreathMax ("Max", Float) = 1.0 float k = 0.5 + 0.5 * sin(_Time.y * _BreathSpeed + IN.phase); half4 c = tex2D(_MainTex, IN.texcoord) * IN.color; c.rgb *= lerp(_BreathMin, _BreathMax, k);相位怎么做成 per-instance 又不打断合批? 把每个元素的随机 ID 预先写进
uv2(或复用顶点色的某个通道),shader 里IN.uv2.x * 6.283当相位偏移。这样所有呼吸灯共享同一份材质实例,可合批、零 CPU 每帧开销、还各自闪各自的。
三个脏标记
怎么看出来的
- 顶点脏和材质脏,调的是同一个方法 :
RegisterCanvasElementForGraphicRebuild- 布局脏完全不同 :转交给
LayoutRebuilder.MarkLayoutForRebuild,根本不进那两条队列本体代表什么
顶点脏和材质脏是"一条队列 + 两个布尔位" ,靠
m_VertsDirty/m_MaterialDirty区分"找到它之后干什么"。布局脏走的是另一条路 (
LayoutRebuilder)

两个关键细节
① canvasRenderer.cull 检查在最开头
被矩形遮罩剔除的元素,连顶点都不生成,直接 return。
这就是 RectMask2D "省 CPU"的源码实锤------不只是"不画",是根本不生成网格。
② switch 里只有 case CanvasUpdate.PreRender
③ 两个布尔位用完就置 false
这就是为什么"队列去重"能生效------标记是"待办事项",处理完就划掉。
OnCullingChanged
是 UGUI CanvasRenderer 剔除状态变化的回调,
没有 OnCullingChanged 会发生什么问题?
举滚动列表例子:
- 一个 Graphic(Image/Text)在列表里滑出屏幕 → cull=true
- 代码修改它的文字 / 颜色,设置
m_VertsDirty=true - UGUI 走 Rebuild 流程,发现 cull,直接 return,不刷新网格 ✘ 脏标记还留在 true,但是重建动作被跳过了
- 之后这个 Item 再滑回屏幕,cull=false → UGUI 默认不会主动发现:"哎,你还有脏标记没处理!" → 表现 bug:UI 显示是旧的,修改不生效,要等下一次别的触发才刷新
这就是漏洞:剔除期间产生的脏数据,丢失了重建时机。
public virtual void OnCullingChanged()
{
// 如果现在【不再被剔除】,并且还有顶点脏标记 或者 材质脏标记
if (!canvasRenderer.cull && (m_VertsDirty || m_MaterialDirty))
{
// 注释:被剔除的时候,我们很可能跳过了Rebuild调用
// 把自己重新注册到Canvas的Graphic重建队列,下一帧去执行Rebuild
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
}
}
触发时机:canvasRenderer.cull 状态切换的时候(true ↔ false)
- 从可见 → 被剔除(cull 变成 true):if 条件不满足,啥也不干
- 从被剔除 → 重新可见(cull 变成 false):进入 if 判断
- 只要身上还挂着脏标记:立刻排队重建
为什么说这个设计优雅?(重点,滚动列表核心收益)
- 滑出去的时候:0 开销 Item 不在视口,就算你疯狂改它的数据,UGUI 不会跑昂贵的顶点重建、不会提交网格,节省 CPU。 如果没有剔除机制,列表哪怕几十上百个滑出去的 Item,每一帧都在 Rebuild 顶点,直接卡。
- 滑回来的时候:自动补更新,业务层无感 业务代码不用自己写逻辑:监听滚动、判断这个 Item 是不是刚滑进来、要不要手动 MarkDirty+Rebuild。 UGUI 底层回调自动兜底脏数据。 业务只管正常改 Text/Image,不管它在不在屏幕里。
DoMeshGeneration
DoMeshGeneration() 是 Graphic 真正生成 UI 网格的核心函数 : 先让图形本体(Image/Text)往 VertexHelper 写入基础顶点,再遍历身上所有 IMeshModifier(Shadow、Outline、自定义网格修改器)追加 / 改动顶点,最后把顶点数据打包进共享临时 Mesh,交给 CanvasRenderer 送到底层渲染。
阴影、描边不是额外一个 GameObject、不是额外一个 DrawCall,是在同一份顶点流上追加三角形。
private void DoMeshGeneration()
{
// ① 有效矩形:让子类填充基础顶点到 VertexHelper
if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
OnPopulateMesh(s_VertexHelper);
else
s_VertexHelper.Clear(); // 宽高<=0,清空顶点,不渲染
// 拿一个池化List,收集身上所有实现 IMeshModifier 的组件
var components = ListPool<Component>.Get();
GetComponents(typeof(IMeshModifier), components);
// ② 遍历所有Modifier,修改VertexHelper里的顶点
for (var i = 0; i < components.Count; i++)
((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper);
ListPool<Component>.Release(components); // 归还列表,消灭GC
// ③ 把 VertexHelper 的顶点、索引 拷进 workerMesh
s_VertexHelper.FillMesh(workerMesh);
// ④ 交给CanvasRenderer,提交给Unity原生渲染层
canvasRenderer.SetMesh(workerMesh);
}
代表什么(这就是"描边/阴影怎么来的")
描边、阴影不是多画一层,而是在同一个网格上"加顶点" ------它们实现了 IMeshModifier 接口,在基础图形生成完后继续往 VertexHelper 里塞三角形。
VertexHelper 是什么
一句话:UGUI 专门用来攒 UI 顶点、三角索引的「轻量临时容器」,是 OnPopulateMesh 和 IMeshModifier 之间的标准传参对象。它不是 Mesh,只是 C# 侧的顶点数据缓冲区,设计目标就是方便拼 UI 面片。

- OnPopulateMesh :子类重写。
- Image:生成一个矩形 4 个顶点、2 个三角;
- Text:给每个文字生成 4 顶点,一堆三角; 只负责【本体图形】。
- IMeshModifier(重点:Shadow / Outline) 它拿到同一个
VertexHelper,不替换原有顶点,追加新的顶点 + 三角 :- Shadow:把原有顶点整体偏移一段距离,复制一份塞进去;
- Outline:上下左右偏移多份顶点; 关键点:全部在同一个 VertexHelper 里,最后产出同一个 Mesh → 同一个 CanvasRenderer,同一个 DrawCall(前提:材质、贴图合批条件满足) 不是多加一个 Image、不是两层 UI 叠加,是网格本身就自带阴影三角。
FillMesh:把 VertexHelper 内部的数组(verts、uvs、colors、indices)复制到 Mesh 对象里。SetMesh:通知 CanvasRenderer:"这是我这次要渲染的网格"。Unity C++ 侧会把这份数据拷贝走。
注意:Modifier 是追加顶点,不是新建一层,所以顶点数量会暴涨(Outline 顶点很多,容易爆顶点上限)
走到这里 C# 侧任务结束;之后再出问题,就是原生合批、Shader、Canvas、Mask、剔除的问题,C# 脚本层改不动了
这条链路直接决定:Shadow/Outline 为什么不额外增加 DC
- 全部顶点走同一个 VertexHelper → 产出单个 Mesh → 交给同一个 CanvasRenderer
- 因此无论叠了多少层 Shadow/Outline,CanvasRenderer 数量始终是 1、材质和贴图也没变,DC 不变
- 注意:它们是无条件 不增加 DC,与"是否满足合批条件"无关。合批条件影响的是它还能不能和别的 UI 合并
它们真正增加的是两样东西:顶点数 (Outline ×5)和 overdraw(同一片像素填 5 遍,移动端 tile-based GPU 上这个才是瓶颈,往往比顶点数更早出问题)
合批到底怎么判 (Canvas 把多个 CanvasRenderer 的 mesh 合并成一个大 mesh,即 Canvas.BuildBatch):
- 同 Canvas,且同 sorting layer / order,不被 ParticleSystem 隔断
- 同 material 实例(不是"同 shader")
- 同 texture,或同一图集
- 同裁剪状态(Mask 的 stencil / RectMask2D 的 clip rect)
- depth 不交叉(层级顺序不能打断)
DC 飙升的主因几乎全在 材质/图集不同、Mask、depth 交错 这三条里,不在顶点数里
UGUI 的 Shadow/Outline 原理是什么?
四步链路,点明:不是额外 GameObject,IMeshModifier 追加顶点到同一个 VertexHelper。
它们是 IMeshModifier,在 OnPopulateMesh 之后把偏移后的顶点副本追加进同一个 VertexHelper 。Shadow 叠 1 份 → ×2;Outline 继承 Shadow,四方向各叠一份 + 原始 → ×5。代价是顶点数和 overdraw,不是 DC。
- OnPopulateMesh:图形本体生产
- IMeshModifier:顶点后处理(形变、复制追加)
- FillMesh:数据容器转换(VertexHelper → Mesh)
- SetMesh:提交,移交 C++ 渲染管线
IMeshModifier 执行时机?能不能改材质?多个 Modifier 顺序影响吗?
OnPopulateMesh 之后、FillMesh 之前;只能修改顶点数据;执行顺序是 GetComponents 顺序,顺序会影响叠加效果。
UpdateMaterial() + materialForRendering

拿到这个 Graphic 身上所有 IMaterialModifier,串行加工材质 :原始材质丢进去,返回加工后的新材质,一路传递下去,最后交给 UpdateMaterial → canvasRenderer.SetMaterial。

所有被 Mask 裁剪的 UI(Image、Text)都继承
MaskableGraphic,MaskableGraphic : IMaterialModifier,它重写了GetModifiedMaterial它会 new Material 复制一份,这就是材质变体。
不是改原来的材质,是克隆新实例,给它加上模板缓冲相关参数。
为什么生成新 Material 实例 = 打断合批
UGUI 合批的硬性条件之一:Material 对象引用必须完全一样。
- 两个 Image,共用同一个 Material 引用 → 可以合批
- A 走 GetModifiedMaterial,克隆出 MaterialA_Stencil;B 克隆出 MaterialB_Stencil 哪怕它们 shader、贴图、颜色全都一样,对象引用是两个不同实例 → 直接断合批,DC 上涨
对比前面 IMeshModifier(Shadow/Outline): Shadow 只是往 VertexHelper 追加顶点,不新建 Material 对象,材质引用不变,所以满足条件时可以继续合批。 这就是两者巨大差别。
对比 RectMask2D,反过来帮你理解
RectMask2D 不走 IMaterialModifier、不克隆材质! 它是在 CPU 阶段做矩形裁剪,不碰材质对象,所以不会产生材质变体、不会因为这个原因打断合批
protected override void OnEnable(){
base.OnEnable();
CacheCanvas();
GraphicRegistry.RegisterGraphicForCanvas(canvas, this);
if (s_WhiteTexture == null)
s_WhiteTexture = Texture2D.whiteTexture;
SetAllDirty(); // ← 全量标脏!
}
protected override void OnDisable()
{
GraphicRegistry.DisableGraphicForCanvas(canvas, this);
CanvasUpdateRegistry.DisableCanvasElementForRebuild(this); // ← 注意是 Disable 不是 UnRegister
if (canvasRenderer != null)
canvasRenderer.Clear();
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
base.OnDisable();
}
protected override void OnDestroy()
{
GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);
CanvasUpdateRegistry.UnRegisterCanvasElementForRebuild(this); // ← 这才是彻底移除
if (m_CachedMesh) Destroy(m_CachedMesh);
...
}
OnEnable 最后一行 SetAllDirty() ------ 重新激活 = 布局脏 + 材质脏 + 顶点脏 + 射线脏,全来一遍。
SetActive(true) 会全量重建的源码实锤。 这就解释了为什么排行榜/统计界面"打开时激活整个面板"会卡一帧------面板里几百个 Text 全部 SetAllDirty。
解法就是:禁用 Canvas 组件而不是 SetActive(false)。
Disable vs UnRegister 的精准对应
生命周期 调用 含义 OnDisableDisableCanvasElementForRebuild留在队列里但跳过(临时隐藏,对象池语义) OnDestroyUnRegisterCanvasElementForRebuild彻底移出(真销毁)
protected override void OnRectTransformDimensionsChange()
{
if (gameObject.activeInHierarchy)
{
if (CanvasUpdateRegistry.IsRebuildingLayout())
SetVerticesDirty();
else
{
SetVerticesDirty();
SetLayoutDirty();
}
}
}
OnRectTransformDimensionsChange()是RectTransform的回调:只有 RectTransform 的尺寸相关属性变化才会触发;anchoredPosition、rotation、scale 变化不会触发这个回调(这是 Unity 引擎底层 RectTransform 绑定的事件,不是这个函数内部逻辑)SetLayoutDirty():标记布局脏标记 → 触发 Layout 重建(Horizontal/Vertical LayoutGroup、ContentSizeFitter 这类布局组件走这个)SetVerticesDirty():标记顶点脏标记 → Graphic(Image/Text)重新生成网格顶点、UV、颜色,也就是渲染数据重建CanvasUpdateRegistry.IsRebuildingLayout():查询当前是不是正处在 Layout 重建阶段(CanvasUpdate 生命周期的 Layout 阶段)代表什么
① 这个方法只在"尺寸变化"时触发,改位置不触发。 这就是"只改
anchoredPosition不重建"的源码实锤。② 尺寸一变 → 顶点脏 + 布局脏一起来。
换父节点
protected override void OnBeforeTransformParentChanged()
{
GraphicRegistry.UnregisterGraphicForCanvas(canvas, this);
LayoutRebuilder.MarkLayoutForRebuild(rectTransform); // 换之前:标布局
}
protected override void OnTransformParentChanged()
{
base.OnTransformParentChanged();
m_Canvas = null; // 缓存的 Canvas 作废,要重找
if (!IsActive()) return;
CacheCanvas();
GraphicRegistry.RegisterGraphicForCanvas(canvas, this);
SetAllDirty(); // 换之后:全脏!
}
换父:AllDirty + 注销 / 注册 Graphic + 向上遍历查 Canvas + 旧布局重建,开销高一大截。
什么场景下会发生 "换父节点"?(UI 池化最常见)
ScrollView 长列表 错误复用方案
ScrollRect + Content(带 VerticalLayoutGroup) 有人写对象池思路:
- 池子里拿出来 Item →
item.transform.SetParent(content)- 滑出视口了 →
item.transform.SetParent(poolRoot)(挪去池父节点) 👉 每一次进出视口 = 一次 SetParent → 触发 Graphic 的 OnTransformParentChanged →SetAllDirty()+ 注销注册 Graphic + 找 Canvas。 列表一次滑动几十条 Item 反复 SetParent,每一条都全脏、布局重建、注册表来回插拔 → CPU 暴涨、掉帧、卡顿。这就是你那句结论来源:用 SetParent 做列表复用是大坑
正确对比方案(基于 anchoredPosition 的池化,就是 Unity 推荐的 Virtualized List / LoopScroll): Item永远不换父,一直挂在 Content 下面 。 滑出屏幕:只是把
anchoredPosition挪到视口外面隐藏; 需要复用:直接改 anchoredPosition 放到新位置,刷新数据。前面源码已经看到:改 anchoredPosition 不会触发 OnRectTransformDimensionsChange,不会自动打 Layout/Vertex 脏,没有 SetAllDirty、没有注册表注销注册,开销极低。
graphic.color和CanvasRenderer.color这两个颜色有什么区别?

| 项目 | graphic.color(m_Color) | canvasRenderer.color |
|---|---|---|
| 存储位置 | C# Graphic 成员 m_Color,最终写入 Mesh 顶点 COLOR |
CanvasRenderer 对象里的一个 4 维颜色,不在 Mesh 里 |
| 修改入口 | image.color = xxx setter |
graphic.canvasRenderer.SetColor(col) / CrossFadeColor |
| 是否触发 Dirty | 赋值不同就 SetVerticesDirty(),触发 OnPopulateMesh、重建网格 |
完全不触发任何 Dirty,Mesh 不再重建 |
| 生命周期时机 | 布局 / 顶点重建阶段(CanvasUpdate PreRender)填进 Mesh | Mesh 已经生成好,绘制提交前引擎叠加相乘 |
| 合批影响 | 不打断合批;顶点色是 Mesh 数据,同材质可以合批 | 不打断合批!它是渲染时乘数,不改变材质、不改变 Mesh |
| 叠加特性 | 是顶点原始颜色,贴图先乘它 | 在顶点色结果之上,再整体乘一遍(乘法叠加) |
| 持久化 | Mesh 存在就跟着存在,Mesh 不变它就不变 | 临时渲染状态;不序列化,Inspector 看不到 |
| 代表用法 | 静态底色、文本基础颜色 | Button Tween 渐变、CrossFadeColor 呼吸、临时高亮闪烁 |
- 想改基础底色、固定不变 → 用
graphic.color(写进顶点) - 想做流畅渐变、hover 闪烁、呼吸,不想重建 Mesh → 控制
canvasRenderer.color(CrossFadeColor / 自己每帧 canvasRenderer.SetColor) - 两者可以叠加;叠加规则永远是相乘
- 两者都不会打断合批,这是它俩共同优点,和 material.SetColor 不一样
举一个叠加例子,直观感受乘法
graphic.color = Color.red; // 顶点色:(1,0,0,1) graphic.canvasRenderer.SetColor(0.5f,0.5f,0.5f,1);贴图是白色
(1,1,1,1)final = (1,1,1) * (1,0,0) * (0.5,0.5,0.5) = (0.5, 0, 0)→ 暗红,不是灰色!如果你反过来:
graphic.color = Color.white; graphic.canvasRenderer.SetColor(Color.red);结果就是红色,等价于把 canvasRenderer.color 当主色调用,这也是我们做 CrossFade 呼吸的推荐写法:把 graphic.color 固定白色,只动 canvasRenderer.color。
Raycast 爬父链 + overrideSorting 截断
protected bool Raycast(Vector2 sp, Camera eventCamera, bool ignoreMasks)
{
...
while (t != null)
{
...
t.GetComponents(components);
for (var i = 0; i < components.Count; i++)
{
var canvas = component as Canvas;
if (canvas != null && canvas.overrideSorting)
continueTraversal = false; // ← 遇到覆盖排序的画布就停止向上
var filter = component as ICanvasRaycastFilter; // Image / Mask / RectMask2D / CanvasGroup
if (filter == null) continue;
raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
...
}
t = continueTraversal ? t.parent : null;
isParent = true;
}
}
射线检测是 while (t != null) 一路向上爬父链 ,每级都要 GetComponents 检查有没有 ICanvasRaycastFilter。
→ 层级越深,每次点击要爬的层数越多 ------ 这就是"层级深度影响射线检测性能"的源码实锤。
而且overrideSorting 会把 continueTraversal 置 false,直接截断向上遍历 ------ 这就是子画布勾选覆盖排序后能省射线开销的原因。
另外 :raycastTarget 的 setter 会 GraphicRegistry.Unregister/RegisterRaycastGraphicForCanvas → 关掉 Raycast Target 是把元素从射线候选池里摘出去,这才是"关 Raycast Target 能优化"的真正机制(不只是跳过判断,是压根不进列表)。
Animator 是脏元素制造机
protected override void OnDidApplyAnimationProperties()
{
SetAllDirty(); // ← 动画属性一变就全脏
}
UI 上挂 Animator,每帧只要动画属性有变化就
SetAllDirty(),全量重建。官方文档点名批评过这个行为 → UI 动效优先用代码 Tween(DOTween),别用 Animator。
| 结论 | 出处 |
|---|---|
| 一帧改 N 次只重建 1 次 | CanvasUpdateRegistry 队列用 IndexedSet + AddUnique |
| 布局三档 / 图形两档,Cull 夹中间 | PerformUpdate 两个循环边界 |
| 改颜色 = 顶点脏 = 必然重建 | Graphic.color setter → SetVerticesDirty() |
| 被剔除元素连顶点都不生成 | Rebuild() 开头 canvasRenderer.cull |
| 换父节点 = 全脏 | OnTransformParentChanged → SetAllDirty() |
| 重新激活 = 全脏 | OnEnable → SetAllDirty() |
| Mask 打断合批的出处 | materialForRendering 过 IMaterialModifier 链 |
| 描边/阴影是加顶点不是多画一层 | DoMeshGeneration 的 IMeshModifier 链 |
| 射线检测沿父链向上爬,overrideSorting 截断 | Raycast() 的 while (t != null) |
| CrossFadeColor 不重建 | 回调是 canvasRenderer.SetColor |




