MyFramework:Unity大型项目里的 UI 生命周期应该怎么管理

📅 2026/7/7 11:25:34 👁️ 阅读次数 📝 编程学习
MyFramework:Unity大型项目里的 UI 生命周期应该怎么管理

做 Unity 项目时,UI 管理一开始通常都很简单。

打开界面:

panel.SetActive(true);

关闭界面:

panel.SetActive(false);

销毁界面:

Destroy(panel);

Demo 阶段这样写没有问题。

但是项目一旦变大,UI 就不会只是“显示”和“隐藏”这么简单了。

一个界面从出现到消失,背后通常还牵扯到很多事情:

Prefab 加载 节点绑定 脚本初始化 点击事件注册 输入框注册 ScrollView 初始化 对象池初始化 本地化文本收集 界面显示刷新 界面隐藏清理 动画命令中断 对象池回收 事件监听注销 资源卸载 渲染层级调整 Esc 关闭逻辑 背景模糊处理

这些事情如果都散落在各个 UI 脚本里,项目越做越大,后面就会越来越难维护。

所以 MyFramework 里有一个LayoutManager

它解决的不是“怎么打开一个界面”这么简单的问题,而是:

一个大型 Unity 项目里,UI 从加载、显示、更新、隐藏到销毁,应该由谁来管,怎么管,管到什么程度。


一、UI 不应该只是一个 GameObject

很多项目里的 UI 管理都是围绕 GameObject 做的。

比如:

GameObject loginPanel; GameObject mainPanel; GameObject bagPanel;

然后业务逻辑里到处写:

loginPanel.SetActive(true); bagPanel.SetActive(false);

这种写法最大的问题是,UI 被当成了一个普通节点。

但是在大型项目里,一个界面并不只是一个 GameObject。

它至少应该包含三层概念:

Prefab 资源 界面实例 界面逻辑脚本

在 MyFramework 里,这三层分别对应:

ResourceRef<GameObject> // 界面 Prefab 资源引用 GameLayout // 界面实例对象 LayoutScript // 界面逻辑脚本

也就是说,框架不会让业务直接管理一个裸的 GameObject。

业务层看到的是LayoutScript,框架内部管理的是GameLayout,资源层持有的是ResourceRef<GameObject>

这样做的好处是,界面的生命周期就可以被完整接管。

加载时,LayoutManager 负责实例化 Prefab。
初始化时,GameLayout 负责创建根节点和绑定脚本。
显示时,LayoutScript 负责刷新界面状态。
隐藏时,LayoutScript 负责清理监听和回收对象。
销毁时,GameLayout 负责销毁 UI 对象并释放资源引用。

这比单纯SetActive要可靠得多。


二、LayoutManager 管的是“界面资源和界面实例”

LayoutManager里最核心的几个数据结构,大概可以理解成这样:

mLayoutRegisteList // 所有已注册的界面信息 mLayoutList // 当前已经加载出来的界面实例 mLayoutTypeToPath // LayoutScript 类型到 Prefab 路径的映射 mLayoutPathToType // Prefab 路径到 LayoutScript 类型的映射 mLoadingLayoutList // 正在异步加载的界面 mBackBlurLayoutList // 需要背景模糊的界面

这几个列表决定了 LayoutManager 的定位。

它不是业务导航器。
它不是页面返回栈。
它也不是具体界面的逻辑控制器。

它真正负责的是:

这个界面有没有注册 这个界面的 Prefab 在哪里 这个界面有没有加载 这个界面是否正在异步加载 这个界面当前是否显示 这个界面当前渲染顺序是多少 这个界面是否需要背景模糊 这个界面什么时候销毁

所以 LayoutManager 的职责是比较底层、比较稳定的。

它不关心玩家现在是在登录流程、主城流程、战斗流程,还是副本流程。

它只关心:

某个 LayoutScript 对应的界面资源,应该如何被加载、显示、隐藏和释放。


三、界面注册:先让框架知道这个界面是什么

MyFramework 里的界面不是随便传一个路径加载,而是先注册。

比如:

