Unity UI 导航策略:返回栈、焦点、层级与缓存的实现和坑点
系列第 11 篇。Unity UI 的返回键关错页面往往不是少写一个 if而是把视觉最上层、当前焦点和返回历史当成同一件事。这篇从大厅、活动弹窗与 Loading 出发定位三套状态如何分开哪些规则由框架统一执行。很多 UIManager 在需求变多后会长出一批参数isPopup、needBack、hideOthers、cache、singleton。这些参数单独都合理组合后却经常互相打架。问题不是参数太多而是页面固有规则被每个调用方反复决定。先给结论导航策略应满足三点页面固有规则只有一个来源Layer、History、Coverage、Cache、AllowMultiple 等维度正交表达调用时 Options 只能描述本次意图不能偷偷改写页面身份。FUI 将 RoutePolicy 由 Source Generator 编译进 RouteNavigator 在运行时执行统一语义不再扫描 Attribute 或让调用点携带一串布尔值。四种策略存放方式对比方式优点主要问题Open 参数灵活、就近可见每个调用点可能定义不同页面语义View 子类字段/Inspector美术可配置加载前看不到跨 Prefab 难审查中央配置表统一类型弱、容易与代码声明漂移RoutePolicy 编译进 Route类型化、可审查、运行时直接使用需要生成/注册步骤动态页面另行处理中央配置不是不能用如果策划需要热更新页面策略它很合适。但固定核心页面应优先静态化远端覆盖必须经过验证并限制可变字段。为什么策略维度必须正交不要只定义PageType Popup因为“弹窗”经常同时暗含五六种行为而且项目之间定义不同。这是当前 RoutePolicy 的属性摘录省略构造函数和过渡效果 Provider 属性不是可独立编译的完整类publicsealedclassRoutePolicy{publicLayerLayer{get;}publicHistoryModeHistoryMode{get;}publicCoverageModeCoverageMode{get;}publicCacheModeCacheMode{get;}publicboolAllowMultiple{get;}publicintCacheCapacity{get;}publicfloatCacheDuration{get;}publicRouteDependency[]Dependencies{get;}}每个字段只回答一个问题Navigator 再定义它们组合时的唯一语义。Layer 不是一个随便的 sortingOrder设计框架时可以考虑让 Layer 参与视觉排序和相关导航规则但不能因为字段叫 Layer就推断它自动控制父节点、全局输入优先级或覆盖范围。FUI 当前 Layer 的直接含义是独立 Canvas 排序基准焦点与覆盖由另一套导航状态维护。如果调用方直接传sortingOrder 2000框架无法判断两个页面的语义关系。FUI 当前定义Background、Scene、Panel、Popup、Tips、Overlay、Top基准值分别为 -30000、-20000、0、8000、16000、24000、30000。ReorderLayer 遍历全局 activeOrder只压缩目标层的 LocalOrder每层最多 1000 个活动 View并写入 Instance.Layer 与 Order。超出限制会抛异常不是无限叠加。BringToFront 接收准确的 ViewHandle只移动同层顺序并切换显式页面的焦点它不跨 Layer 改写排序。反过来Layer 较高也不意味着 history 最后一个就是它。视觉、焦点和返回目标应分别验证。History 决定 Back不等于视觉顺序视觉最上层页面不一定进入返回栈Toast、Loading、常驻 HUD 通常不应成为 Back 目标。publicenumHistoryMode{Stack,Transient}Navigator 应维护独立的activeOrder与historyactiveOrder: Home, HUD, Settings, Toast history: Home, Settings当前 Back 从 history 尾部向前遍历找到 IsAlive 的 Handle 就调用 Close(handle)。它没有承诺“先关闭视觉最上层”也没有自动忽略仍存活但已经处于关闭过程的条目继续找另一个。BackAsync 等待所选 Handle 的 CloseAsync。一个容易踩的坑是把 Loading 配为 Transient 后以为它既不进历史也不抢焦点。实际上 CommitOpen 对显式打开的页面都会设置焦点只有 history.Add 受 HistoryMode.Stack 控制。于是 Loading 显式打开后可以覆盖旧焦点但 Back 仍可能选择下方的 Stack 页面。Transient 只回答“是否进入返回历史”不是“忽略一切导航行为”。Coverage 决定下层页面怎样变化FUI 当前把下层视觉处理收敛成两种KeepVisible仍显示只被输入遮挡Hide暂时隐藏关闭 Popup 后恢复更精确地说当前 Focus/CommitOpen 读取新目标的 CoverageMode改变上一个焦点页面Interactablefalse记录 HiddenByCoverage进入 Covered并调用 Covered(hide)。不是按 Layer 扫描所有低层页面也不是某个透明度检测系统。KeepVisible 仍会让旧焦点不可交互Hide 也不等于 Close。关闭当前焦点后ReleaseFocusAfterClose 从 activeOrder 末端向前寻找仍可导航且有 ExplicitOwner 的页面执行 Revealed 并恢复焦点。它还检查进入动画是否未完成避免提前开放交互。这里的“恢复哪一页”不能直接用 Canvas 数值推断。Cache 与 History 是两套不同规则Cache true只说明关闭后实例可能保留不表示它还在返回栈。Open/Covered → Back/Close → Closing → 从 History 移除 → Cached保留 Lease 和 View再次打开时从 Cache 取实例创建新 Handle再按 HistoryMode 重新入栈。FUI 当前缓存策略是None、KeepAlive、Timed容量由CacheCapacity控制Timed 模式还要求正的CacheDuration。当前容量淘汰按同一 Route 的 LastUse 顺序选择旧项并不是一个叫 LRU 的公开枚举。Timed 的年龄由 Navigator.Tick(unscaledDeltaTime) 推进宿主没有调用 Tick就不能指望缓存自动计时淘汰。AllowMultiple 的组合难点多开不仅影响查找还影响历史和缓存键Route 参数是否构成逻辑唯一键同类型两个实例 Back 的顺序是什么缓存按 Route 存一个还是多个BringToFront 需要 Handle 还是 Route依赖页面由两个 Owner 共享时何时关闭当前 RoutePolicy 默认 AllowMultiplefalse。活动单实例重复打开会复用原 Handle不是分配第二个页面缓存恢复才获得新 Handle。FUI 当前不是把 Route 加任意参数对象当成实例键参数不同不能自动推导出不同页面。OpenWithViewModel 传入不同的 ViewModel 时默认 Keep 模式抛异常显式选择 OpenOptions.Rebind 才会保留已有 View 与 Handle换绑到新数据。同一个购物详情页面究竟要换绑还是多开应该由业务需求决定不能靠调用顺序碰运气。页面依赖为什么也适合写进 Policy例如商城页创建前需要货币栏父页创建后还要附加一个教学提示。AttachedAfter 是“创建后附加”不是“关闭后打开”。下面是 Attribute 配置片段假设这些 ViewModel 已有符合框架的 ViewContract 和默认 Route[RoutePolicy(RequiredBeforenew[]{typeof(CurrencyBarViewModel)},AttachedAfternew[]{typeof(ShopGuideViewModel)})]依赖关系需要所有权Shop ─owns→ CurrencyBar Inventory ─owns→ CurrencyBar当前 Navigator 确实有 Owners 集合同时还保存 ExplicitOwner。ReleaseOwner 只有在 Owners.Count0 且没有显式所有权时才调用 Close。若依赖后来被用户显式打开不能因为父页退出就将它关掉。AttachedAfter 依赖只调整视觉顺序不通过 BringToFront 偷拿焦点也不独立进入返回历史。RouteGenerator 当前用 FUI0011 报告缺失的依赖默认 Route用 FUI0012 报告静态依赖环。运行时仍有动态环防御。若声明 AllowMultiple同一依赖的重复节点还需要各自的可转移资源 Lease不能简单把所有重复类型都去重。RoutePolicy 为什么应不可变如果页面打开后任意代码都能改写固有策略活动 Entry、历史和缓存的解释就可能分叉。FUI 当前的访问路径是 route.Descriptor.Settings不是 route.PolicyCacheMode 等标量属性只有 getter。构造后不可变有三个好处可安全共享同一 Route测试输入确定生成器可以直接产生构造代码。但这里不能夸大为深度不可变Dependencies 是公开数组构造函数直接保存传入数组没有防御性复制。调用方仍能替换数组项数组中的 RouteDependency 还保存 Func。这是当前实现的边界。若希望强保证可继续改为防御性复制与只读集合并限制依赖工厂行为不能把这项建议说成已有能力。当前 OpenOptions 实际只包含 OpenModePush/Replace与 ExistingViewModelModeKeep/Rebind并提供 Push、Replace、Rebind 三个静态入口。BringToFront 是 Navigator 独立方法CancellationToken 是异步 API 参数都不是 OpenOptions 字段。组合规则必须集中而不是散在 if 中可以先建立决策表场景HistoryCoverageCache结果活动弹窗StackKeepVisibleKeepAlive可进入返回历史关闭后可缓存LoadingTransientKeepVisibleNone不进历史显式打开仍可能接管焦点主面板StackHideNone新页面打开时暂时隐藏下层ToastTransientKeepVisibleNone不进历史但这不足以保证不抢焦点这张表是需求起点不是完整的产品行为证明。比如不抢焦点的 Toast可以评估由页面的受控展示通道或不取焦点的附加依赖承载若要新增独立的非焦点通知 API需要另行设计其寿命与所有权。当前没有一个声明就能把任意显式页面变成完整 Toast 系统。让 Navigator 集中执行组合语义不意味着所有组合都自动正确。框架应把已定义的规则统一起来也应把不支持的组合尽早显露出来。常见坑点坑一布尔字段产生非法组合addHistoryfalse、replaceHistorytrue同时出现没有意义。使用 enum 表达互斥状态并在构造时验证。坑二Back 与 Close 使用两套路径Back 最终应选择 Handle 后调用同一 Close 状态机否则动画、缓存和所有权会分叉。坑三视觉排序就是打开顺序不同 Layer 的排序规则不同BringToFront 也可能只在 Layer 内生效。显式维护视觉顺序。坑四依赖只有列表没有 Owner共享依赖会被过早关闭。必须记录谁拥有它。坑五远端策略可改所有字段热更新 Layer/依赖可能破坏静态验证。只开放确有业务需求且能校验的字段。可执行验证Toast/Loading 不进入 Back 历史。Popup 覆盖和关闭后下层页面收到一次 Cover/Reveal。Replace 不遗留旧历史条目。Cache 页从历史移除复用时重新入栈并获得新 Handle。两个 Owner 共享依赖时释放一个不会关闭依赖。静态依赖环在编译期报错。构造函数拒绝 CacheCapacity1以及 Timed 且 CacheDuration0这些不是对所有枚举强转值和所有业务组合的完备校验。同一 Route 在不同调用点保持相同固有语义。以上是接入验收清单不是已经运行通过的测试报告。一段看起来能用、却会让返回键失控的代码下面是传统 UIManager 的错误教学伪代码不是 FUI APIvoidOpen(GameObjectpage,booladdHistory,boolpopup){page.transform.SetAsLastSibling();if(popup)current.SetActive(false);if(addHistory)history.Add(page);currentpage;}voidBack(){Destroy(root.GetChild(root.childCount-1).gameObject);}先开大厅再开活动弹窗最后显示 Toast。Toast 被放在最后一个 sibling却没有进入 history。Back 仍按 Transform 选择它返回历史和视觉对象立即分叉如果最后一个 child 恰好是公共遮罩删掉的甚至不是页面。popup 分支还会直接禁用 current没有区分覆盖、关闭与资源释放。把两个列表同步维护也不够。Close 若有退出动画Destroy 尚未完成时第二次 Back 可能再选到它缓存又要求保留对象而撤销旧身份。真正需要统一的是状态转换而不只是给 Manager 再加一个 List。从声明到运行生成器究竟替我们做了什么RoutePolicyAttribute 是编译期输入RoutePolicy 是运行时配置两者不能混为一物。RouteGenerator.BuildSettings 读取构造参数和 NamedArguments生成明确的 new RoutePolicy(…)依赖则生成带 Route 工厂的 RouteDependency 数组。BuildRoute 把这些配置交给 GeneratedRouteFactory与 ViewModel、Presenter、Binding 工厂一起装配。这条链路可以概括为ViewModel 上的 RoutePolicyAttribute → Roslyn 读取类型与命名参数 → 依赖默认 Route 检查、静态环诊断 → 生成 new RoutePolicy(...) 与 RouteDependency(...) → Route.Descriptor.Settings → Navigator 处理 Open / Back / Close / Cache它省掉的不是所有运行时判断而是运行时扫描 Attribute、拼字符串找类型以及各调用点自行解释默认规则。状态、焦点、异步和缓存仍然必须在运行时计算。编译器能确认静态依赖图不可能替你证明任意玩家操作序列符合产品意图。一个细节值得区分CacheCapacity 和 Timed 时长的参数校验位于 RoutePolicy 构造函数。生成器能产出构造代码不等于这些错误全都有编译期 Diagnostic。若生成静态 Route 初始化时传入非法容量仍可能在初始化阶段失败。希望提前到编译期需要新增对应诊断测试。用大厅、弹窗、Loading 跑一条可推理的路径假设项目已有 HomeViewModel、ActivityViewModel 与 LoadingViewModel 的 ViewContract下面只列策略不省略号伪装完整业务代码// 配在各自 ViewModel 上的独立 Attribute 示例。[RoutePolicy(Layer.Panel,HistoryModeHistoryMode.Stack,CoverageModeCoverageMode.Hide)]// class HomeViewModel ...[RoutePolicy(Layer.Popup,HistoryModeHistoryMode.Stack,CoverageModeCoverageMode.KeepVisible,CacheModeCacheMode.KeepAlive,CacheCapacity1)]// class ActivityViewModel ...[RoutePolicy(Layer.Overlay,HistoryModeHistoryMode.Transient)]// class LoadingViewModel ...这是三个独立配置片段不能直接连续粘贴到同一个类Routes 的生成名还要以自己的 ViewContract 为准。按当前实现分析Home 显式打开进入 historyActivity 打开后让旧焦点 Home 保持可见但不可交互。Loading 显式打开不进 history却仍会成为焦点。此时 Back 选择的可能是 Activity不是 Loading。如果产品要求加载期间完全不允许返回应由输入门或明确的上层导航约束拦截不能只写 Transient。再看 Activity 关闭进入缓存先退出生命周期再移出活动导航身份并保留 Instance 和 Lease。重新打开得到新 Handle。业务持有旧 Handle 的迟到回调不能被当成新页面的控制权。资源复用与操作资格是两件事把它们分开新人就不必自行发明一套缓存身份规则。最小可运行测试先验证真实配置再验证教学模型以下 NUnit 代码使用 FUI.Navigation 的真实 RoutePolicy 与 OpenOptions。放入已引用 FUI 和 NUnit 的测试程序集这里未执行 Unity 测试因此只给出预期断言不报告通过率。usingSystem;usingFUI.Navigation;usingNUnit.Framework;publicsealedclassNavigationPolicyTests{[Test]publicvoidDefaults_KeepCommonPageSemanticsTogether(){varpolicynewRoutePolicy();Assert.That(policy.Layer,Is.EqualTo(Layer.Panel));Assert.That(policy.HistoryMode,Is.EqualTo(HistoryMode.Stack));Assert.That(policy.CacheMode,Is.EqualTo(CacheMode.None));Assert.That(policy.AllowMultiple,Is.False);}[Test]publicvoidInvalidCacheSettings_FailAtConstruction(){Assert.ThrowsArgumentOutOfRangeException(()newRoutePolicy(cacheCapacity:0));Assert.ThrowsArgumentOutOfRangeException(()newRoutePolicy(cacheMode:CacheMode.Timed,cacheDuration:0));}[Test]publicvoidRebind_DoesNotMeanReplace(){Assert.That(OpenOptions.Rebind.OpenMode,Is.EqualTo(OpenMode.Push));Assert.That(OpenOptions.Rebind.ExistingViewModelMode,Is.EqualTo(ExistingViewModelMode.Rebind));Assert.That(OpenOptions.Replace.ExistingViewModelMode,Is.EqualTo(ExistingViewModelMode.Keep));}[Test]publicvoidDependencyArray_IsNotDeeplyImmutableToday(){varfirstnewRouteDependency(DependencyTiming.RequiredBefore,()thrownewNotSupportedException());varsecondnewRouteDependency(DependencyTiming.AttachedAfter,()thrownewNotSupportedException());vardependenciesnew[]{first};varpolicynewRoutePolicy(dependencies:dependencies);dependencies[0]second;Assert.That(policy.Dependencies[0],Is.SameAs(second));}}最后一个测试是边界记录不是建议修改共享配置。它帮助团队避免把“属性没有 setter”误当成深度不可变。修正设计后这个测试也应该随新契约修改。配置测试仍不足以证明 Back 或覆盖正确。下面是独立教学模型只刻画“显式打开时焦点与历史分开”不冒充 Navigator 的完整替身usingSystem.Collections.Generic;usingFUI.Navigation;usingNUnit.Framework;publicsealedclassFocusHistoryModelTests{sealedclassModel{readonlyListinthistorynewListint();intnext;publicintFocus{get;privateset;}publicintOpen(HistoryModemode){varhandlenext;Focushandle;if(modeHistoryMode.Stack)history.Add(handle);returnhandle;}publicintBackTargethistory.Count0?0:history[history.Count-1];}[Test]publicvoidTransient_CanHaveFocusWithoutBeingBackTarget(){varmodelnewModel();varactivitymodel.Open(HistoryMode.Stack);varloadingmodel.Open(HistoryMode.Transient);Assert.That(model.Focus,Is.EqualTo(loading));Assert.That(model.BackTarget,Is.EqualTo(activity));}}真实框架集成测试还需要 Fake Provider、生成的 Route 与生命周期记录器打开大厅、弹窗和 Loading断言焦点通知、历史选择、关闭状态、资源释放次数再等待 CloseAsync 完成。测试替身应记录调用不能只看最终画面。若要覆盖真实输入和 Canvas还需要在 Unity 场景中验证显示与交互不能拿这个 List 模型替代。这些约束最后帮到了谁把规则放到 Route 上首先帮助的是每天写业务的人。同一页面从商城、背包、活动入口打开不应得到三套缓存或返回语义新同事沿默认入口写代码也不会被迫自行决定何时卸载、关谁、把谁恢复焦点。对框架作者而言代价是必须解释组合边界。正交不等于互不影响History 不决定焦点Layer 不等于返回顺序Coverage 不代表关闭Cache 不保留旧 Handle。把这些限制写成测试比一个名叫 Popup 的万能枚举更容易扩展。这是从源码推断出的设计收益而不是宣称所有误用都被封死。Dependencies 数组仍可变部分参数仍运行时验证不抢焦点的独立通知通道也需要明确设计。框架的价值是让常见正确路径短且一致让剩余风险可见而不是把未实现的保证藏在“统一管理”四个字里。资料与源码索引FUIRoutePolicyFUINavigation 文档FUIRouteGeneratorFUIOpenOptionsFUINavigator.Api.csFUINavigator.State.csFUINavigator.Operations.cs排查导航问题时分别记录视觉顺序、焦点、历史、Handle 和 Lease。只看“哪个界面还在屏幕上”无法验证状态是否一致。下一篇Unity 自定义血条接入 FUIElement 扩展、事件方向与生命周期清理