系列第 11 篇,面向熟悉 Unity 和 C#、开始写框架的开发者。以 FUI 当前 RoutePolicy、OpenOptions 和 Navigator 源码为依据,先拆一个返回键错误,再沿生成和运行路径验证组合语义;教学模型与真实API分开标记。
一段看起来能用、却会让返回键失控的代码
下面是传统 UIManager 的错误教学伪代码,不是 FUI API:
csharp
void Open(GameObject page, bool addHistory, bool popup)
{
page.transform.SetAsLastSibling();
if (popup) current.SetActive(false);
if (addHistory) history.Add(page);
current = page;
}
void Back()
{
Destroy(root.GetChild(root.childCount - 1).gameObject);
}
先开大厅,再开活动弹窗,最后显示 Toast。Toast 被放在最后一个 sibling,却没有进入 history。Back 仍按 Transform 选择它,返回历史和视觉对象立即分叉;如果最后一个 child 恰好是公共遮罩,删掉的甚至不是页面。popup 分支还会直接禁用 current,没有区分覆盖、关闭与资源释放。
把两个列表同步维护也不够。Close 若有退出动画,Destroy 尚未完成时第二次 Back 可能再选到它;缓存又要求保留对象而撤销旧身份。真正需要统一的是状态转换,而不只是给 Manager 再加一个 List。
很多 UIManager 在需求变多后,会长出一批参数:isPopup、needBack、hideOthers、cache、singleton。这些参数单独都合理,组合后却经常互相打架。
问题不是参数太多,而是页面固有规则被每个调用方反复决定。
先给结论
导航策略应满足三点:
- 页面固有规则只有一个来源;
- Layer、History、Coverage、Cache、AllowMultiple 等维度正交表达;
- 调用时 Options 只能描述本次意图,不能偷偷改写页面身份。
FUI 将 RoutePolicy 由 Source Generator 编译进 Route,Navigator 在运行时执行统一语义,不再扫描 Attribute 或让调用点携带一串布尔值。
四种策略存放方式对比
| 方式 | 优点 | 主要问题 |
|---|---|---|
| Open 参数 | 灵活、就近可见 | 每个调用点可能定义不同页面语义 |
| View 子类字段/Inspector | 美术可配置 | 加载前看不到,跨 Prefab 难审查 |
| 中央配置表 | 统一 | 类型弱、容易与代码声明漂移 |
| RoutePolicy 编译进 Route | 类型化、可审查、运行时直接使用 | 需要生成/注册步骤,动态页面另行处理 |
中央配置不是不能用;如果策划需要热更新页面策略,它很合适。但固定核心页面应优先静态化,远端覆盖必须经过验证并限制可变字段。
为什么策略维度必须正交
不要只定义 PageType = Popup,因为"弹窗"经常同时暗含五六种行为,而且项目之间定义不同。
这是当前 RoutePolicy 的属性摘录,省略构造函数和过渡效果 Provider 属性,不是可独立编译的完整类:
csharp
public sealed class RoutePolicy
{
public Layer Layer { get; }
public HistoryMode HistoryMode { get; }
public CoverageMode CoverageMode { get; }
public CacheMode CacheMode { get; }
public bool AllowMultiple { get; }
public int CacheCapacity { get; }
public float CacheDuration { get; }
public RouteDependency[] 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 目标。
csharp
public enum HistoryMode
{
Stack,
Transient
}
Navigator 应维护独立的 activeOrder 与 history:
text
activeOrder: 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,改变上一个焦点页面:Interactable=false,记录 HiddenByCoverage,进入 Covered,并调用 Covered(hide)。不是按 Layer 扫描所有低层页面,也不是某个透明度检测系统。KeepVisible 仍会让旧焦点不可交互;Hide 也不等于 Close。
关闭当前焦点后,ReleaseFocusAfterClose 从 activeOrder 末端向前寻找仍可导航且有 ExplicitOwner 的页面,执行 Revealed 并恢复焦点。它还检查进入动画是否未完成,避免提前开放交互。这里的"恢复哪一页"不能直接用 Canvas 数值推断。
Cache 与 History 是两套不同规则
Cache = true 只说明关闭后实例可能保留,不表示它还在返回栈。
text
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 默认 AllowMultiple=false。活动单实例重复打开会复用原 Handle,不是分配第二个页面;缓存恢复才获得新 Handle。FUI 当前不是把 Route 加任意参数对象当成实例键:参数不同不能自动推导出不同页面。
OpenWithViewModel 传入不同的 ViewModel 时,默认 Keep 模式抛异常;显式选择 OpenOptions.Rebind 才会保留已有 View 与 Handle,换绑到新数据。同一个购物详情页面究竟要换绑还是多开,应该由业务需求决定,不能靠调用顺序碰运气。
页面依赖为什么也适合写进 Policy
例如商城页创建前需要货币栏,父页创建后还要附加一个教学提示。AttachedAfter 是"创建后附加",不是"关闭后打开"。下面是 Attribute 配置片段,假设这些 ViewModel 已有符合框架的 ViewContract 和默认 Route:
csharp
[RoutePolicy(
RequiredBefore = new[] { typeof(CurrencyBarViewModel) },
AttachedAfter = new[] { typeof(ShopGuideViewModel) })]
依赖关系需要所有权:
text
Shop ─owns→ CurrencyBar
Inventory ─owns→ CurrencyBar
当前 Navigator 确实有 Owners 集合,同时还保存 ExplicitOwner。ReleaseOwner 只有在 Owners.Count==0 且没有显式所有权时才调用 Close。若依赖后来被用户显式打开,不能因为父页退出就将它关掉。AttachedAfter 依赖只调整视觉顺序,不通过 BringToFront 偷拿焦点,也不独立进入返回历史。
RouteGenerator 当前用 FUI0011 报告缺失的依赖默认 Route,用 FUI0012 报告静态依赖环。运行时仍有动态环防御。若声明 AllowMultiple,同一依赖的重复节点还需要各自的可转移资源 Lease;不能简单把所有重复类型都去重。
RoutePolicy 为什么应不可变
如果页面打开后任意代码都能改写固有策略,活动 Entry、历史和缓存的解释就可能分叉。FUI 当前的访问路径是 route.Descriptor.Settings,不是 route.Policy;CacheMode 等标量属性只有 getter。
构造后不可变有三个好处:
- 可安全共享同一 Route;
- 测试输入确定;
- 生成器可以直接产生构造代码。
但这里不能夸大为深度不可变:Dependencies 是公开数组,构造函数直接保存传入数组,没有防御性复制。调用方仍能替换数组项;数组中的 RouteDependency 还保存 Func。这是当前实现的边界。若希望强保证,可继续改为防御性复制与只读集合,并限制依赖工厂行为,不能把这项建议说成已有能力。
当前 OpenOptions 实际只包含 OpenMode(Push/Replace)与 ExistingViewModelMode(Keep/Rebind),并提供 Push、Replace、Rebind 三个静态入口。BringToFront 是 Navigator 独立方法,CancellationToken 是异步 API 参数,都不是 OpenOptions 字段。
组合规则必须集中,而不是散在 if 中
可以先建立决策表:
| 场景 | History | Coverage | Cache | 结果 |
|---|---|---|---|---|
| 活动弹窗 | Stack | KeepVisible | KeepAlive | 可进入返回历史;关闭后可缓存 |
| Loading | Transient | KeepVisible | None | 不进历史;显式打开仍可能接管焦点 |
| 主面板 | Stack | Hide | None | 新页面打开时暂时隐藏下层 |
| Toast | Transient | KeepVisible | None | 不进历史,但这不足以保证不抢焦点 |
这张表是需求起点,不是完整的产品行为证明。比如不抢焦点的 Toast,可以评估由页面的受控展示通道或不取焦点的附加依赖承载;若要新增独立的非焦点通知 API,需要另行设计其寿命与所有权。当前没有一个声明就能把任意显式页面变成完整 Toast 系统。
让 Navigator 集中执行组合语义,不意味着所有组合都自动正确。框架应把已定义的规则统一起来,也应把不支持的组合尽早显露出来。
常见坑点
坑一:布尔字段产生非法组合
addHistory=false、replaceHistory=true 同时出现没有意义。使用 enum 表达互斥状态,并在构造时验证。
坑二:Back 与 Close 使用两套路径
Back 最终应选择 Handle 后调用同一 Close 状态机,否则动画、缓存和所有权会分叉。
坑三:视觉排序就是打开顺序
不同 Layer 的排序规则不同,BringToFront 也可能只在 Layer 内生效。显式维护视觉顺序。
坑四:依赖只有列表,没有 Owner
共享依赖会被过早关闭。必须记录谁拥有它。
坑五:远端策略可改所有字段
热更新 Layer/依赖可能破坏静态验证。只开放确有业务需求且能校验的字段。
可执行验证
- Toast/Loading 不进入 Back 历史。
- Popup 覆盖和关闭后,下层页面收到一次 Cover/Reveal。
- Replace 不遗留旧历史条目。
- Cache 页从历史移除,复用时重新入栈并获得新 Handle。
- 两个 Owner 共享依赖时,释放一个不会关闭依赖。
- 静态依赖环在编译期报错。
- 构造函数拒绝 CacheCapacity<1,以及 Timed 且 CacheDuration<=0;这些不是对所有枚举强转值和所有业务组合的完备校验。
- 同一 Route 在不同调用点保持相同固有语义。
以上是接入验收清单,不是已经运行通过的测试报告。
从声明到运行:生成器究竟替我们做了什么
RoutePolicyAttribute 是编译期输入,RoutePolicy 是运行时配置,两者不能混为一物。RouteGenerator.BuildSettings 读取构造参数和 NamedArguments,生成明确的 new RoutePolicy(...);依赖则生成带 Route 工厂的 RouteDependency 数组。BuildRoute 把这些配置交给 GeneratedRouteFactory,与 ViewModel、Presenter、Binding 工厂一起装配。
这条链路可以概括为:
text
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,下面只列策略,不省略号伪装完整业务代码:
csharp
// 配在各自 ViewModel 上的独立 Attribute 示例。
[RoutePolicy(Layer.Panel, HistoryMode = HistoryMode.Stack,
CoverageMode = CoverageMode.Hide)]
// class HomeViewModel ...
[RoutePolicy(Layer.Popup, HistoryMode = HistoryMode.Stack,
CoverageMode = CoverageMode.KeepVisible,
CacheMode = CacheMode.KeepAlive, CacheCapacity = 1)]
// class ActivityViewModel ...
[RoutePolicy(Layer.Overlay, HistoryMode = HistoryMode.Transient)]
// class LoadingViewModel ...
这是三个独立配置片段,不能直接连续粘贴到同一个类;Routes 的生成名还要以自己的 ViewContract 为准。
按当前实现分析:Home 显式打开进入 history,Activity 打开后让旧焦点 Home 保持可见但不可交互。Loading 显式打开不进 history,却仍会成为焦点。此时 Back 选择的可能是 Activity,不是 Loading。如果产品要求加载期间完全不允许返回,应由输入门或明确的上层导航约束拦截,不能只写 Transient。
再看 Activity 关闭进入缓存:先退出生命周期,再移出活动导航身份并保留 Instance 和 Lease。重新打开得到新 Handle。业务持有旧 Handle 的迟到回调不能被当成新页面的控制权。资源复用与操作资格是两件事,把它们分开,新人就不必自行发明一套缓存身份规则。
最小可运行测试:先验证真实配置,再验证教学模型
以下 NUnit 代码使用 FUI.Navigation 的真实 RoutePolicy 与 OpenOptions。放入已引用 FUI 和 NUnit 的测试程序集;这里未执行 Unity 测试,因此只给出预期断言,不报告通过率。
csharp
using System;
using FUI.Navigation;
using NUnit.Framework;
public sealed class NavigationPolicyTests
{
[Test]
public void Defaults_KeepCommonPageSemanticsTogether()
{
var policy = new RoutePolicy();
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]
public void InvalidCacheSettings_FailAtConstruction()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new RoutePolicy(cacheCapacity: 0));
Assert.Throws<ArgumentOutOfRangeException>(() =>
new RoutePolicy(cacheMode: CacheMode.Timed, cacheDuration: 0));
}
[Test]
public void Rebind_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]
public void DependencyArray_IsNotDeeplyImmutableToday()
{
var first = new RouteDependency(DependencyTiming.RequiredBefore,
() => throw new NotSupportedException());
var second = new RouteDependency(DependencyTiming.AttachedAfter,
() => throw new NotSupportedException());
var dependencies = new[] { first };
var policy = new RoutePolicy(dependencies: dependencies);
dependencies[0] = second;
Assert.That(policy.Dependencies[0], Is.SameAs(second));
}
}
最后一个测试是边界记录,不是建议修改共享配置。它帮助团队避免把"属性没有 setter"误当成深度不可变。修正设计后,这个测试也应该随新契约修改。
配置测试仍不足以证明 Back 或覆盖正确。下面是独立教学模型,只刻画"显式打开时焦点与历史分开",不冒充 Navigator 的完整替身:
csharp
using System.Collections.Generic;
using FUI.Navigation;
using NUnit.Framework;
public sealed class FocusHistoryModelTests
{
sealed class Model
{
readonly List<int> history = new List<int>();
int next;
public int Focus { get; private set; }
public int Open(HistoryMode mode)
{
var handle = ++next;
Focus = handle;
if (mode == HistoryMode.Stack) history.Add(handle);
return handle;
}
public int BackTarget => history.Count == 0 ? 0 : history[history.Count - 1];
}
[Test]
public void Transient_CanHaveFocusWithoutBeingBackTarget()
{
var model = new Model();
var activity = model.Open(HistoryMode.Stack);
var loading = model.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 数组仍可变,部分参数仍运行时验证,不抢焦点的独立通知通道也需要明确设计。框架的价值,是让常见正确路径短且一致,让剩余风险可见,而不是把未实现的保证藏在"统一管理"四个字里。
资料与源码索引
- FUI:RoutePolicy
- FUI:Navigation 文档
- FUI:RouteGenerator
- FUI:OpenOptions
- FUI:Navigator.Api.cs
- FUI:Navigator.State.cs
- FUI:Navigator.Operations.cs
先跑配置断言,再用可控Provider与生命周期记录器测真实导航,最后进场景验证输入和显示。每一层只证明自己负责的行为。
下一篇预告:增加自定义血条 Element,讨论怎样扩展控件而不修改 Core、Navigator 或生成器特判。第 12 篇发布后补充同平台跳转。