ARTICLE · 1077153
【Unity UGUI源码深度解析】01|UGUI全景架构:从组件继承关系到渲染、布局与事件三条主线
《UGUI源码深度解析》第 1 篇 · 界面小组工作日志源码基准:Unity 2022.3.62f2c1,项目内 com.unity.ugui 1.0.0。人物与项目情节为虚构;源码机制以本地实现为准。

一、按钮还在,怎么就不理人了?
周一,我们接到一个小任务:给游戏做个背包界面。
我负责界面,同事阿澈负责交互。第一版刚拼好,他就把椅子滑了过来。
“整理按钮,点不动。”
“是不是没绑 onClick?”
“绑了。”
“那就……再绑一次?”
阿澈看了我一眼。我决定先把这条建议撤回。
我们沿着场景查了一圈,发现 EventSystem 被禁用了。阿澈把这个发现记在便签上,我却盯着屏幕多想了一步:为什么输入停了,按钮却还好端端地画在那儿?
如果你也觉得“按钮不就是一个 Button 吗”,今天正好,我们一起拆开看看。先不钻几千行代码,只弄清三件事:谁负责画,谁负责排,谁负责听鼠标说话。
二、我们看到一个按钮,Unity看到一组同事
我选中“整理”按钮,把 Inspector 拉宽。
“你看,背景是 Image,文字是 Text,Button 反倒不负责画它们。”
阿澈指了指 Button:“所以它只是名字比较像老板?”
我们不妨先给这些组件画张简历表。下面箭头表示“派生自”,不是调用顺序:

Button 和 Image 并不是父子关系。 Image 通过 Graphic 这一支准备绘制数据;Button 通过 Selectable 这一支获得交互状态和过渡能力,再处理点击。它可以借助 targetGraphic 改变视觉表现,但背景网格不是它生成的。
UIBehaviour 则提供生命周期、尺寸和层级变化的回调入口。我们可以把它理解成共同的接入规范,而不是替所有组件干活的万能基类。
“那布局站哪边?”
“它不急着认祖归宗,主要看接口。”
ILayoutElement 报告尺寸需求,ILayoutController 负责设置布局。同一个组件既能参与绘制,又能提供布局信息。读到这里,我们先记住:界面上的一个东西,往往是多个组件合作出来的。
三、我只是换个颜色,怎么还要排队?
为了看看“画”这条线,我把按钮背景从白色改成绿色。
“现在就重新画了?”阿澈问。
先别急,我们跟进 Image.color。它使用 Graphic 的颜色属性 setter,颜色发生变化才会调用 SetVerticesDirty。下面是这个方法的源码:
public virtual void SetVerticesDirty(){ if(!IsActive()) return; m_VertsDirty =true; CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); if(m_OnDirtyVertsCallback !=null) m_OnDirtyVertsCallback();}你看,这里面没有生成网格。它更像在待办本上记了一笔:“我的顶点数据过期了,稍后处理。”颜色属于顶点数据,所以这里标记顶点,而不是重新创建材质。
接过待办本的是 CanvasUpdateRegistry。它通过这行代码,接入渲染前的更新时机:
Canvas.willRenderCanvases += PerformUpdate;“那我连续换三次颜色,会登记三遍吗?”
在正常延迟更新路径里,同一元素的队列注册会去重,不必每改一次就生成一次网格。不过别拿它当免费通行证:赋值、回调和注册检查仍然有开销。
轮到 Graphic.Rebuild 的 PreRender 阶段,才会按脏标记更新。沿常规网格路径往下找,我们会遇到 OnPopulateMesh、网格修改器、VertexHelper.FillMesh,最后到 CanvasRenderer.SetMesh。
走到这里,我们先停一下。CanvasRenderer 等组件涉及原生引擎实现,包内 C# 能告诉我们数据怎样提交,却不能展示底层合批的完整算法。准备网格和构建渲染批次,不能混成一件事。
四、背包里的长名字,先给它安排座位
按钮聊明白了,阿澈又塞进来一件装备:“一把名字特别长的剑”。
我们的物品行使用水平布局,一边是图标,一边是文字。名字变长后,究竟该占多宽、换几行?如果座位还没排好就生成最终网格,后面可能又得返工。
这时布局系统上场:ILayoutElement 报告“我需要多少空间”,ILayoutController 决定位置和尺寸,LayoutRebuilder 找到需要重建的布局根,再组织计算。
“为什么先算宽,再算高?”
你可以把文本框拉窄试试:宽度变了,换行数量就可能变,高度也跟着变。因此 LayoutRebuilder 按水平测量、水平控制、垂直测量、垂直控制的顺序工作,不是随意排的。
我们把调度流程画下来:

