Unity UI 导航重构:用 Route、参数类型和 ViewHandle 替换字符串

9 阅读8分钟

本文以“从背包打开物品详情并返回选择结果”为最小案例,把字符串导航逐步重构成 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 发生在缓存复用:

  1. 第一次打开物品详情,得到 Handle 10;
  2. 页面关闭并进入缓存;
  3. 第二次复用同一个 GameObject;
  4. 旧异步任务拿着 Handle 10 调用 Close;
  5. 如果复用原 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 进入运行时后的问题:打开和关闭不是两个方法,而是一组需要防止竞态的状态迁移。

资料与源码索引

继续阅读

下一篇:Unity UI 生命周期状态机:处理 Covered、异步竞态与事务回滚