1. 先摸清UGUI的骨架:它到底由哪些角色撑起来
做Unity项目的人,几乎绕不开UI这一关。场景再漂亮、逻辑再顺,玩家第一眼看到的还是界面——主菜单、血条、背包、设置面板,全是UI。而Unity 3D里做UI,主流方案就是UGUI这套系统。它是Unity官方从5.0版本开始内置的一套UI解决方案,全称Unity GUI,通常简称UGUI,用来和更老的OnGUI、IMGUI区分开。这套系统的源码本身是开源的,包名是com.unity.ugui,可以在Package Manager里看到,也能下载下来逐行读,这也是很多人职业进阶的起点。
这篇内容我打算把UGUI里能碰到的组件从头到尾讲一遍,不是罗列API文档,而是把它们放在真实项目里,讲清楚每个组件为什么存在、什么时候该用、参数怎么调、容易踩什么坑。新手能照着搭出能跑的界面,有一定经验的人也能从源码角度理解它内部的运作方式。我手上这套分析基于Unity 2021 LTS和2022 LTS两个版本,URP和内置管线都验证过,UI部分的行为基本一致。
1.1 从NGUI到UGUI:一次彻底的重构
要理解UGUI的设计,得先知道它替换掉了什么。在UGUI之前,社区里最流行的是NGUI,它把UI元素组织成一棵树,每个控件都带渲染器和碰撞体,靠物理射线做点击检测。这套思路能用,但问题也明显:每个控件自己的DrawCall容易失控,而且和Unity的渲染管线结合得不够紧密。
UGUI的设计推翻了不少东西,核心变化有三点。第一,它引入了Canvas这个概念,所有UI元素必须挂在Canvas下面才能被渲染,Canvas统一管理整个UI树的渲染和合批。第二,它抛弃了物理碰撞体做点击检测,改用图形轮廓和射线计算,性能更好,也更可控。第三,它把布局、渲染、事件三条线彻底解耦,Layout组件只负责算位置,Graphic组件只负责画,EventSystem单独管交互。这种解耦是UGUI最值得学的地方,也是后面所有组件能自由组合的基础。
我做项目时经常看到有人把UI逻辑写在一个几千行的脚本里,改一个按钮就要翻半天。这其实跟没理解UGUI的解耦思路有关。你只要顺着Canvas、Graphic、Layout、EventSystem这四条线去组织代码,结构自然会清晰。
1.2 UGUI的三层骨架与关键角色
把UGUI拆开看,它其实是三层结构。最底层是渲染层,由Canvas和CanvasRenderer组成,负责把顶点数据提交给GPU。中间是组件层,也就是我们平时拖拽的那些Text、Image、Button,它们都继承自Graphic或者Selectable。最上是事件层,EventSystem加输入模块负责把鼠标、触摸、键盘的输入翻译成事件,再分发给对应的组件。
这里有几个类必须记住。Graphic是所有可见UI元素的基类,Image、Text、RawImage都从它继承,它管理材质、颜色、顶点生成。Selectable是所有可交互控件的基类,Button、Toggle、Slider、Dropdown都从它继承,它管理状态切换、过渡效果。MaskableGraphic是能被遮罩裁剪的Graphic。还有ILayoutElement、ILayoutGroup这些接口,是布局系统用的。
我把这些类关系画在纸上贴过工位,新人来了先看这张图,基本半天就能对上号。源码里这些类定义得很清楚,UnityEngine.UI命名空间下,翻一遍就知道谁继承谁。
1.3 一个UI元素从创建到上屏的完整链路
很多人用UGUI用了很久,但说不清一个Image到底怎么显示出来的。我把它拆成几个阶段。你在编辑器里创建一个Image,它挂了Image组件和CanvasRenderer组件。Image继承Graphic,在OnPopulateMesh里根据Sprite和Type生成顶点数据,写进VertexHelper。CanvasRenderer拿到顶点后,提交给Canvas。Canvas在WillRenderCanvases阶段收集所有子元素的网格,做合批排序,最后通过CanvasRenderer提交渲染。整个过程是延迟到帧末统一处理的,这也是为什么改UI属性不会立刻触发重绘,而是标记为Dirty等下一帧处理。
理解这个链路,你就能明白为什么有些操作特别贵。比如频繁改Text的内容,会触发重建;频繁改RectTransform的尺寸,会触发布局重建。这些在后面的性能章节会细讲。现在只要记住一点:UGUI的显示是批处理、延迟提交的,所以任何改动都要考虑它触发的重建成本。
2. Canvas与渲染:UI世界的舞台怎么搭
Canvas是整个UGUI的地基,没有它,任何UI元素都不会显示。它决定了UI用什么坐标系、怎么缩放、怎么被摄像机看到。我见过不少人UI做不出来,最后发现是元素没挂在Canvas下,或者Canvas的渲染模式配错了。这一章把Canvas相关的组件讲透。
2.1 Canvas三种渲染模式怎么选
Canvas的Render Mode有三个选项:Screen Space - Overlay、Screen Space - Camera、World Space。这三个不是随便选的,各有明确的使用场景。
Screen Space - Overlay是最常用的,UI直接覆盖在屏幕最上层,不依赖任何摄像机,分辨率自适应由Canvas Scaler处理。它性能最好,因为不需要摄像机的参与,适合绝大多数2D界面、HUD、菜单。
Screen Space - Camera是把UI当成一个平面放在指定摄像机前方一定距离处。它的好处是能和场景里的3D物体做层级穿插,比如你想要一个3D角色挡在UI前面。代价是要指定一个摄像机,UI的缩放会受摄像机透视影响(可以调成正交避免)。适合需要和3D场景混合的界面。
World Space是把UI当成场景里的普通物体,有真实的三维位置和缩放。适合做游戏内的交互面板,比如一块悬浮的操作屏、一个可以走近查看的告示牌。它的缺点是缩放、射线检测都要自己处理,性能也比前两种差一些。
我的经验是,90%的UI用Overlay就够了。只有当UI必须和3D画面产生遮挡关系时,才考虑Camera模式。World Space要慎用,尤其是移动端,射线检测和重建成本都不低。
2.2 Canvas Scaler的缩放策略与参数计算
Canvas Scaler是配合Canvas使用的,解决适配问题。它的UI Scale Mode有三档:Constant Pixel Size、Scale With Screen Size、Constant Physical Size。
Constant Pixel Size就是UI尺寸固定像素,屏幕变了UI大小不变。小屏上UI会显得很大,大屏上显得很小,一般只在开发工具类界面用。
Scale With Screen Size是最常用的,也是最推荐的。它会根据一个Reference Resolution(参考分辨率)来缩放整个Canvas。比如你设参考分辨率1920x1080,实际屏幕是1280x720,缩放系数就是0.666。这样UI在不同分辨率下的相对比例就一致了。
这里有个关键参数Match。Match是0到1之间的值,0表示按宽度匹配,1表示按高度匹配,0.5表示两者加权。计算公式大致是:scaleFactor = Mathf.Lerp(screenWidth/referenceWidth, screenHeight/referenceHeight, match)。举个例子,参考分辨率1920x1080,实际屏幕2340x1080(全面屏手机),如果Match=0,按宽度算缩放是2340/1920=1.219;如果Match=1,按高度算是1080/1080=1.0。这两个值差别很大,选错就会导致UI要么被拉伸要么留黑边。
我一般横屏游戏Match设0.5,竖屏游戏Match设0,让宽度优先匹配,因为竖屏设备宽度差异更大。这个值没有绝对标准,要根据具体UI布局试出来。
Constant Physical Size是按物理尺寸(英寸、厘米)来,需要考虑DPI,用得很少,除非你有严格的物理尺寸要求。
2.3 Graphic Raycaster、合批与DrawCall
Canvas上还挂着Graphic Raycaster组件,它负责把点击事件转换成UI元素的命中检测。它和物理射线不一样,是基于图形轮廓的。Raycaster会遍历Canvas下所有Graphic,用RectTransform的范围做初步筛选,再对候选元素做精确的像素检测(如果开启)
合批这块要重点说。UGUI的DrawCall合并规则是:相邻的、使用相同材质和纹理的UI元素会被合并成一个DrawCall。一旦中间插入一个不同材质的元素,合批就会中断,产生新的DrawCall。所以UI层级顺序直接影响DrawCall数量。
常见的合批中断原因有几个:不同图集的图片交替出现、中间夹了文字(Text用的是字体图集)、中间夹了Mask(Mask会打断合批)、中间有不同材质的效果(比如自定义Shader)。排查的时候可以在Game视图的Stats面板看Batches和SetPass Calls,或者用Frame Debugger逐帧看。
我踩过的一个坑是,图集没规划好,一张背景图和一堆小图标来自不同图集,结果DrawCall翻了好几倍。后来我把所有小图标打进一张图集,背景图单独一张,DrawCall立刻降下来。图集规划这件事,越早做越好,后期改起来很痛苦。
3. 基础显示组件:Text、Image、RawImage逐个啃
显示类组件是UI的血肉,负责把内容画出来。这一章讲最常用的三个:Text、Image、RawImage。别看它们简单,参数没调对,效果和性能都会出问题。
3.1 Text与TextMeshPro的性能差别
先说Text。它是Unity内置的文本组件,用TrueType字体渲染。它的原理是把每个字符生成一个四边形,采样字体图集上的字形。问题在于,Text在字符变化时会重建整个网格,长文本频繁更新会非常卡。而且它的字形图集是动态生成的,字体大了图集容易溢出。
TextMeshPro(TMP)是后来官方推荐的替代方案。它用Signed Distance Field技术,字形可以无损放大缩小,图集更省空间,渲染质量也高得多。而且TMP在更新文本时只重建变化的部分,性能比Text好一大截。
我的建议很明确:新项目一律用TMP,不要再用旧Text。迁移也不难,把Text组件换成TextMeshProUGUI即可,API大部分兼容。唯一要注意的是TMP需要生成字体资源,中文字体要做图集预生成,不然运行时动态生成会卡。中文TMP字体我做的时候一般设图集1024x1024,包含常用三千多字,剩下的走动态回退。
3.2 Image的四种Type与九宫格原理
Image组件是显示图片的主力,它的Image Type有四个:Simple、Sliced、Tiled、Filled。
Simple最简单,整张图拉伸填充。适合不需要保持边角形状的图,比如纯色块。
Sliced是九宫格,最实用。它把图片切成九块,四个角不拉伸,四条边单向拉伸,中间双向拉伸。这样圆角按钮、边框在任意尺寸下都不会变形。用九宫格的前提是Sprite要设置Border,在Sprite Editor里画好九宫格的边界线。我见过有人做圆角按钮直接Simple拉伸,结果圆角变椭圆,很难看。正确做法是设置Border后用Sliced。
Tiled是把图片平铺,保持原图尺寸重复填充。适合纹理类背景,比如网格、砖墙。注意Tiled模式下如果开启Fill Center会平铺,不开则中间留空。
Filled是填充模式,配合Fill Method(Horizontal、Vertical、Radial 360等)做进度条、血条、环形倒计时。Fill Amount从0到1控制填充比例。血条用Horizontal,环形技能CD用Radial 360,这两个是最常见用法。
3.3 RawImage与Image的取舍
RawImage和Image长得像,但用途不同。Image显示的是Sprite,会受图集、九宫格、Type设置影响。RawImage显示的是Texture,直接渲染原始纹理,不做任何切割和打包。
什么时候用RawImage?需要显示动态纹理的时候,比如Render Texture(摄像机画面、小地图)、视频帧、程序生成的Texture。这些用Image没法处理,因为不是Sprite。RawImage的UV Rect参数还能控制显示纹理的哪一部分,做纹理滚动、缩放很方便。
代价是RawImage不参与图集合批,每张RawImage基本就是独立DrawCall。所以静态图片能用Image就用Image,RawImage留给必须用纹理的场景。
4. 交互组件家族:Button、Toggle、Slider、Dropdown
能点的、能拖的、能选的组件,都继承自Selectable。这一章把交互组件的状态机、事件、参数讲清楚。
4.1 Selectable基类与状态过渡
Selectable是所有交互组件的爹,它定义了一套状态机:Normal、Highlighted、Pressed、Selected、Disabled。每个状态可以配置不同的颜色或图片过渡。Transition有四种:None、Color Tint、Sprite Swap、Animation。
Color Tint最常用,给每个状态配一个颜色,简单高效。Sprite Swap是给每个状态配不同图片,能做更丰富的视觉反馈,代价是多几张图。Animation是让状态切换驱动Animator,最灵活但最重,一般不用。
所有状态过渡的图/颜色都会参与合批,所以如果大量按钮用了Sprite Swap且来自不同图集,DrawCall会涨。我一般用小图打图集,按钮的几张状态图放同一张图集里。
Selectable还管理着Navigation(导航),也就是键盘/手柄的方向键在按钮间移动。做主机游戏或多平台适配时,Navigation要配好,不然手柄按方向键选不中按钮。显式导航比自动导航更可控,我一般手动指定上下左右邻居。
4.2 Button的点击与长按扩展
Button是最常用的交互组件。它暴露了onClick事件,可以在Inspector里绑,也可以代码里AddListener。要注意的是,Button的点击判定用了UnityEvents,会在Inspector序列化,如果场景被复用或者对象池回收,监听没清干净会出问题。我一般代码里统一用AddListener和RemoveListener成对管理。
Button默认只支持点击,长按、双击、连点都不支持。要长按得自己实现,用IPointerDownHandler和IPointerUpHandler记录按下时间,在Update里判断超时。这里有个坑:指针移出按钮范围时要取消长按,所以要处理IPointerExitHandler。我封装过一个LongPressButton,把按下、超时、取消三个状态理清楚,用了很久没出过问题。
Button还有过渡的Interactable属性,设false后按钮变灰不可点。这个属性很方便,但要注意它只影响交互和过渡表现,不会阻止代码调用onClick。
4.3 Toggle与ToggleGroup
Toggle是开关,有个isOn布尔值。它通常由Background(背景图)和Checkmark(勾选图)组成,Checkmark在isOn为true时显示。Toggle的onValueChanged事件在每次状态切换时触发。
ToggleGroup是管理一组Toggle互斥的组件。把几个Toggle挂到同一个ToggleGroup下,开启Allow Switch Off控制是否允许全部关闭。做单选框、标签页切换用这个。这里有个坑:ToggleGroup在运行时动态添加Toggle,如果没设置好group引用,互斥会失效。我一般在代码里显式设置toggle.group = toggleGroup。
Toggle的Checkmark显示逻辑是切换Graphic的enabled,不是切换透明度。所以如果Checkmark图参与合批,状态变化会触发Canvas重建,量大了要注意。
4.4 Slider与Scrollbar的参数
Slider是滑动条,有Min Value、Max Value、Whole Numbers(是否取整)、Value、Direction(方向)。血条、音量条、进度条都用它。Direction控制滑块从哪端开始填充,Left To Right对应Horizontal,Bottom To Top对应Vertical。
Slider的事件onValueChanged在拖动时持续触发,如果监听里做重逻辑会卡。所以我一般把重逻辑放在onValueChanged里但做节流,或者用OnPointerUp再统一处理。
Scrollbar和Slider类似,但它是给ScrollRect用的,有个Size参数控制滑块长度,Handle Rect指定滑块对象。手动做滚动条用得少,基本都是ScrollRect自动带。
4.5 Dropdown的坑
Dropdown是下拉菜单,看起来简单,实际坑不少。它的Options是List,运行时改Options不会自动刷新显示,要调RefreshShownValue。它的模板(Template)是隐藏的子对象,结构复杂,自定义样式要改好几层。
更麻烦的是,Dropdown在打开时会实例化下拉列表项,每次打开都生成,对象多的时候有GC压力。我一般把Dropdown换成自己实现的下拉面板,可控性更高。如果非要用原生Dropdown,注意模板里的Item要设好,别在运行时动态改结构。
5. 布局与滚动:Layout组件与ScrollRect
手摆UI位置效率低,屏幕一适配就全乱。Layout组件让UI自动排版,ScrollRect让内容能滚动。这一章讲这两个。
5.1 Layout Group与Layout Element
Layout Group有三种:Horizontal Layout Group、Vertical Layout Group、Grid Layout Group。它们控制子物体的排列方式,可以设间距、内边距、对齐方式、是否控制子物体尺寸。
Grid Layout Group最常用,做背包格子、道具列表。它有Cell Size(格子尺寸)、Spacing(间距)、Constraint(约束列数或行数)。做背包时我用Fixed Column Count,每行固定几个,自动换行。
Layout Element是给子物体用的,用来覆盖Layout Group的自动计算。它能设置minWidth、preferredWidth、flexibleWidth等,告诉父布局这个元素想要多大。比如某个列表项高度要固定,就给它加Layout Element设preferredHeight。
5.2 ContentSizeFitter的正确用法
ContentSizeFitter是让容器根据内容自动调整尺寸。它和Layout Group配合,能做出自适应内容的面板。比如一个聊天列表,条目多了面板自动变高。
但ContentSizeFitter有个性能问题,它会触发布局重建,而且如果在ScrollRect的Content上用,滚动时可能反复触发计算。我的经验是,ContentSizeFitter只用在内容不频繁变化的场景,频繁变化的列表最好用固定尺寸加手动计算。
还有一个坑:ContentSizeFitter的Horizontal Fit和Vertical Fit设成Preferred Size时,如果同时有Layout Group,可能产生循环依赖,导致尺寸抖动。排查方法是把其中一个的Fit设成Unconstrained,看是否稳定。
5.3 ScrollRect的性能与复用
ScrollRect是滚动视图,包含Viewport(可视区域)、Content(内容容器)、Scrollbar(滚动条)。Mask或RectMask2D负责裁剪Viewport外的内容。
Mask用的是模板缓冲,会打断合批,DrawCall多。RectMask2D用矩形裁剪,不打断合批,性能好很多。做滚动列表优先用RectMask2D,除非需要非矩形遮罩。
列表项多了,一次性生成几百个Item会卡。要做复用,也就是对象池。只生成可视区域加缓冲数量的Item,滚动时复用移出屏幕的Item,更新内容。我实现的复用列表,可视区显示8个,缓冲2个,总共10个Item循环使用,一千条数据也不卡。这套复用的关键是Item的高度要固定(或者可计算),根据滚动位置算当前该显示哪几条数据。
6. 事件系统:从射线到回调的完整链路
交互组件能响应点击,背后是EventSystem在工作。理解事件链路,才能处理复杂的交互需求,比如拖拽、穿透、优先级。
6.1 EventSystem与输入模块
场景里有一个EventSystem对象,挂着EventSystem组件和输入模块。输入模块负责把原始输入(鼠标、触摸、键盘)翻译成事件。老版本是StandaloneInputModule,新输入系统是InputSystemUIInputModule。如果输入没反应,第一件事就是检查EventSystem在不在、输入模块配没配。
一个场景只能有一个EventSystem,多了会冲突。我见过有人复制场景对象时连EventSystem一起复制,结果UI点击错乱。
6.2 事件接口与触发顺序
UGUI的事件接口很多,常用的有IPointerClickHandler、IPointerDownHandler、IPointerUpHandler、IPointerEnterHandler、IPointerExitHandler、IDragHandler、IBeginDragHandler、IEndDragHandler、IScrollHandler。
一次完整点击的事件顺序是:PointerDown → PointerUp → PointerClick。拖拽是:BeginDrag → Drag(多次)→ EndDrag。注意PointerClick是在按下和抬起都落在同一个对象上时才触发,如果中途移出对象,不触发Click但会触发Exit。
实现这些接口的脚本挂在哪个对象上,事件就发给哪个对象。Raycaster会从上层往下遍历,找到最上面命中的、且实现了对应接口的对象。如果上层对象不处理(比如没实现接口),事件会往下层传递,这就是穿透。
6.3 自定义事件与穿透处理
有时候需要事件穿透,比如一个半透明的遮罩,点击它能透到下面的按钮。这种情况遮罩不要挂Graphic Raycaster能命中的Graphic,或者把raycastTarget设false。
反过来,需要阻止穿透,就在上层放一个Image,raycastTarget设true,它即使看不见也会吃掉点击。这个技巧做弹窗的背景遮罩很常用。
还有个常见需求是手写拖拽窗口。实现IDragHandler,在OnDrag里用eventData.delta累加位置。注意eventData.delta是屏幕坐标增量,要转成Canvas坐标系下的增量,不然缩放下位置会飘。
7. 性能优化与实战避坑
UI性能问题往往到项目后期才暴露,那时候改成本很高。这一章把常见问题和排查方法整理出来。
7.1 合批中断排查
再次强调合批。排查方法:打开Game视图Stats面板看Batches,用一个极简场景做对照,逐个元素enable/disable,观察Batches变化。也可以用Frame Debugger逐DrawCall看哪些元素被合了、哪些断了。
常见的断点:不同图集交替、Text穿插在Image之间、Mask打断、RawImage、不同材质。优化方向:合并图集、调整层级让同图集元素相邻、用RectMask2D代替Mask、文字集中到一块区域。
7.2 Rebuild与Layout Rebuild
UGUI的性能开销主要有两块:Rebuild(重建网格)和Layout Rebuild(重建布局)。Rebuild在Text变化、Image变化、位置尺寸变化时触发。Layout Rebuild在Layout Group、ContentSizeFitter、Layout Element变化时触发。
优化思路是减少重建频率。Text频繁变,改完一次性提交,别每帧改。动态列表用对象池,别频繁创建销毁。RectTransform的尺寸改完再改位置,合并操作。把静态UI和动态UI分开到不同Canvas,静态Canvas不会因为动态部分变化而重建。这个分Canvas的策略特别有效,我做过一个项目把常变的血条单独放一个Canvas,主界面Canvas几乎不重建,CPU占用降了一大截。
7.3 常见问题速查表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| UI完全不显示 | 没挂Canvas下,或被遮挡 | 检查父级,检查Order |
| 点击没反应 | EventSystem缺失,raycastTarget关 | 检查EventSystem,检查Graphic |
| UI被3D物体挡住 | Canvas是Overlay下的Camera排序 | 调整Canvas Sort Order |
| 缩放后UI错位 | Canvas Scaler的Match不对 | 调整Match,验证多分辨率 |
| 文字模糊 | Text图集小,或缩放非整数 | 换TMP,用整数缩放 |
| DrawCall高 | 图集分散,Mask打断 | 合并图集,用RectMask2D |
| 列表卡顿 | 一次性生成太多Item | 做对象池复用 |
| 拖拽跟手漂移 | delta没转坐标系 | 用RectTransformUtility转换 |
这张表我贴电脑旁边,出问题先对一遍,八成的UI问题都能定位。
8. 源码视角:UGUI源码结构与二次封装
想从会用进阶到懂原理,读源码是必经之路。UGUI源码是开源的,结构清晰,值得花时间读。
8.1 UGUI源码目录结构
UGUI源码在com.unity.ugui包里,核心代码在Runtime/UI目录下。几个关键文件:Graphic.cs(所有可见元素的基类,管顶点生成和材质)、MaskableGraphic.cs(可遮罩元素)、Image.cs、Text.cs、Button.cs、Selectable.cs(交互基类)、CanvasUpdateRegistry.cs(重建调度核心)、LayoutRebuilder.cs(布局重建)。
重点读CanvasUpdateRegistry和LayoutRebuilder,这两个是性能的根。CanvasUpdateRegistry维护了Layout重建、Graphic重建、裁剪重建三个队列,在帧末统一处理。理解了它的调度机制,你就明白为什么改UI属性不立即生效,以及怎么减少重建。
8.2 常见UI框架封装
实际项目里,UGUI原生组件不够用,要做一层封装。我常用的封装包括:UI基类(管理生命周期、事件绑定、资源释放)、UI管理器(栈式管理界面、打开关闭动画)、复用列表组件、自适应文本。这些封装的核心目的是把重复代码抽出来,把资源管理统一起来。
UI基类的典型设计是定义OnOpen、OnClose、OnRefresh几个虚方法,子类继承后实现自己的逻辑。打开时调OnOpen,关闭时调OnClose做清理。这样界面逻辑清爽,也不会漏掉资源释放。
8.3 图集与资源管理
图集规划前面提过,这里补充实现层面。Unity的Sprite Atlas是官方图集方案,把多张Sprite打包成一张大图,减少DrawCall。设置时注意Max Texture Size、压缩格式、Padding。Padding要留够,不然图之间会有渗色。
资源加载用Addressables或自研的引用计数管理。UI预制体加载后,如果直接Destroy,里面的图集可能被卸载导致其他界面花屏。所以要引用计数,多个界面共用同一图集时不卸载。这块坑很深,我见过项目因为图集卸载时机不对,切场景后UI变白块。稳妥做法是UI相关资源常驻,或者统一生命周期管理。
我个人在读UGUI源码和做二次封装的过程中,最大的收获是明白了“框架设计要先想清楚职责边界”。Canvas管渲染,Layout管布局,EventSystem管交互,各司其职。你在它之上做封装,也应该延续这个思路,别把所有逻辑往一个脚本里塞。UI代码写得好不好,很大程度上取决于你有没有顺着UGUI的设计哲学走。