LayoutManager.registeLayout<UILogin>(LAYOUT_LIFE_CYCLE.PART_USE); LayoutManager.registeLayoutPersist<UIMain>(null);

注册时会记录几个关键东西:

界面脚本类型 界面 Prefab 路径 界面生命周期类型 界面创建后的回调

生命周期目前主要分两类:

PERSIST // 全局常驻 PART_USE // 局部使用

这个设计很重要。

因为大型项目里的 UI 并不是所有界面都应该一样处理。

有些界面是全局常驻的,比如主界面、提示层、网络等待层、通用弹窗层。
这些界面可能会跨流程、跨场景存在。

有些界面只是某个流程临时使用,比如登录界面、创建角色界面、某个活动界面、副本结算界面。
这些界面在离开对应流程后就应该卸载。

所以 LayoutManager 不是只提供一个Load,而是把界面的生命周期属性也登记进来。

后面场景流程退出时,就可以统一调用:

mLayoutManager.unloadAllPartLayout();

这样所有PART_USE类型的界面都会被销毁,而PERSIST类型的界面会继续保留。

这就是框架层生命周期管理的意义。

不是每个界面自己判断“我该不该销毁”,而是注册时就把规则确定下来。


四、加载和显示要分开

很多项目里,打开界面时会把加载和显示混在一起:

LoadPanel(); ShowPanel();

看起来没问题,但实际项目里会遇到很多情况。

比如:

进入场景前提前加载 UI,但是暂时不显示 打开界面时如果已经加载过,就只显示 异步加载完成后再显示 某些界面要预加载,避免打开时卡顿 某些界面只加载一次,后续重复使用

所以 MyFramework 里提供了多种入口:

LOAD<T>(); LOAD_HIDE<T>(); LOAD_ASYNC<T>(); LOAD_ASYNC_HIDE<T>(); SHOW<T>(); HIDE<T>(); UNLOAD<T>();

这些接口背后的区别很清楚:

LOAD 加载并显示 LOAD_HIDE 加载但不显示 SHOW 已加载界面显示出来 HIDE 已加载界面隐藏起来 UNLOAD 隐藏并销毁界面

这比简单的OpenUI更适合大型项目。

因为大型项目里,UI 的状态不只有“打开”和“关闭”。

至少有这几种状态:

未注册 已注册但未加载 正在异步加载 已加载但隐藏 已加载且显示 正在隐藏动画中 已销毁

这些状态如果没有框架统一管理,很容易出现重复加载、异步回调错乱、隐藏后还在更新、资源提前释放等问题。

MyFramework 里还有一个mLoadingLayoutList,就是用来避免一个界面正在异步加载时,又被同步加载。

这种保护在小项目里看起来没必要,但在实际项目里非常重要。

因为 UI 打开入口可能来自很多地方:

按钮点击 网络回包 场景流程 新手引导 系统推送 热更逻辑

没有统一保护,同一个界面被重复创建并不是小概率事件。


五、GameLayout 才是真正的界面实例

LayoutManager负责管理所有界面,而每一个具体界面,则由GameLayout表示。

GameLayout不是 MonoBehaviour,它是框架里的普通 C# 对象。

它内部持有:

mRoot // 界面根节点 mScript // 界面脚本 mPrefab // Prefab 资源引用 mObjectList // 当前界面里所有已创建的 UI 对象 mNeedUpdateList // 当前界面里需要每帧更新的 UI 对象 mRenderOrder // 当前界面渲染顺序 mRenderOrderType // 渲染顺序计算方式 mBlurBack // 是否需要背景模糊

界面加载出来以后,初始化流程大致是:

创建 LayoutScript 实例化 Prefab 创建根 myUGUICanvas 重置 RectTransform 锚点和尺寸 绑定 LayoutScript 的 Root 调用 assignWindow 绑定节点 执行自适应 Anchor 调用 LayoutScript.init 调用 LayoutScript.postInit 设置渲染顺序并刷新 UI 深度 初始化完成后默认隐藏

这里有一个关键点:

加载完成后不是立即随便显示,而是先把整个界面的内部结构初始化完整。

