我们项目组最开始做UI的时候,没人想过要单独做一个框架。界面数量少的时候,UGUI自带的Panel、Button、Text拖拖拽拽就搞定了。等到十几个玩法模块陆续接入,每个模块两三个界面,再加上弹窗、引导、红点、加载转圈,整个Canvas下面挂了几百个节点,每次改需求都要在大括号一样的嵌套结构里翻找组件——那一刻我才意识到,UI开发缺的不是控件,而是一套约束组织方式的框架。EGUIFramework就是在这个背景下逐步沉淀出来的一个基于UGUI的轻量级UI框架,它不追求大而全,重点解决界面生命周期管理、数据驱动刷新、异步加载配合和模块间事件解耦这几个真正让我头疼的问题。如果你所在的团队正处在"界面越来越多、代码越来越乱、改一个功能要牵连三四个文件"的阶段,这篇文章里的设计思路和踩坑记录应该有参考价值。
1. 为什么EGUI先从"窗口管理"切入,而不是控件库或动画库
很多团队一提到UI框架,第一反应是去做一套酷炫的控件库:各种进度条、滚动列表、飘字特效。但我在实际项目里体会到,UI代码腐烂的根源通常不是控件表现力不够,而是窗口的打开和关闭完全没有规范。今天这个界面用SetActive(true)直接显示,明天那个界面用Instantiate现场创建,后天又有一个人写了个Destroy把界面对象给删了——三种做法混在一起,界面的缓存、初始化、数据刷新时机全是乱的。
EGUIFramework最早定的方向就是解决"界面生命周期"这一件事。窗口管理器由一个UIManager单例负责,所有界面派生自同一个基类UIWindowBase。打开界面走UIManager.Instance.OpenWindow<T>(),关闭界面走CloseWindow<T>(),禁止业务层直接操作界面的GameObject。这个约束看着简单,但执行起来之后,项目里"界面谁打开、谁关闭、关了之后要不要通知别人"这些问题第一次有了统一的回答。
从收益角度看,窗口统一管理带来的第一个直接好处是缓存复用。以前每次打开商店界面都要重新加载图集和控件引用,高频界面来回切换能明显感到卡顿。EGUI里每个界面默认走"首次创建、后续复用"的模式,关闭时不会销毁对象,而是执行Hide流程再存入对象池。实测下来,背包界面在连续开关20次之后,帧时间从优化前的平均8毫秒降到了2毫秒以内,GC Alloc也几乎归零。这个数据让我确认了一个判断:UI框架的第一价值是回收秩序,而不是花哨的视觉封装。
2. 窗口生命周期状态机:EGUI顶层设计的核心骨架
窗口管理的本质是状态机,这一点我是踩了两次坑才想明白的。第一版EGUI只有Init和Cleanup两个虚方法,界面显示顺序全靠OnEnable和Start来猜,结果出现了"A界面打开时依赖B界面的数据,但B还没走完初始化"这种诡异时序问题。后来我翻了几套开源UI框架的源码,才意识到问题的核心在于每个界面必须有明确的、可追踪的状态,状态之间只能通过驱动函数流转。
2.1 状态定义与流转规则
EGUI给每个窗口定义了六个基础状态:
| 状态 | 含义 | 进入条件 |
|---|---|---|
| Closed | 未创建或已回收 | 对象池中无实例,或OnHide执行完毕 |
| Loading | 资源加载中 | OpenWindow被调用,且界面资源尚未就绪 |
| Ready | 资源就位,组件可用 | 异步加载完成,实例化结束 |
| Showing | 显示过渡中 | ShowUI被调用,正在播放动画或布局 |
| Visible | 完全可见可交互 | 显示过渡完成 |
| Hiding | 隐藏过渡中 | CloseWindow被调用,正在播放淡出动画 |
这套状态机最关键的设计是禁止乱跳。比如业务方在Visible状态下调用CloseWindow,状态会先进入Hiding,执行完动画和事件通知之后才回到Closed。如果业务方在Loading状态下又发了一次OpenWindow,框架不会重复创建资源,而是把同一个请求挂到加载完成后的回调队列里,保证"同一时间同一类型的窗口只有一个异步流程在跑"。
状态流转的具体执行藏在UIWindowBase内部,业务层只需要关注三个虚方法:
protected override void OnInit() // 界面创建完成,只执行一次 protected override void OnRefresh() // 每次显示前都会调用,用来同步数据 protected override void OnHide() // 隐藏时调用,释放临时引用我在项目里反复强调一个规矩:不要在OnRefresh里做耗时操作。有个同事把成就列表的排序逻辑放在了这里,界面淡入之后列表是空的,等了快一帧才刷出来,体感就是"卡了一下"。把排序挪到数据层并做缓存之后,OnRefresh只负责赋值,这个问题就消失了。
2.2 界面栈与反向传值
状态机解决了单个窗口的管理问题,窗口之间的压栈传值则走了另一条路。EGUI维护了一个UIStack,每次打开新窗口时入栈,关闭当前窗口时自动回到上一个窗口。这个设计最实用的场景是"任务详情页 -> 奖励预览弹窗 -> 点击跳转商城"这种多级跳转,返回的时候不需要业务层手动记"我上一个界面是谁",框架通过栈顶索引直接拿到。
不过在实现的时候有个细节要注意:入栈和出栈不能和状态的显隐完全绑定。比如弹窗类界面,它入栈了但底下的主界面不应该被销毁,只是被遮罩挡住。EGUI里加了一个IsPopUp标记,带有这个标记的窗口入栈时不会去Hide底下那层界面,只在遮罩层上做文章。这个设计后来在处理充值弹窗、签到弹窗这些高频弹层时非常省心。
反向传值我走的是"请求-回调"模式,而不是直接塞引用。A界面打开B界面时,可以传入一个Action<object>回调,B在OnHide时把结果通过回调抛出去。这样A和B之间没有直接引用关系,后续重构把B拆成两个界面时,A完全不用改。
3. 数据绑定与界面刷新:不做重MVVM,但需要一套"刷新协议"
一开始我做EGUI时,犹豫要不要上MVVM,想了很久,结论是不能用。项目里UI代码大部分是C#写的,MVVM需要引入绑定器和属性通知机制,对于团队里新人来说理解成本太高,而且UGUI的序列化字段在MVVM下经常要在Inspector和绑定代码之间来回切换,调试起来非常痛苦。但完全放弃数据驱动也不行,手写赋值刷新的代码太容易漏——数据变了界面没更新,这种Bug在测试阶段极难复现。
EGUI最后取了个中间态:每个窗口实现一个IDataBindable接口,显示前由框架统一调用绑定方法。
public interface IDataBindable { void BindData(object data); void UnbindData(); }和完整的MVVM不同,EGUI不追踪属性的变化,只在两个时机调用BindData:第一次创建完成后、以及每次OnShow之前。这意味着业务层只要保证"数据变了之后调用RefreshWindow",界面就一定是最新的。
为了让"手动通知刷新"这件事不容易漏,EGUI在UIWindowBase里提供了Refresh()方法,它的内部逻辑是排重加延迟合并:
public void Refresh() { if (mRefreshDirty) return; mRefreshDirty = true; UIManager.Instance.ExecuteAfterNextFrame(() => { mRefreshDirty = false; OnRefresh(); }); }同一帧内多次调用Refresh只会真正执行一次。这个设计在红点提醒这种高频场景下非常重要——每次领取奖励、完成任务、背包变化都会触发界面的刷新请求,如果没有合并机制,一个界面在一帧里可能被刷三四次,CPU白白浪费。
数据绑定这块还有一个大坑:界面显示前有些字段是空的,直接取组件赋值会报NullReference。所以BindData的执行时机我放在了动画播放之前、OnInit完成之后,顺序是OnInit -> BindData -> OnShow。如果你在项目里自己扩充这个框架,千万不要把数据绑定放在OnEnable里,因为UGUI的OnEnable在对象从池中激活的瞬间就会触发,那时资源引用和数据源都不一定准备好了。
4. 资源加载与异步初始化:让UI帧率不再被Instantiate拖垮
UGUI的Instantiate开销大是公认的,尤其是预制体上挂了复杂Image、Text、LayoutGroup的时候,主线程上的耗时可能飙到几十毫秒。EGUI在第二版迭代时接入了异步资源加载,目标是任何窗口的打开都不允许在调用线程上同步卡顿。
4.1 加载管线的工作方式
框架定义了一个统一的资源加载回调:
public class UIResourceRequest { public string assetPath; public Action<GameObject> onCompleted; }打开窗口时,UIManager先检查对象池里有没有缓存实例,有就直接复用;没有则走资源管线异步加载预制体。加载完成后框架负责Instantiate、挂载窗口脚本、注入绑定的数据上下文,最后才把窗口状态切换到Ready。业务层的OpenWindow<T>调用只会拿到一个句柄,界面的真正可用时机通过OnOpened事件回调通知。
这套异步化的收益在低端安卓机上特别明显。我们在一台内存只有3GB的测试机上对比过:同步创建主城活动入口界面,耗时37毫秒,直接导致掉帧;走EGUI异步管线之后,创建过程被拆散到三帧内完成,最大单帧耗时不超过12毫秒,体感上完全没察觉到界面打开的过程。
4.2 加载途中被关闭的优雅处理
异步加载必然会引入一个边界问题:如果用户加载途中又点了关闭怎么办。EGUI的做法是给UIResourceRequest加了一个isCanceled标记。窗口还没就绪时收到CloseWindow<T>请求,框架不销毁正在加载的预制体,而是等它加载完成、实例化之后直接进入Closed状态放入对象池,保证下次打开走缓存。这个方案比"加载到一半直接丢弃引用"要稳,因为Asset管线里加载过的资源会被引擎缓存,下次再加载反而更快。
另一个容易遗漏的点是Addressable依赖的自动释放。如果你项目里用了资源包管理,切记UI框架只能负责窗口的显示逻辑,不能替资源管线做资产释放决策。EGUI里给每个窗口配了AssetHandle列表,在OnDestroy时统一Release。但这里有个细节:窗口入池不等于销毁,所以池中的窗口不能释放资源,否则下一次打开又得重新加载。我加了一个ForceReleaseUnuseAsset()方法,由业务层在"内存告警"或"切场景"时手动调用,用来清理池中长期未打开的界面资源——这个开关解决了"池子越来越大吃掉内存"的问题。
5. 事件通信与模块解耦:全局总线不是万能的,得分级
UI框架如果只解决了窗口管理,那还没碰到真正的痛点。多个界面之间、界面与玩法逻辑之间的通信,才是代码腐化的重灾区。我见过最离谱的写法,是A界面持有一个BUIWindow的静态引用,直接调用B的公开方法更新显示——这种写法在改版时牵一发动全身,A一改,B也得跟着改,改到最后没人敢动这些文件。
EGUI里内置了一个轻量级事件总线UIMessageBus,支持泛型事件和弱引用订阅。基类UIWindowBase自动注册RegisterMessage<T>(),业务层只需要实现OnMessage(T msg):
public class UIMessageBus { private Dictionary<Type, List<WeakReference>> mSubscribers; public void Publish<T>(T msg); public void Subscribe<T>(IEventSubscriber subscriber); public void Unsubscribe<T>(IEventSubscriber subscriber); }5.1 为什么用弱引用
这里特意提一下弱引用的原因。如果事件总线持有的是强引用,而界面对象已经关闭入池,但没有调用Unsubscribe,那么界面对象永远不会被回收,等于内存泄漏。弱引用则允许对象被GC回收,即使业务方忘记反注册,最多就是收到消息时发现引用已失效,不会造成持续泄漏。
但弱引用也带来一个隐患:内存告警时,池中的窗口对象可能被整个回收,此时事件总线里的弱引用已经悬空,如果总线广播消息时统一强转,会抛InvalidCastException。EGUI在Publish内部做了目标类型检查和空引用过滤,所以这个异常从来没在线上出现过,但你在自研框架时千万别忽略这个边界。
5.2 事件范围的分级
全局事件总线的缺点是噪音大、难定位。EGUI后来加了一个小机制:UIMessageBus支持在消息类型上挂HandleScope枚举,分Global和Local两种。局部事件只发送给特定的窗口实例,适合"某个界面内部组件之间的协作";全局事件才走所有订阅者,适合"货币变化、任务状态更新"这类跨模块信息。
举例来说,背包界面里点击某个道具弹出一个详情弹窗,弹窗关闭后背包要刷新道具数量——这种场景用局部事件,弹窗关闭时Publish(new ItemUsedEvent(...)),目标订阅者只有背包窗口,不会让主界面的杂七杂八组件全都收到消息。而"货币余额变动"这种消息一定要走全局,因为商城、主界面、任务界面都可能要刷新显示。
这套分级带来的最大好处是排查方便。线上出了问题,如果是全局事件,去看谁订阅了谁的代码一眼就能扫完;如果是局部事件,直接看消息类型的作用域就能锁定目标窗口,不会出现"这个事件到底被谁处理了"的困惑。
6. 实测踩坑记录:EGUI在真实项目中暴露的问题与优化
框架不是写完就完事的,在接入真实玩法、经过长时间运行之后,EGUI暴露了不少单测阶段完全发现不了的性能问题。这一节没有源码,都是直接在项目里用Profiler抓出来的教训,希望你能避开。
6.1 被忽视的Canvas重建
最让我头疼的一个问题是:打开任意窗口都会导致全屏Canvas重建。排查了很久才发现,原因不是EGUI本身,而是UI界面预设里大量使用了CanvasGroup。有一段时间我为了做界面淡入动画,在每个窗口根节点上挂了一个CanvasGroup,结果CanvasGroup.alpha的变化会让UGUI把它标记为"属性变更",进而触发该Canvas下所有元素的顶点重建。窗口多的时候,一次淡入动画能带来每帧2到3毫秒的额外开销。
优化方案是在EGUI里加了一条约束:界面显隐过渡走位置或缩放动画,尽量不动alpha;如果一定要用淡入淡出,就把动画节点单独放到一个子Canvas下,这样只有那一个子Canvas会重建,不影响整个界面。这个改动让多窗口同屏场景的帧率提升了约15%。
6.2 对象池膨胀与显隐翻转
对象池的好处是快,但有一个副作用:界面开关的次数多了之后,池子里囤了大量"最近用过但可能再也不用了"的对象。EGUI第一版没有池大小限制,运行了半小时后池里有80多个界面实例,其中大部分是一局游戏只会弹一次的新手引导弹窗。内存撑住了,但FindViewById遍历组件的耗时上来了。
后来我给池加了两个策略:一是MaxPoolSize配置,超过上限后最久未被复用的实例直接销毁;二是PoolLifeTime,界面入池超过若干分钟且未再次打开就释放AssetHandle并销毁GameObject。这两个参数直接写在UIManager的资源配置文件里,运营活动上线后,某个冷门活动界面就再也没占过内存。
6.3 红点刷新风暴
红点系统是UI性能的隐形杀手。EGUI的全局事件总线把"红点状态变化"广播给所有订阅窗口,一开始没做合并,结果一次背包物品变化触发了40多个窗口同时执行红点刷新逻辑,每次刷新还要查一次配置表。在低端机上那几帧直接卡成PPT。
解决方式是给UIMessageBus的Publish加了个"同帧合并"参数,和前面说的Refresh()合并机制一脉相承:
public void Publish<T>(T msg, bool mergeInFrame = false)mergeInFrame为true时,同一类型消息在一帧内只会实际派发一次,后续请求被丢弃。红点消息统一用这个模式发布,一帧内无论触发多少次都只刷新一轮。测试结果非常理想,红点相关帧时间从平均5.7毫秒降到了1.8毫秒,而且UI表现上没有任何损失。
6.4 调用顺序的隐形依赖
最后一个坑是团队协作层面的。EGUI的窗口生命周期把OnInit、OnRefresh、OnShow的时机固定了,但不同开发者在写业务代码时,经常在OnRefresh里假设另一个窗口已经Visible了。比如"主界面打开后,引导窗口要立刻弹出来",如果写在主界面的OnRefresh里,有时会竞态,因为引导窗口的加载是异步的,可能还没就绪。
框架层面很难解决这种业务时序问题,我最后做的是在UIWindowBase里加了一个辅助方法GetOpenedWindow<T>()返回窗口的当前状态,然后在项目规约里强调:跨窗口联动必须在目标窗口进入Visible状态后再触发,不要依赖某个窗口的OnRefresh时序。这个方法不是万能的,但至少把团队里"靠猜时序"的情况减少了七成。
EGUIFramework这套东西写到这里,其实核心思想就一句话:UI框架的本职是建立秩序——窗口管理的秩序、数据刷新的秩序、事件通信的秩序——表现层的东西都应该排在这些之后。如果你正在规划自己项目的UI框架,建议先别急着封装控件、动画、列表组件,而是把这几件基础秩序定下来,后面所有界面开发都会顺很多。我个人在实际使用中的体会是,框架的代码量其实不大,真正花时间的是在真实项目里反复打磨边界情况:异步加载时关闭窗口怎么办、红点刷新风暴怎么合并、CanvasGroup的alpha陷阱怎么避开。把这些记录沉淀下来,框架才算真正立住了。后续如果你们团队有特殊需求,比如对接新的资源管理方案,或者要支持全屏弹层的多级回退,在EGUI的状态机和事件分级基础上扩展起来也会比较顺手。