做 Unity 项目时,UI 管理一开始通常都很简单。
打开界面:
bash
panel.SetActive(true);
关闭界面:
bash
panel.SetActive(false);
销毁界面:
bash
Destroy(panel);
Demo 阶段这样写没有问题。
但是项目一旦变大,UI 就不会只是"显示"和"隐藏"这么简单了。
一个界面从出现到消失,背后通常还牵扯到很多事情:
bash
Prefab 加载
节点绑定
脚本初始化
点击事件注册
输入框注册
ScrollView 初始化
对象池初始化
本地化文本收集
界面显示刷新
界面隐藏清理
动画命令中断
对象池回收
事件监听注销
资源卸载
渲染层级调整
Esc 关闭逻辑
背景模糊处理
这些事情如果都散落在各个 UI 脚本里,项目越做越大,后面就会越来越难维护。
所以 MyFramework 里有一个 LayoutManager。
它解决的不是"怎么打开一个界面"这么简单的问题,而是:
一个大型 Unity 项目里,UI 从加载、显示、更新、隐藏到销毁,应该由谁来管,怎么管,管到什么程度。
一、UI 不应该只是一个 GameObject
很多项目里的 UI 管理都是围绕 GameObject 做的。
比如:
bash
GameObject loginPanel;
GameObject mainPanel;
GameObject bagPanel;
然后业务逻辑里到处写:
bash
loginPanel.SetActive(true);
bagPanel.SetActive(false);
这种写法最大的问题是,UI 被当成了一个普通节点。
但是在大型项目里,一个界面并不只是一个 GameObject。
它至少应该包含三层概念:
bash
Prefab 资源
界面实例
界面逻辑脚本
在 MyFramework 里,这三层分别对应:
bash
ResourceRef<GameObject> // 界面 Prefab 资源引用
GameLayout // 界面实例对象
LayoutScript // 界面逻辑脚本
也就是说,框架不会让业务直接管理一个裸的 GameObject。
业务层看到的是 LayoutScript,框架内部管理的是 GameLayout,资源层持有的是 ResourceRef<GameObject>。
这样做的好处是,界面的生命周期就可以被完整接管。
加载时,LayoutManager 负责实例化 Prefab。
初始化时,GameLayout 负责创建根节点和绑定脚本。
显示时,LayoutScript 负责刷新界面状态。
隐藏时,LayoutScript 负责清理监听和回收对象。
销毁时,GameLayout 负责销毁 UI 对象并释放资源引用。
这比单纯 SetActive 要可靠得多。
二、LayoutManager 管的是"界面资源和界面实例"
LayoutManager 里最核心的几个数据结构,大概可以理解成这样:
bash
mLayoutRegisteList // 所有已注册的界面信息
mLayoutList // 当前已经加载出来的界面实例
mLayoutTypeToPath // LayoutScript 类型到 Prefab 路径的映射
mLayoutPathToType // Prefab 路径到 LayoutScript 类型的映射
mLoadingLayoutList // 正在异步加载的界面
mBackBlurLayoutList // 需要背景模糊的界面
这几个列表决定了 LayoutManager 的定位。
它不是业务导航器。
它不是页面返回栈。
它也不是具体界面的逻辑控制器。
它真正负责的是:
bash
这个界面有没有注册
这个界面的 Prefab 在哪里
这个界面有没有加载
这个界面是否正在异步加载
这个界面当前是否显示
这个界面当前渲染顺序是多少
这个界面是否需要背景模糊
这个界面什么时候销毁
所以 LayoutManager 的职责是比较底层、比较稳定的。
它不关心玩家现在是在登录流程、主城流程、战斗流程,还是副本流程。
它只关心:
某个 LayoutScript 对应的界面资源,应该如何被加载、显示、隐藏和释放。
三、界面注册:先让框架知道这个界面是什么
MyFramework 里的界面不是随便传一个路径加载,而是先注册。
比如:
bash
LayoutManager.registeLayout<UILogin>(LAYOUT_LIFE_CYCLE.PART_USE);
LayoutManager.registeLayoutPersist<UIMain>(null);
注册时会记录几个关键东西:
bash
界面脚本类型
界面 Prefab 路径
界面生命周期类型
界面创建后的回调
生命周期目前主要分两类:
bash
PERSIST // 全局常驻
PART_USE // 局部使用
这个设计很重要。
因为大型项目里的 UI 并不是所有界面都应该一样处理。
有些界面是全局常驻的,比如主界面、提示层、网络等待层、通用弹窗层。
这些界面可能会跨流程、跨场景存在。
有些界面只是某个流程临时使用,比如登录界面、创建角色界面、某个活动界面、副本结算界面。
这些界面在离开对应流程后就应该卸载。
所以 LayoutManager 不是只提供一个 Load,而是把界面的生命周期属性也登记进来。
后面场景流程退出时,就可以统一调用:
bash
mLayoutManager.unloadAllPartLayout();
这样所有 PART_USE 类型的界面都会被销毁,而 PERSIST 类型的界面会继续保留。
这就是框架层生命周期管理的意义。
不是每个界面自己判断"我该不该销毁",而是注册时就把规则确定下来。
四、加载和显示要分开
很多项目里,打开界面时会把加载和显示混在一起:
bash
LoadPanel();
ShowPanel();
看起来没问题,但实际项目里会遇到很多情况。
比如:
bash
进入场景前提前加载 UI,但是暂时不显示
打开界面时如果已经加载过,就只显示
异步加载完成后再显示
某些界面要预加载,避免打开时卡顿
某些界面只加载一次,后续重复使用
所以 MyFramework 里提供了多种入口:
bash
LOAD<T>();
LOAD_HIDE<T>();
LOAD_ASYNC<T>();
LOAD_ASYNC_HIDE<T>();
SHOW<T>();
HIDE<T>();
UNLOAD<T>();
这些接口背后的区别很清楚:
bash
LOAD 加载并显示
LOAD_HIDE 加载但不显示
SHOW 已加载界面显示出来
HIDE 已加载界面隐藏起来
UNLOAD 隐藏并销毁界面
这比简单的 OpenUI 更适合大型项目。
因为大型项目里,UI 的状态不只有"打开"和"关闭"。
至少有这几种状态:
bash
未注册
已注册但未加载
正在异步加载
已加载但隐藏
已加载且显示
正在隐藏动画中
已销毁
这些状态如果没有框架统一管理,很容易出现重复加载、异步回调错乱、隐藏后还在更新、资源提前释放等问题。
MyFramework 里还有一个 mLoadingLayoutList,就是用来避免一个界面正在异步加载时,又被同步加载。
这种保护在小项目里看起来没必要,但在实际项目里非常重要。
因为 UI 打开入口可能来自很多地方:
bash
按钮点击
网络回包
场景流程
新手引导
系统推送
热更逻辑
没有统一保护,同一个界面被重复创建并不是小概率事件。
五、GameLayout 才是真正的界面实例
LayoutManager 负责管理所有界面,而每一个具体界面,则由 GameLayout 表示。
GameLayout 不是 MonoBehaviour,它是框架里的普通 C# 对象。
它内部持有:
bash
mRoot // 界面根节点
mScript // 界面脚本
mPrefab // Prefab 资源引用
mObjectList // 当前界面里所有已创建的 UI 对象
mNeedUpdateList // 当前界面里需要每帧更新的 UI 对象
mRenderOrder // 当前界面渲染顺序
mRenderOrderType // 渲染顺序计算方式
mBlurBack // 是否需要背景模糊
界面加载出来以后,初始化流程大致是:
bash
创建 LayoutScript
实例化 Prefab
创建根 myUGUICanvas
重置 RectTransform 锚点和尺寸
绑定 LayoutScript 的 Root
调用 assignWindow 绑定节点
执行自适应 Anchor
调用 LayoutScript.init
调用 LayoutScript.postInit
设置渲染顺序并刷新 UI 深度
初始化完成后默认隐藏
这里有一个关键点:
加载完成后不是立即随便显示,而是先把整个界面的内部结构初始化完整。
比如节点绑定、自适应、对象池初始化、窗口深度刷新,都是在显示之前完成的。
这样业务层拿到这个 LayoutScript 时,界面内部已经是一个完整可用的状态。
六、显示界面时,不只是 SetActive(true)
在 MyFramework 里,显示界面时确实会激活根节点。
但显示不是只有这一句。
GameLayout.setVisible(true) 的核心逻辑是:
bash
激活根节点
调用 LayoutScript.onGameState
onGameState 这个名字很有框架特点。
它不是简单叫 onShow,而是强调:
界面显示时,应该根据当前游戏状态重新调整自己。
比如同一个主界面,在不同流程下显示内容可能不同:
bash
主城状态
战斗状态
副本状态
观战状态
剧情状态
引导状态
界面不是只要显示出来就行,还要根据当前游戏状态重置显示内容。
所以 onGameState 里会做几类事情:
bash
中断之前未完成的界面命令
编辑器下检查 ScrollRect 是否注册
编辑器下检查 InputField 是否注册
重置一级子窗口对象
通知激活的子窗口 onShow
这说明 MyFramework 对 UI 显示的理解不是"打开窗口",而是"窗口进入当前游戏状态"。
这个区别很重要。
大型项目里,很多 UI bug 都不是界面没打开,而是界面打开后状态不干净。
比如:
bash
上一次打开留下的选中状态还在
上一次创建的对象池元素没回收
上一次注册的事件还在监听
上一次播放的动画命令还没结束
文本本地化对象重复注册
ScrollView 状态没有刷新
所以显示界面时必须有一个统一入口来重置状态。
七、隐藏界面时,也不只是 SetActive(false)
隐藏界面更容易被低估。
很多项目里隐藏 UI 就是:
bash
gameObject.SetActive(false);
但这只解决了"看不见"。
它没有解决这些问题:
bash
事件监听是否注销
输入监听是否注销
动画命令是否中断
对象池元素是否回收
子窗口是否收到隐藏通知
本地化对象是否清理
MyFramework 的 LayoutScript.onHide 会统一处理这些事情:
bash
中断所有命令
清理本地化注册
注销事件监听
通知所有根窗口对象 onHide
回收对象池中的对象
注销按键监听
这就是 UI 生命周期管理真正有价值的地方。
隐藏界面不是简单让它不可见,而是让它从当前交互系统里退出。
一个已经隐藏的界面,不应该继续收到事件。
一个已经隐藏的界面,不应该继续响应按键。
一个已经隐藏的界面,不应该继续持有临时对象池数据。
一个已经隐藏的界面,不应该继续播放之前的动画命令。
否则项目运行久了,就会出现各种奇怪问题。
比如界面已经关了,但还在响应网络事件。
界面已经隐藏了,但还在刷新列表。
界面已经切走了,但输入框还在被输入系统管理。
界面重新打开时,对象池里残留的是上一次的数据。
这些问题靠每个业务脚本自觉处理是不可靠的。
框架必须在基类里兜底。
八、为什么返回栈不应该放在 LayoutManager 里
很多 UI 框架喜欢在 UIManager 里做一个全局返回栈。
大概是这样:
bash
打开 A,压栈 A
打开 B,压栈 B
打开 C,压栈 C
点击返回,关闭 C
再点击返回,关闭 B
这种做法适合页面结构比较简单的 App。
但是游戏项目里的 UI 往往不是纯页面导航。
尤其是 MyFramework 这种结构里,返回关系通常不应该由 LayoutManager 统一决定,而是应该配合 GameScene 和 SceneProcedure 来实现。
原因很简单:
界面属于流程,而不是流程属于界面。
比如一个游戏场景里可能有这些流程:
bash
LoginScene
├── ProcedureLogin
├── ProcedureSelectServer
├── ProcedureCreateRole
└── ProcedureEnterGame
或者:
bash
MainScene
├── ProcedureMainCity
│ ├── ProcedureBag
│ ├── ProcedureShop
│ └── ProcedureTask
└── ProcedureBattle
├── ProcedureNormalBattle
└── ProcedureResult
SceneProcedure 是树形结构。
这意味着每个流程只需要关心自己负责的界面。
比如:
bash
ProcedureLogin 负责登录界面
ProcedureBag 负责背包界面
ProcedureShop 负责商店界面
ProcedureBattle 负责战斗界面
ProcedureResult 负责结算界面
当流程进入时,它打开自己的 UI。
当流程退出时,它关闭或卸载自己的 UI。
当从子流程返回父流程时,父流程通过 onInitFromChild 恢复自己的状态。
当进入子流程时,父流程通过 onExitToChild 决定自己是否隐藏部分 UI。
这样每个流程的责任边界非常清楚。
LayoutManager 不需要知道"点击返回时应该回到背包还是主城"。
这个问题应该由当前 GameScene 的 SceneProcedure 决定。
因为只有 SceneProcedure 知道当前处于什么游戏流程,知道返回以后应该恢复哪些界面,隐藏哪些界面,保留哪些状态。
九、树形 SceneProcedure 比全局 UI 返回栈更适合游戏
全局 UI 返回栈的问题在于,它把所有界面都当成了同一种东西。
但游戏里的界面不是同一层级的。
比如这些界面就不是一个概念:
bash
主界面
背包界面
弹窗确认框
战斗 HUD
副本结算
网络等待层
新手引导层
聊天窗口
系统跑马灯
如果全部塞进一个 UI 返回栈,就会很混乱。
例如:
bash
战斗 HUD 要不要进返回栈?
网络等待层要不要进返回栈?
确认弹窗要不要进返回栈?
新手引导界面要不要进返回栈?
常驻主界面要不要进返回栈?
这些问题最后都会变成各种特殊判断。
而树形 SceneProcedure 的优势是,它天然表达了流程关系。
父流程和子流程之间有明确关系。
兄弟流程之间有明确切换逻辑。
退出到共同父节点时,框架可以只退出必要的流程。
从子流程返回父流程时,也可以走专门的恢复入口。
MyFramework 的 SceneProcedure 里有几个很关键的函数:
bash
onInit
onInitFromChild
onExit
onExitToChild
onExitSelf
onPrepareExit
这些入口刚好对应游戏流程里的几种情况:
bash
第一次进入流程
从子流程返回当前流程
退出当前流程进入其他流程
退出当前流程进入自己的子流程
无论进入哪里都要执行的退出逻辑
准备退出但还没真正退出
这比单纯 UI 返回栈表达能力强很多。
因为它不是在管理"界面打开顺序",而是在管理"游戏流程状态"。
UI 只是流程状态的一部分。
十、一个流程只关心自己的界面
这种设计还有一个很大的好处:
每个 SceneProcedure 只需要关心自己的 UI。
比如登录流程只写登录相关逻辑:
bash
protected override void onInit(SceneProcedure lastProcedure)
{
LOAD<UILogin>();
}
protected override void onExit(SceneProcedure nextProcedure)
{
HIDE<UILogin>();
}
背包流程只写背包相关逻辑:
bash
protected override void onInit(SceneProcedure lastProcedure)
{
LOAD<UIBag>();
}
protected override void onExit(SceneProcedure nextProcedure)
{
HIDE<UIBag>();
}
子流程返回时,也只处理自己的恢复逻辑:
bash
protected override void onInitFromChild(SceneProcedure lastProcedure)
{
SHOW<UIMain>();
}
进入子流程时,也只处理自己需要暂时退让的内容:
bash
protected override void onExitToChild(SceneProcedure nextProcedure)
{
HIDE<UIMainMenu>();
}
这样 UI 管理不会集中堆在一个巨大的 UIManager 里。
每个流程只管自己的界面,父流程只管父流程的界面,子流程只管子流程的界面。
这才适合大型项目长期维护。
因为大型项目最怕的不是代码多,而是所有逻辑都集中在一个地方。
如果所有 UI 打开、关闭、返回、恢复都写在 LayoutManager 或 UIManager 里,最后一定会变成一个巨大的状态机。
而 SceneProcedure 树形结构的好处是,把状态机拆开了。
每个流程节点就是一小块状态机。
十一、LayoutManager 只做通用规则,不做业务决策
这也是 MyFramework 里 LayoutManager 设计比较克制的地方。
它提供这些能力:
bash
加载界面
异步加载界面
显示界面
隐藏界面
卸载界面
计算渲染顺序
管理 Esc 关闭
管理背景模糊
管理常驻和局部界面
管理 UI 根节点
刷新 UI 深度
但它不应该做这些事情:
bash
当前应该进入哪个业务页面
返回按钮应该回到哪里
某个流程退出时要不要保留某个界面
某个子流程返回后父流程应该恢复什么状态
这些都属于业务流程问题。
所以正确分层应该是:
bash
GameScene / SceneProcedure 负责流程
LayoutManager 负责界面生命周期
GameLayout 负责界面实例
LayoutScript 负责界面逻辑
WindowObject / myUGUIObject 负责具体节点
这个分层很清晰。
流程层决定"现在应该显示哪些界面"。
布局管理层负责"如何把这些界面正确显示出来"。
界面脚本层负责"这个界面显示什么内容"。
节点层负责"具体控件怎么响应和刷新"。
十二、渲染顺序也应该由框架统一管理
大型项目里,UI 层级也是一个常见问题。
比如:
bash
主界面
普通弹窗
二级弹窗
提示层
引导层
网络等待层
GM 窗口
系统错误弹窗
如果每个界面自己写 sortingOrder,很容易乱。
MyFramework 里用 LAYOUT_ORDER 来描述渲染顺序的计算方式:
bash
AUTO // 普通自动层级
FIXED // 固定层级
ALWAYS_TOP // 固定的最上层
ALWAYS_TOP_AUTO // 自动计算的最上层
这几个类型解决的是不同问题。
普通界面通常用 AUTO。
需要固定层级的场景 UI 可以用 FIXED。
提示层、引导层、最高弹窗可以用 ALWAYS_TOP 或 ALWAYS_TOP_AUTO。
显示界面时,如果是自动层级,框架会重新计算当前最高层级。
这样业务层不需要到处写:
bash
canvas.sortingOrder = 100;
canvas.sortingOrder = 200;
canvas.sortingOrder = 999;
也不需要猜哪个界面应该压过哪个界面。
框架里统一计算,才能保证规则一致。
十三、Esc 关闭不是简单关闭最后打开的界面
PC 端还有一个常见需求:按 Esc 关闭界面。
如果有全局 UI 返回栈,通常就是关闭栈顶界面。
但 MyFramework 里 Esc 的处理更接近渲染层级。
COMLayoutManagerEscHide 会维护当前显示中的界面列表,并按渲染顺序排序。
按下 Esc 时,从最上层界面往下传递:
bash
最上层界面先收到 Esc
如果它处理了,就停止传递
如果它不处理,再传给下一层
具体是否处理,由 LayoutScript.onESCDown 决定。
默认逻辑是:
bash
if (mEscHide)
{
close();
}
return mEscHide;
这比全局关栈更合理。
因为 Esc 关闭的是当前用户视觉上最上层、最应该响应的界面,而不是某个抽象栈里最后记录的界面。
并且每个界面可以自己决定是否响应 Esc。
比如:
bash
普通背包界面可以 Esc 关闭
强制剧情界面不能 Esc 关闭
网络等待界面不能 Esc 关闭
确认弹窗可以 Esc 关闭
战斗 HUD 不应该 Esc 关闭
这些判断放在界面脚本里,比放在全局管理器里更自然。
十四、背景模糊也应该跟随界面显示状态
很多项目会有这种效果:
bash
打开弹窗时,后面的界面变模糊
关闭弹窗时,后面的界面恢复正常
如果每个弹窗自己处理背景模糊,很容易出现问题。
比如:
bash
A 弹窗打开,背景模糊
B 弹窗又打开,背景仍然模糊
关闭 B 时,不应该取消 A 的模糊
关闭 A 时,才应该真正取消模糊
所以 MyFramework 里 LayoutManager 有一个 mBackBlurLayoutList。
当显示一个需要背景模糊的界面时,把它加入列表。
当隐藏时,从列表中移除。
然后根据当前列表里最高层的模糊界面,决定哪些低层界面要切到模糊层。
这类逻辑就非常适合放在 LayoutManager。
因为它不是某个具体界面的业务,而是多个界面之间的通用显示规则。
十五、更新也要受可见性控制
UI 更新也是大型项目里容易浪费性能的地方。
很多界面隐藏以后,脚本还在跑 Update。
很多节点实际上不需要每帧刷新,却一直在 Update。
很多列表隐藏以后还在刷新数据。
MyFramework 里 GameLayout.update 会先判断:
bash
界面是否可见
脚本是否存在
脚本是否已经初始化完成
只有可见且初始化完成的界面才会继续更新。
而且它还把更新拆成两部分:
bash
更新需要 Update 的 UI 对象
更新 LayoutScript 自己
其中 UI 对象还有一个 mNeedUpdateList。
也就是说,并不是界面里的所有节点都会无脑 Update。
只有需要更新的窗口对象才会放进更新列表。
这对大型 UI 很重要。
一个复杂界面里可能有几十甚至上百个 UI 节点,但真正需要每帧更新的可能只有几个。
框架层做这个区分,可以避免很多无意义开销。
十六、销毁时必须释放资源和清理引用
隐藏和销毁不是一回事。
隐藏只是暂时不用。
销毁是这个界面实例彻底退出。
MyFramework 里销毁界面时,会走:
bash
LayoutManager.destroyLayout
GameLayout.destroy
LayoutScript.destroy
myUGUIObject.destroyWindow
ResourceManager.unload
LayoutScript.destroy 里还会做兜底清理:
bash
清理本地化注册
销毁对象池
销毁窗口对象池
销毁根窗口对象
清理所有窗口对象列表
注销事件监听
注销按键监听
中断所有命令
这很关键。
因为隐藏时可能只是临时关闭,销毁时则必须彻底清理。
尤其是框架里有事件系统、命令系统、对象池、本地化、输入系统,如果销毁时没有统一兜底,很容易留下悬空引用。
大型项目里很多内存泄漏、重复回调、空对象报错,最后都能追到生命周期没管好。
十七、场景退出时统一卸载局部 UI
SceneProcedure 里有一个通用退出逻辑:
bash
genericExit()
其中就会调用:
bash
mLayoutManager.unloadAllPartLayout();
这代表一个很明确的规则:
场景退出时,局部界面不应该残留。
因为 PART_USE 界面本来就是某些流程临时使用的。
离开场景或者退出流程后,继续留着没有意义,还可能持有不该持有的资源。
而 PERSIST 界面则可以保留。
这比在每个流程里手动 UNLOAD 一堆界面更稳。
流程可以负责自己明确打开和隐藏的界面。
而场景退出这种大清理,则由框架统一兜底。
十八、总结
MyFramework 里的 UI 生命周期管理,核心不是多封装几个 Show、Hide 函数。
它真正做的是把 UI 拆成了几层:
bash
LayoutManager 管理所有界面的注册、加载、显示、隐藏、销毁
GameLayout 表示一个具体界面实例
LayoutScript 承载一个界面的业务逻辑和显示状态
myUGUIObject 表示具体 UI 节点
SceneProcedure 决定当前流程应该显示哪些界面
这里最关键的一点是:
LayoutManager 不负责业务返回栈。
返回关系、流程跳转、父子流程恢复,应该放在 GameScene + SceneProcedure 里。
因为 SceneProcedure 是树形的,每个流程只需要关心自己的界面。
父流程管父流程的 UI,子流程管子流程的 UI。
进入流程时显示自己的界面,退出流程时隐藏自己的界面,从子流程返回时恢复自己的状态。
这样 UI 生命周期和游戏流程就不会混在一起。
LayoutManager 负责通用能力:
bash
加载
显示
隐藏
卸载
渲染顺序
Esc 关闭
背景模糊
对象更新
资源释放
SceneProcedure 负责业务流程:
bash
现在处于哪个状态
应该进入哪个子流程
返回时回到哪个流程
这个流程应该显示哪些界面
这个流程退出时应该隐藏哪些界面
这套分层的好处是,项目越大越明显。
小项目里,一个 UIManager 什么都管也能跑。
但大型项目里,UI 管理一定要拆清楚。
界面的生命周期归 LayoutManager。
界面的业务状态归 LayoutScript。
界面的流程关系归 SceneProcedure。
只有这样,UI 才不会变成一个巨大的、谁都不敢改的全局状态机。