比如节点绑定、自适应、对象池初始化、窗口深度刷新,都是在显示之前完成的。

这样业务层拿到这个 LayoutScript 时,界面内部已经是一个完整可用的状态。


六、显示界面时,不只是 SetActive(true)

在 MyFramework 里,显示界面时确实会激活根节点。

但显示不是只有这一句。

GameLayout.setVisible(true)的核心逻辑是:

激活根节点 调用 LayoutScript.onGameState

onGameState这个名字很有框架特点。

它不是简单叫onShow,而是强调:

界面显示时,应该根据当前游戏状态重新调整自己。

比如同一个主界面,在不同流程下显示内容可能不同:

主城状态 战斗状态 副本状态 观战状态 剧情状态 引导状态

界面不是只要显示出来就行,还要根据当前游戏状态重置显示内容。

所以onGameState里会做几类事情:

中断之前未完成的界面命令 编辑器下检查 ScrollRect 是否注册 编辑器下检查 InputField 是否注册 重置一级子窗口对象 通知激活的子窗口 onShow

这说明 MyFramework 对 UI 显示的理解不是“打开窗口”,而是“窗口进入当前游戏状态”。

这个区别很重要。

大型项目里,很多 UI bug 都不是界面没打开,而是界面打开后状态不干净。

比如:

上一次打开留下的选中状态还在 上一次创建的对象池元素没回收 上一次注册的事件还在监听 上一次播放的动画命令还没结束 文本本地化对象重复注册 ScrollView 状态没有刷新

所以显示界面时必须有一个统一入口来重置状态。


七、隐藏界面时,也不只是 SetActive(false)

隐藏界面更容易被低估。

很多项目里隐藏 UI 就是:

gameObject.SetActive(false);

但这只解决了“看不见”。

它没有解决这些问题:

事件监听是否注销 输入监听是否注销 动画命令是否中断 对象池元素是否回收 子窗口是否收到隐藏通知 本地化对象是否清理

MyFramework 的LayoutScript.onHide会统一处理这些事情:

中断所有命令 清理本地化注册 注销事件监听 通知所有根窗口对象 onHide 回收对象池中的对象 注销按键监听

这就是 UI 生命周期管理真正有价值的地方。

隐藏界面不是简单让它不可见,而是让它从当前交互系统里退出。

一个已经隐藏的界面,不应该继续收到事件。
一个已经隐藏的界面,不应该继续响应按键。
一个已经隐藏的界面,不应该继续持有临时对象池数据。
一个已经隐藏的界面,不应该继续播放之前的动画命令。

否则项目运行久了,就会出现各种奇怪问题。

比如界面已经关了,但还在响应网络事件。
界面已经隐藏了,但还在刷新列表。
界面已经切走了,但输入框还在被输入系统管理。
界面重新打开时,对象池里残留的是上一次的数据。

这些问题靠每个业务脚本自觉处理是不可靠的。
框架必须在基类里兜底。


八、为什么返回栈不应该放在 LayoutManager 里

很多 UI 框架喜欢在 UIManager 里做一个全局返回栈。

大概是这样:

打开 A,压栈 A 打开 B,压栈 B 打开 C,压栈 C 点击返回,关闭 C 再点击返回,关闭 B

这种做法适合页面结构比较简单的 App。

但是游戏项目里的 UI 往往不是纯页面导航。

尤其是 MyFramework 这种结构里,返回关系通常不应该由 LayoutManager 统一决定,而是应该配合GameSceneSceneProcedure来实现。

原因很简单:

界面属于流程,而不是流程属于界面。

比如一个游戏场景里可能有这些流程:

LoginScene ├── ProcedureLogin ├── ProcedureSelectServer ├── ProcedureCreateRole └── ProcedureEnterGame

或者:

MainScene ├── ProcedureMainCity │ ├── ProcedureBag │ ├── ProcedureShop │ └── ProcedureTask └── ProcedureBattle ├── ProcedureNormalBattle └── ProcedureResult

SceneProcedure是树形结构。

这意味着每个流程只需要关心自己负责的界面。

比如:

