夜雨聆风学习资料网

ARTICLE · 1077153

【Unity UGUI源码深度解析】01|UGUI全景架构:从组件继承关系到渲染、布局与事件三条主线

【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.cs
  • 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
  • EventSystem:./com.unity.ugui@1.0.0/Runtime/EventSystem/EventSystem.cs

相关学习资料