这张图展示调度顺序,不代表改个颜色就得重新排版。布局与图形各有重建队列,只处理各自登记的任务。要是每次换个衣服颜色,全办公室都得重新安排工位,那确实有点忙。
五、谁来告诉按钮:“刚才有人点你了”
我们终于绕回开头那个不理人的按钮。
“既然能画、能排,缺的是听消息的。”阿澈说。
在传统输入模块这条路径里,EventSystem.Update 更新并选择模块;没有切换模块且当前模块有效时,才调用它的 Process。不是每个 Button 自己盯着鼠标。
我们顺着一次点击往下走:StandaloneInputModule 准备指针数据,经 EventSystem.RaycastAll 收集命中结果;GraphicRaycaster 检查候选图形的范围、过滤条件和深度。随后,输入模块维护按下、拖拽和释放状态,判断点击是否成立,再通过 ExecuteEvents 派发。Button 收到事件并通过检查,才触发 onClick。
“鼠标落在它上面,就一定算点击?”
不一定。按下后拖走、被上层图形挡住,都可能改变结果。看得见与点得到,本来就是两套条件。
阿澈注意到另一个名字:GraphicRegistry。“这也是刚才那个待办本?”
差一点。GraphicRegistry 维护 Canvas 与图形的关联,为射线检测提供候选;CanvasUpdateRegistry 才负责重建队列。一个回答“有哪些图形”,一个回答“谁需要更新”。我们给名字相近的两位分清职责,就不难理解:静止不动的图片虽然不用重建,仍然可能挡住鼠标。
我们也别把事件理解成“子节点通知所有祖先”。例如 ExecuteHierarchy 沿父级查找,在首个能处理事件的节点就停下,并不是一路广播。
如果 onClick 改了背包数量文本,文本又会标脏,回到更新流程。于是三条线接起来了:输入引起变化,布局安排空间,图形准备绘制数据。
六、先别信我,给按钮做个小实验
故事可以虚构,结论最好自己验证。你可以建一个带 Image、传统 Text 子对象和 Button 的按钮,配好 Canvas、GraphicRaycaster、EventSystem 与 StandaloneInputModule,并绑定一个可观察的点击回调。
先禁用 EventSystem。预期按钮仍然显示,但这条输入链路不再触发点击。然后恢复它,在 Graphic.SetVerticesDirty 和 Rebuild 设置断点,再改变背景颜色,观察标脏与渲染前重建的先后关系。
这些是实验步骤和预期,不是本次已运行的测试报告。初始化也可能命中断点,我们要看调用栈,别把每次暂停都当成新的一帧。
阿澈在便签上写下三个词:“画、排、点。”
我又补了一行:“先找谁负责,再问它怎么做。”
今天不必背住所有类名。下次图片不对、布局乱了或者按钮失灵,我们至少不会只盯着 Button 猜。
正准备收工,阿澈又问:“那这些组件怎么知道自己被启用,或者换了个父节点?”
好问题。下一篇,我们去找它们共同的入口——UIBehaviour。
本篇源码索引
Graphic: ./com.unity.ugui@1.0.0/Runtime/UI/Core/Graphic.csCanvasUpdateRegistry: ./com.unity.ugui@1.0.0/Runtime/UI/Core/CanvasUpdateRegistry.csLayoutRebuilder: /com.unity.ugui@1.0.0/Runtime/UI/Core/Layout/LayoutRebuilder.csEventSystem: ./com.unity.ugui@1.0.0/Runtime/EventSystem/EventSystem.cs