本文以“从背包打开物品详情并返回选择结果”为最小案例,把字符串导航逐步重构成 Route + 强类型参数 + ViewHandle,并验证多实例、缓存复用、失败回滚与旧句柄隔离。示例以 FUI 当前 Navigation 设计为依据,扩展部分会明确标注。
“从背包打开物品详情”最短可以写成:
UIManager.Open("ItemDetailView", itemId);
短不等于简单。这一行把资源地址、页面身份、实例规则、参数类型和返回值全部藏起来了。拼错字符串只是最浅的一层问题;真正难处理的是调用方拿不到一个可以继续管理的“这一次打开”。
先给结论
可靠的导航入口需要同时回答两个问题:
Route<TViewModel>:要打开的是哪一类页面,它如何创建,有什么固定策略。ViewHandle:这一次具体打开产生了哪个实例代际。
Route 是定义,Handle 是运行时事实。只做泛型 Open 而没有 Handle,仍然无法可靠处理多开、缓存复用和迟到回调。
1. 五种页面标识方案怎么选
| 方案 | 编译检查 | 可携带策略 | 支持多实例 | 重构体验 | 主要问题 |
|---|---|---|---|---|---|
| 字符串 | 无 | 需额外查表 | 弱 | 差 | 身份与地址混在一起 |
| enum | 名称可检查 | 需中心 switch | 弱 | 一般 | 跨模块扩展困难,参数仍不类型化 |
typeof(TView) | 有 | 可查元数据 | 一般 | 较好 | View 类型泄漏到业务层,创建关系仍缺失 |
| Prefab/AssetReference | 资源可追踪 | 不自然 | 一般 | 一般 | 调用方耦合资源系统 |
Route<TViewModel> | 有 | 直接编译进对象 | 强 | 好 | 需要注册或生成阶段 |
enum 比字符串好,但它只是把拼写错误变成一个封闭列表。随着 Package、热更新模块或业务程序集增加,一个中央 enum 会成为合并冲突中心。
直接引用 Prefab 对小项目很直观,却让业务流程知道资源后端。更换 Resources/Addressables、加入 Presenter 或测试替身时,调用方都要变化。
2. Route 应该包含什么,不应该包含什么
教学化简版:
public abstract class Route
{
public string AssetKey { get; }
public RoutePolicy Policy { get; }
}
public sealed class Route<TViewModel> : Route
{
public Func<TViewModel> CreateViewModel { get; }
public Func<UIView, TViewModel, IBindingContext> CreateBinding { get; }
public Func<IPresenter> CreatePresenter { get; }
}
Route 适合保存编译时稳定的装配关系:
- 资源键;
- ViewModel 工厂;
- BindingContext 工厂;
- Presenter 工厂;
- 不可变页面策略;
- 静态依赖路由。
Route 不应该保存:
- 当前 GameObject;
- 当前动画进度;
- 当前请求参数;
- 某个玩家的状态;
- 是否正在加载。
这些属于一次打开产生的 NavigationEntry,而不是页面定义。
3. 为什么用 ViewModel 类型,而不是 View 类型做路由泛型
业务调用方通常关心“我要查看哪件物品,并接收选择或取消结果”,而不是“我要实例化哪个 MonoBehaviour”。
ViewHandle handle = navigator.Open(
Routes.ItemDetail,
new ItemDetailOpenArgs(itemId));
以 ViewModel 作为泛型参数,有几个好处:
- 调用方不依赖 uGUI、UI Toolkit 或自定义 View 实现;
- 测试中可替换 View Provider;
- 获取页面状态时可类型化:
TryGetViewModel<ItemDetailViewModel>; - 同一个展示模型可以在不同渲染适配层复用。
代价是框架需要维护 ViewContract,将 ViewModel 路由映射到资产和 View 类型。这个关系适合由生成器在编译期固化。
4. ViewHandle 解决了泛型 API 仍解决不了的问题
假设奖励详情页允许同时打开两个:
var first = navigator.Open(Routes.Reward, new RewardParam(1001));
var second = navigator.Open(Routes.Reward, new RewardParam(1002));
这时 Close<RewardViewModel>() 没有唯一答案。Handle 才能精确指向一次打开:
navigator.Close(first);
一个最小实现:
public readonly struct ViewHandle : IEquatable<ViewHandle>
{
public static ViewHandle Invalid => default;
public long Value { get; }
public bool IsValid => Value != 0;
internal ViewHandle(long value) => Value = value;
}
Navigator 内部使用 Handle 查找 NavigationEntry:
sealed class NavigationEntry
{
public ViewHandle Handle;
public Route Route;
public object ViewModel;
public IView View;
public ViewState State;
public int OperationVersion;
}
5. Handle 为什么必须有“代际”语义
最隐蔽的 Bug 发生在缓存复用:
- 第一次打开物品详情,得到 Handle 10;
- 页面关闭并进入缓存;
- 第二次复用同一个 GameObject;
- 旧异步任务拿着 Handle 10 调用 Close;
- 如果复用原 Handle,新打开的页面被错误关闭。
正确做法是缓存对象可以复用,打开身份不能复用:
GameObject A + Handle 10 → Closed/Cached
GameObject A + Handle 11 → Visible
Handle 10 永远保持终态;第二次打开分配 Handle 11。这与网络连接、实体句柄中的 generation counter 是同一个思想:防止陈旧引用重新获得权力。
6. 参数应该怎样类型化
最弱的写法是 object param,灵活但错误延后:
navigator.Open(route, new WrongParam());
可以让 Route 同时携带参数类型:
public sealed class Route<TViewModel, TParam>
{
public Func<TParam, TViewModel> CreateViewModel { get; }
}
但 API 和生成代码会更复杂。实际设计可以分层:
- 高频核心页面使用强类型参数;
- 无参数页面保留简洁重载;
- 框架内部兼容非泛型 Route 以便统一存储;
- 动态模块可使用受控的序列化参数,并在边界处验证。
不要为了 API 表面统一,把所有参数都降级为 Dictionary。
7. 返回结果也要有明确类型
物品详情可能让玩家选择“装备”或“取消”。如果只传一个 Action<object>,调用方和页面仍要约定装箱类型。可以在业务导航门面中定义结果:
public abstract record ItemDetailResult
{
public sealed record Equipped(string ItemId) : ItemDetailResult;
public sealed record Cancelled : ItemDetailResult;
}
FUI 当前 Navigator 返回的是 ViewHandle,没有内置 Route<TParam, TResult>。项目若需要等待页面结果,可以在业务门面中用 TaskCompletionSource<ItemDetailResult> 协调,并让 Close、Back、取消令牌都只完成一次结果。这个包装是可继续扩展的业务能力,不是 FUI 当前公开 API。
最容易漏掉的不是成功选择,而是页面被 Back、依赖失败或场景切换关闭时,等待者永远不完成。结果协调器必须把页面 Handle 与完成源绑定,并在任意终态返回 Cancelled 或明确异常。
8. 创建过程应该是事务,而不是一串副作用
打开页面的实际步骤通常是:
解析 Route
→ 检查单实例/缓存
→ 打开依赖
→ 获取资源 Lease
→ 创建 ViewModel
→ 创建 View
→ 创建 BindingContext/Presenter
→ Bind
→ 执行 Enter Transition
→ 提交到活动表与历史
任一步骤失败,都要逆序撤销已经完成的动作:
try
{
lease = await provider.CreateAsync(route.Descriptor, null, token);
entry = CreateEntry(route, lease);
entry.Binding.Bind();
await entry.Transition.EnterAsync(token);
Commit(entry);
return entry.Handle;
}
catch
{
entry?.Binding?.Dispose();
entry?.Presenter?.Dispose();
lease?.Dispose();
RollbackDependencies();
throw;
}
真正实现时,不应只依赖散落的 null 判断。可以用局部事务对象记录已获得资源,确保只提交一次、只回滚一次。
9. 单实例和多实例如何共存
AllowMultiple = false 不等于 Open 时静默返回旧 Handle 就结束了。需要先定义语义:
- 已 Visible:返回现有 Handle,还是 BringToFront?
- 正在 Loading:复用进行中的任务,还是拒绝第二次请求?
- 已 Covered:是否 Reveal?
- 在 Cache:复用对象但生成新 Handle。
- 正在 Exiting:等待关闭完成还是取消退出?
推荐把默认行为固定在 RoutePolicy 中,并允许少量显式 OpenOptions 表达这一次操作的意图。Options 不应重新定义页面固有策略。
10. FUI 中这条链怎样落地
FUI 的公开调用路径是:
[ViewContract] 声明
↓ Source Generator
typed Route / RouteRegistry / BindingRegistry
↓
Navigator.Open / OpenAsync
↓
ViewHandle + NavigationEntry
生成的 Route 携带资源键、ViewModel/BindingContext/Presenter 工厂和 RoutePolicy。Navigator 维护活动实例、终态、视觉顺序、历史、缓存、预加载和依赖所有权。
这里应注意:生成器消除了稳定关系的运行时发现,但 Navigator 的状态管理仍然是手写、可测试的运行时代码。代码生成不能替代正确的导航语义。
11. 实现中的坑点
坑一:Route 既是定义又保存运行时状态
静态 Route 被多个页面实例共享,写入 CurrentView 会制造交叉污染。
坑二:Handle 暴露内部索引
如果 Handle 只是 List 下标,删除和复用槽位会让旧 Handle 指向新对象。使用单调 ID 或 index + generation。
坑三:终态一关闭就删除
调用方随后查询状态只能得到“不存在”,无法区分 Invalid、Closed、Failed。可以保留有界的终态记录,并设置清理策略。
坑四:资源键成为新的全局字符串
字符串仍可存在于 Route 内部,但要由构建验证检查资产是否存在;调用方不应自行拼接路径。
坑五:直接把 ViewModel 实例当句柄
缓存或重新绑定时,ViewModel 的复用策略可能变化;对象引用也无法表达加载中和失败终态。
12. 可执行验证
- 路由资产不存在时,Open 失败且没有残留活动 Entry。
- 同一单实例路由连续 Open,行为符合明确定义。
- 多实例路由返回不同 Handle,可分别关闭。
- 缓存复用后 GameObject 相同,但 Handle 不同。
- 旧 Handle 的 Close 不影响新代际。
- 错误参数在编译期或进入 Navigator 边界时失败。
- 创建 BindingContext 失败时,资源 Lease 和依赖全部释放。
- 业务程序集不需要引用具体 View 类型或资源后端。
下一篇会继续处理 Route 进入运行时后的问题:打开和关闭不是两个方法,而是一组需要防止竞态的状态迁移。