ProcedureLogin 负责登录界面 ProcedureBag 负责背包界面 ProcedureShop 负责商店界面 ProcedureBattle 负责战斗界面 ProcedureResult 负责结算界面

当流程进入时,它打开自己的 UI。
当流程退出时,它关闭或卸载自己的 UI。
当从子流程返回父流程时,父流程通过onInitFromChild恢复自己的状态。
当进入子流程时,父流程通过onExitToChild决定自己是否隐藏部分 UI。

这样每个流程的责任边界非常清楚。

LayoutManager 不需要知道“点击返回时应该回到背包还是主城”。
这个问题应该由当前 GameScene 的 SceneProcedure 决定。

因为只有 SceneProcedure 知道当前处于什么游戏流程,知道返回以后应该恢复哪些界面,隐藏哪些界面,保留哪些状态。


九、树形 SceneProcedure 比全局 UI 返回栈更适合游戏

全局 UI 返回栈的问题在于,它把所有界面都当成了同一种东西。

但游戏里的界面不是同一层级的。

比如这些界面就不是一个概念:

主界面 背包界面 弹窗确认框 战斗 HUD 副本结算 网络等待层 新手引导层 聊天窗口 系统跑马灯

如果全部塞进一个 UI 返回栈,就会很混乱。

例如:

战斗 HUD 要不要进返回栈? 网络等待层要不要进返回栈? 确认弹窗要不要进返回栈? 新手引导界面要不要进返回栈? 常驻主界面要不要进返回栈?

这些问题最后都会变成各种特殊判断。

而树形 SceneProcedure 的优势是,它天然表达了流程关系。

父流程和子流程之间有明确关系。
兄弟流程之间有明确切换逻辑。
退出到共同父节点时,框架可以只退出必要的流程。
从子流程返回父流程时,也可以走专门的恢复入口。

MyFramework 的SceneProcedure里有几个很关键的函数:

onInit onInitFromChild onExit onExitToChild onExitSelf onPrepareExit

这些入口刚好对应游戏流程里的几种情况:

第一次进入流程 从子流程返回当前流程 退出当前流程进入其他流程 退出当前流程进入自己的子流程 无论进入哪里都要执行的退出逻辑 准备退出但还没真正退出

这比单纯 UI 返回栈表达能力强很多。

因为它不是在管理“界面打开顺序”,而是在管理“游戏流程状态”。

UI 只是流程状态的一部分。


十、一个流程只关心自己的界面

这种设计还有一个很大的好处:

每个 SceneProcedure 只需要关心自己的 UI。

比如登录流程只写登录相关逻辑:

protected override void onInit(SceneProcedure lastProcedure) { LOAD<UILogin>(); } protected override void onExit(SceneProcedure nextProcedure) { HIDE<UILogin>(); }

背包流程只写背包相关逻辑:

protected override void onInit(SceneProcedure lastProcedure) { LOAD<UIBag>(); } protected override void onExit(SceneProcedure nextProcedure) { HIDE<UIBag>(); }

子流程返回时,也只处理自己的恢复逻辑:

protected override void onInitFromChild(SceneProcedure lastProcedure) { SHOW<UIMain>(); }

进入子流程时,也只处理自己需要暂时退让的内容:

protected override void onExitToChild(SceneProcedure nextProcedure) { HIDE<UIMainMenu>(); }

这样 UI 管理不会集中堆在一个巨大的 UIManager 里。

每个流程只管自己的界面,父流程只管父流程的界面,子流程只管子流程的界面。

这才适合大型项目长期维护。

因为大型项目最怕的不是代码多,而是所有逻辑都集中在一个地方。

如果所有 UI 打开、关闭、返回、恢复都写在 LayoutManager 或 UIManager 里,最后一定会变成一个巨大的状态机。

而 SceneProcedure 树形结构的好处是,把状态机拆开了。

每个流程节点就是一小块状态机。


十一、LayoutManager 只做通用规则,不做业务决策

这也是 MyFramework 里 LayoutManager 设计比较克制的地方。

它提供这些能力:

