做 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 里这三层分别对应ResourceRefGameObject // 界面 Prefab 资源引用 GameLayout // 界面实例对象 LayoutScript // 界面逻辑脚本也就是说框架不会让业务直接管理一个裸的 GameObject。业务层看到的是LayoutScript框架内部管理的是GameLayout资源层持有的是ResourceRefGameObject。这样做的好处是界面的生命周期就可以被完整接管。加载时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.registeLayoutUILogin(LAYOUT_LIFE_CYCLE.PART_USE); LayoutManager.registeLayoutPersistUIMain(null);注册时会记录几个关键东西界面脚本类型 界面 Prefab 路径 界面生命周期类型 界面创建后的回调生命周期目前主要分两类PERSIST // 全局常驻 PART_USE // 局部使用这个设计很重要。因为大型项目里的 UI 并不是所有界面都应该一样处理。有些界面是全局常驻的比如主界面、提示层、网络等待层、通用弹窗层。这些界面可能会跨流程、跨场景存在。有些界面只是某个流程临时使用比如登录界面、创建角色界面、某个活动界面、副本结算界面。这些界面在离开对应流程后就应该卸载。所以 LayoutManager 不是只提供一个Load而是把界面的生命周期属性也登记进来。后面场景流程退出时就可以统一调用mLayoutManager.unloadAllPartLayout();这样所有PART_USE类型的界面都会被销毁而PERSIST类型的界面会继续保留。这就是框架层生命周期管理的意义。不是每个界面自己判断“我该不该销毁”而是注册时就把规则确定下来。四、加载和显示要分开很多项目里打开界面时会把加载和显示混在一起LoadPanel(); ShowPanel();看起来没问题但实际项目里会遇到很多情况。比如进入场景前提前加载 UI但是暂时不显示 打开界面时如果已经加载过就只显示 异步加载完成后再显示 某些界面要预加载避免打开时卡顿 某些界面只加载一次后续重复使用所以 MyFramework 里提供了多种入口LOADT(); LOAD_HIDET(); LOAD_ASYNCT(); LOAD_ASYNC_HIDET(); SHOWT(); HIDET(); UNLOADT();这些接口背后的区别很清楚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.onGameStateonGameState这个名字很有框架特点。它不是简单叫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 统一决定而是应该配合GameScene和SceneProcedure来实现。原因很简单界面属于流程而不是流程属于界面。比如一个游戏场景里可能有这些流程LoginScene ├── ProcedureLogin ├── ProcedureSelectServer ├── ProcedureCreateRole └── ProcedureEnterGame或者MainScene ├── ProcedureMainCity │ ├── ProcedureBag │ ├── ProcedureShop │ └── ProcedureTask └── ProcedureBattle ├── ProcedureNormalBattle └── ProcedureResultSceneProcedure是树形结构。这意味着每个流程只需要关心自己负责的界面。比如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) { LOADUILogin(); } protected override void onExit(SceneProcedure nextProcedure) { HIDEUILogin(); }背包流程只写背包相关逻辑protected override void onInit(SceneProcedure lastProcedure) { LOADUIBag(); } protected override void onExit(SceneProcedure nextProcedure) { HIDEUIBag(); }子流程返回时也只处理自己的恢复逻辑protected override void onInitFromChild(SceneProcedure lastProcedure) { SHOWUIMain(); }进入子流程时也只处理自己需要暂时退让的内容protected override void onExitToChild(SceneProcedure nextProcedure) { HIDEUIMainMenu(); }这样 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_TOP或ALWAYS_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.unloadLayoutScript.destroy里还会做兜底清理清理本地化注册 销毁对象池 销毁窗口对象池 销毁根窗口对象 清理所有窗口对象列表 注销事件监听 注销按键监听 中断所有命令这很关键。因为隐藏时可能只是临时关闭销毁时则必须彻底清理。尤其是框架里有事件系统、命令系统、对象池、本地化、输入系统如果销毁时没有统一兜底很容易留下悬空引用。大型项目里很多内存泄漏、重复回调、空对象报错最后都能追到生命周期没管好。十七、场景退出时统一卸载局部 UISceneProcedure里有一个通用退出逻辑genericExit()其中就会调用mLayoutManager.unloadAllPartLayout();这代表一个很明确的规则场景退出时局部界面不应该残留。因为PART_USE界面本来就是某些流程临时使用的。离开场景或者退出流程后继续留着没有意义还可能持有不该持有的资源。而PERSIST界面则可以保留。这比在每个流程里手动UNLOAD一堆界面更稳。流程可以负责自己明确打开和隐藏的界面。而场景退出这种大清理则由框架统一兜底。十八、总结MyFramework 里的 UI 生命周期管理核心不是多封装几个Show、Hide函数。它真正做的是把 UI 拆成了几层LayoutManager 管理所有界面的注册、加载、显示、隐藏、销毁 GameLayout 表示一个具体界面实例 LayoutScript 承载一个界面的业务逻辑和显示状态 myUGUIObject 表示具体 UI 节点 SceneProcedure 决定当前流程应该显示哪些界面这里最关键的一点是LayoutManager 不负责业务返回栈。返回关系、流程跳转、父子流程恢复应该放在GameScene SceneProcedure里。因为SceneProcedure是树形的每个流程只需要关心自己的界面。父流程管父流程的 UI子流程管子流程的 UI。进入流程时显示自己的界面退出流程时隐藏自己的界面从子流程返回时恢复自己的状态。这样 UI 生命周期和游戏流程就不会混在一起。LayoutManager 负责通用能力加载 显示 隐藏 卸载 渲染顺序 Esc 关闭 背景模糊 对象更新 资源释放SceneProcedure 负责业务流程现在处于哪个状态 应该进入哪个子流程 返回时回到哪个流程 这个流程应该显示哪些界面 这个流程退出时应该隐藏哪些界面这套分层的好处是项目越大越明显。小项目里一个 UIManager 什么都管也能跑。但大型项目里UI 管理一定要拆清楚。界面的生命周期归 LayoutManager。界面的业务状态归 LayoutScript。界面的流程关系归 SceneProcedure。只有这样UI 才不会变成一个巨大的、谁都不敢改的全局状态机。
MyFramework:Unity大型项目里的 UI 生命周期应该怎么管理
发布时间:2026/7/7 11:08:13
做 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 里这三层分别对应ResourceRefGameObject // 界面 Prefab 资源引用 GameLayout // 界面实例对象 LayoutScript // 界面逻辑脚本也就是说框架不会让业务直接管理一个裸的 GameObject。业务层看到的是LayoutScript框架内部管理的是GameLayout资源层持有的是ResourceRefGameObject。这样做的好处是界面的生命周期就可以被完整接管。加载时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.registeLayoutUILogin(LAYOUT_LIFE_CYCLE.PART_USE); LayoutManager.registeLayoutPersistUIMain(null);注册时会记录几个关键东西界面脚本类型 界面 Prefab 路径 界面生命周期类型 界面创建后的回调生命周期目前主要分两类PERSIST // 全局常驻 PART_USE // 局部使用这个设计很重要。因为大型项目里的 UI 并不是所有界面都应该一样处理。有些界面是全局常驻的比如主界面、提示层、网络等待层、通用弹窗层。这些界面可能会跨流程、跨场景存在。有些界面只是某个流程临时使用比如登录界面、创建角色界面、某个活动界面、副本结算界面。这些界面在离开对应流程后就应该卸载。所以 LayoutManager 不是只提供一个Load而是把界面的生命周期属性也登记进来。后面场景流程退出时就可以统一调用mLayoutManager.unloadAllPartLayout();这样所有PART_USE类型的界面都会被销毁而PERSIST类型的界面会继续保留。这就是框架层生命周期管理的意义。不是每个界面自己判断“我该不该销毁”而是注册时就把规则确定下来。四、加载和显示要分开很多项目里打开界面时会把加载和显示混在一起LoadPanel(); ShowPanel();看起来没问题但实际项目里会遇到很多情况。比如进入场景前提前加载 UI但是暂时不显示 打开界面时如果已经加载过就只显示 异步加载完成后再显示 某些界面要预加载避免打开时卡顿 某些界面只加载一次后续重复使用所以 MyFramework 里提供了多种入口LOADT(); LOAD_HIDET(); LOAD_ASYNCT(); LOAD_ASYNC_HIDET(); SHOWT(); HIDET(); UNLOADT();这些接口背后的区别很清楚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.onGameStateonGameState这个名字很有框架特点。它不是简单叫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 统一决定而是应该配合GameScene和SceneProcedure来实现。原因很简单界面属于流程而不是流程属于界面。比如一个游戏场景里可能有这些流程LoginScene ├── ProcedureLogin ├── ProcedureSelectServer ├── ProcedureCreateRole └── ProcedureEnterGame或者MainScene ├── ProcedureMainCity │ ├── ProcedureBag │ ├── ProcedureShop │ └── ProcedureTask └── ProcedureBattle ├── ProcedureNormalBattle └── ProcedureResultSceneProcedure是树形结构。这意味着每个流程只需要关心自己负责的界面。比如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) { LOADUILogin(); } protected override void onExit(SceneProcedure nextProcedure) { HIDEUILogin(); }背包流程只写背包相关逻辑protected override void onInit(SceneProcedure lastProcedure) { LOADUIBag(); } protected override void onExit(SceneProcedure nextProcedure) { HIDEUIBag(); }子流程返回时也只处理自己的恢复逻辑protected override void onInitFromChild(SceneProcedure lastProcedure) { SHOWUIMain(); }进入子流程时也只处理自己需要暂时退让的内容protected override void onExitToChild(SceneProcedure nextProcedure) { HIDEUIMainMenu(); }这样 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_TOP或ALWAYS_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.unloadLayoutScript.destroy里还会做兜底清理清理本地化注册 销毁对象池 销毁窗口对象池 销毁根窗口对象 清理所有窗口对象列表 注销事件监听 注销按键监听 中断所有命令这很关键。因为隐藏时可能只是临时关闭销毁时则必须彻底清理。尤其是框架里有事件系统、命令系统、对象池、本地化、输入系统如果销毁时没有统一兜底很容易留下悬空引用。大型项目里很多内存泄漏、重复回调、空对象报错最后都能追到生命周期没管好。十七、场景退出时统一卸载局部 UISceneProcedure里有一个通用退出逻辑genericExit()其中就会调用mLayoutManager.unloadAllPartLayout();这代表一个很明确的规则场景退出时局部界面不应该残留。因为PART_USE界面本来就是某些流程临时使用的。离开场景或者退出流程后继续留着没有意义还可能持有不该持有的资源。而PERSIST界面则可以保留。这比在每个流程里手动UNLOAD一堆界面更稳。流程可以负责自己明确打开和隐藏的界面。而场景退出这种大清理则由框架统一兜底。十八、总结MyFramework 里的 UI 生命周期管理核心不是多封装几个Show、Hide函数。它真正做的是把 UI 拆成了几层LayoutManager 管理所有界面的注册、加载、显示、隐藏、销毁 GameLayout 表示一个具体界面实例 LayoutScript 承载一个界面的业务逻辑和显示状态 myUGUIObject 表示具体 UI 节点 SceneProcedure 决定当前流程应该显示哪些界面这里最关键的一点是LayoutManager 不负责业务返回栈。返回关系、流程跳转、父子流程恢复应该放在GameScene SceneProcedure里。因为SceneProcedure是树形的每个流程只需要关心自己的界面。父流程管父流程的 UI子流程管子流程的 UI。进入流程时显示自己的界面退出流程时隐藏自己的界面从子流程返回时恢复自己的状态。这样 UI 生命周期和游戏流程就不会混在一起。LayoutManager 负责通用能力加载 显示 隐藏 卸载 渲染顺序 Esc 关闭 背景模糊 对象更新 资源释放SceneProcedure 负责业务流程现在处于哪个状态 应该进入哪个子流程 返回时回到哪个流程 这个流程应该显示哪些界面 这个流程退出时应该隐藏哪些界面这套分层的好处是项目越大越明显。小项目里一个 UIManager 什么都管也能跑。但大型项目里UI 管理一定要拆清楚。界面的生命周期归 LayoutManager。界面的业务状态归 LayoutScript。界面的流程关系归 SceneProcedure。只有这样UI 才不会变成一个巨大的、谁都不敢改的全局状态机。