加载界面 异步加载界面 显示界面 隐藏界面 卸载界面 计算渲染顺序 管理 Esc 关闭 管理背景模糊 管理常驻和局部界面 管理 UI 根节点 刷新 UI 深度

但它不应该做这些事情:

当前应该进入哪个业务页面 返回按钮应该回到哪里 某个流程退出时要不要保留某个界面 某个子流程返回后父流程应该恢复什么状态

这些都属于业务流程问题。

所以正确分层应该是:

GameScene / SceneProcedure 负责流程 LayoutManager 负责界面生命周期 GameLayout 负责界面实例 LayoutScript 负责界面逻辑 WindowObject / myUGUIObject 负责具体节点

这个分层很清晰。

流程层决定“现在应该显示哪些界面”。
布局管理层负责“如何把这些界面正确显示出来”。
界面脚本层负责“这个界面显示什么内容”。
节点层负责“具体控件怎么响应和刷新”。


十二、渲染顺序也应该由框架统一管理

大型项目里,UI 层级也是一个常见问题。

比如:

主界面 普通弹窗 二级弹窗 提示层 引导层 网络等待层 GM 窗口 系统错误弹窗

如果每个界面自己写 sortingOrder,很容易乱。

MyFramework 里用LAYOUT_ORDER来描述渲染顺序的计算方式:

AUTO // 普通自动层级 FIXED // 固定层级 ALWAYS_TOP // 固定的最上层 ALWAYS_TOP_AUTO // 自动计算的最上层

这几个类型解决的是不同问题。

普通界面通常用AUTO
需要固定层级的场景 UI 可以用FIXED
提示层、引导层、最高弹窗可以用ALWAYS_TOPALWAYS_TOP_AUTO

显示界面时,如果是自动层级,框架会重新计算当前最高层级。

这样业务层不需要到处写:

canvas.sortingOrder = 100; canvas.sortingOrder = 200; canvas.sortingOrder = 999;

也不需要猜哪个界面应该压过哪个界面。

框架里统一计算,才能保证规则一致。


十三、Esc 关闭不是简单关闭最后打开的界面

PC 端还有一个常见需求:按 Esc 关闭界面。

如果有全局 UI 返回栈,通常就是关闭栈顶界面。

但 MyFramework 里 Esc 的处理更接近渲染层级。

COMLayoutManagerEscHide会维护当前显示中的界面列表,并按渲染顺序排序。

按下 Esc 时,从最上层界面往下传递:

最上层界面先收到 Esc 如果它处理了,就停止传递 如果它不处理,再传给下一层

具体是否处理,由LayoutScript.onESCDown决定。

默认逻辑是:

if (mEscHide) { close(); } return mEscHide;

这比全局关栈更合理。

因为 Esc 关闭的是当前用户视觉上最上层、最应该响应的界面,而不是某个抽象栈里最后记录的界面。

并且每个界面可以自己决定是否响应 Esc。

比如:

普通背包界面可以 Esc 关闭 强制剧情界面不能 Esc 关闭 网络等待界面不能 Esc 关闭 确认弹窗可以 Esc 关闭 战斗 HUD 不应该 Esc 关闭

这些判断放在界面脚本里,比放在全局管理器里更自然。


十四、背景模糊也应该跟随界面显示状态

很多项目会有这种效果:

打开弹窗时,后面的界面变模糊 关闭弹窗时,后面的界面恢复正常

如果每个弹窗自己处理背景模糊,很容易出现问题。

比如:

A 弹窗打开,背景模糊 B 弹窗又打开,背景仍然模糊 关闭 B 时,不应该取消 A 的模糊 关闭 A 时,才应该真正取消模糊

所以 MyFramework 里 LayoutManager 有一个mBackBlurLayoutList

当显示一个需要背景模糊的界面时,把它加入列表。
当隐藏时,从列表中移除。
然后根据当前列表里最高层的模糊界面,决定哪些低层界面要切到模糊层。

这类逻辑就非常适合放在 LayoutManager。

因为它不是某个具体界面的业务,而是多个界面之间的通用显示规则。


十五、更新也要受可见性控制

UI 更新也是大型项目里容易浪费性能的地方。

很多界面隐藏以后,脚本还在跑 Update。
很多节点实际上不需要每帧刷新,却一直在 Update。
很多列表隐藏以后还在刷新数据。

MyFramework 里GameLayout.update会先判断:

界面是否可见 脚本是否存在 脚本是否已经初始化完成

只有可见且初始化完成的界面才会继续更新。

而且它还把更新拆成两部分:

更新需要 Update 的 UI 对象 更新 LayoutScript 自己

其中 UI 对象还有一个mNeedUpdateList

也就是说,并不是界面里的所有节点都会无脑 Update。

只有需要更新的窗口对象才会放进更新列表。

这对大型 UI 很重要。

一个复杂界面里可能有几十甚至上百个 UI 节点,但真正需要每帧更新的可能只有几个。

框架层做这个区分,可以避免很多无意义开销。


十六、销毁时必须释放资源和清理引用

隐藏和销毁不是一回事。

隐藏只是暂时不用。
销毁是这个界面实例彻底退出。

MyFramework 里销毁界面时,会走:

LayoutManager.destroyLayout GameLayout.destroy LayoutScript.destroy myUGUIObject.destroyWindow ResourceManager.unload

LayoutScript.destroy里还会做兜底清理:

清理本地化注册 销毁对象池 销毁窗口对象池 销毁根窗口对象 清理所有窗口对象列表 注销事件监听 注销按键监听 中断所有命令

这很关键。

因为隐藏时可能只是临时关闭,销毁时则必须彻底清理。

尤其是框架里有事件系统、命令系统、对象池、本地化、输入系统,如果销毁时没有统一兜底,很容易留下悬空引用。

大型项目里很多内存泄漏、重复回调、空对象报错,最后都能追到生命周期没管好。


十七、场景退出时统一卸载局部 UI

SceneProcedure里有一个通用退出逻辑:

genericExit()

其中就会调用:

mLayoutManager.unloadAllPartLayout();

这代表一个很明确的规则:

场景退出时,局部界面不应该残留。

因为PART_USE界面本来就是某些流程临时使用的。
离开场景或者退出流程后,继续留着没有意义,还可能持有不该持有的资源。

PERSIST界面则可以保留。

这比在每个流程里手动UNLOAD一堆界面更稳。

流程可以负责自己明确打开和隐藏的界面。
而场景退出这种大清理,则由框架统一兜底。


十八、总结

MyFramework 里的 UI 生命周期管理,核心不是多封装几个ShowHide函数。

它真正做的是把 UI 拆成了几层:

LayoutManager 管理所有界面的注册、加载、显示、隐藏、销毁 GameLayout 表示一个具体界面实例 LayoutScript 承载一个界面的业务逻辑和显示状态 myUGUIObject 表示具体 UI 节点 SceneProcedure 决定当前流程应该显示哪些界面

这里最关键的一点是:

LayoutManager 不负责业务返回栈。

返回关系、流程跳转、父子流程恢复,应该放在GameScene + SceneProcedure里。

因为SceneProcedure是树形的,每个流程只需要关心自己的界面。
父流程管父流程的 UI,子流程管子流程的 UI。
进入流程时显示自己的界面,退出流程时隐藏自己的界面,从子流程返回时恢复自己的状态。

这样 UI 生命周期和游戏流程就不会混在一起。

LayoutManager 负责通用能力:

加载 显示 隐藏 卸载 渲染顺序 Esc 关闭 背景模糊 对象更新 资源释放

SceneProcedure 负责业务流程:

现在处于哪个状态 应该进入哪个子流程 返回时回到哪个流程 这个流程应该显示哪些界面 这个流程退出时应该隐藏哪些界面

这套分层的好处是,项目越大越明显。

小项目里,一个 UIManager 什么都管也能跑。
但大型项目里,UI 管理一定要拆清楚。

界面的生命周期归 LayoutManager。
界面的业务状态归 LayoutScript。
界面的流程关系归 SceneProcedure。

只有这样,UI 才不会变成一个巨大的、谁都不敢改的全局状态机。