系列第 4/15 篇
前提:已有 Unity UI 异步加载、弹窗覆盖或页面缓存经验。
目标:把页面打开、覆盖、恢复、关闭和失败建模为可验证状态迁移,并用 Operation Version 与事务回滚处理交错操作。
战斗暂停菜单上再打开"退出战斗确认"弹窗时,下层暂停页不应该被当成已经关闭。它仍可能保留 ViewModel、绑定和资源,只是失去焦点,或者根据策略暂时隐藏。
如果框架只提供 OnOpen、OnShow、OnHide、OnClose,却没有统一的状态与迁移规则,重复订阅、动画重入和迟到回调仍然会发生。回调只是通知,状态机才是规则。
先给结论
页面生命周期至少要同时表达三件事:
- 资源与对象处在哪个阶段;
- 页面当前是否可见、是否可交互;
- 当前异步操作是否仍有资格提交结果。
推荐把生命周期建模为显式状态迁移,并为 Open/Close 操作增加版本号或操作令牌。任何异步结果在提交前,都必须确认"我等待期间,页面的意图没有变化"。
1. 为什么四个回调不够
假设打开页面时执行:
csharp
await LoadPrefabAsync();
Instantiate();
await PlayEnterAnimation();
OnShow();
用户在加载阶段按返回键:
text
t0 Open(PauseMenu)
t1 开始加载 Prefab
t2 Close(handle)
t3 Prefab 加载完成
t4 页面实例化并显示
每个局部函数都"正确",组合起来却违背用户最后的意图。因为系统没有一个状态说明:Close 已经让这次 Open 失效。
2. 先区分资源生命周期、逻辑生命周期和视觉生命周期
把它们混在一个 activeSelf 中,是许多 Bug 的源头。
资源生命周期
text
Unloaded → Loading → Loaded → Released
回答 Prefab/Addressables Handle 是否存在。
逻辑生命周期
text
Created → Bound → Active → Unbound → Disposed
回答 ViewModel、BindingContext、Presenter 是否仍可接收事件。
视觉生命周期
text
Hidden → Entering → Visible → Covered → Exiting → Hidden
回答页面是否可见、是否被覆盖、能否接收输入。
实际框架可以合并部分状态,但设计时必须先看见这些不同维度。
3. 一个可落地的页面状态机
csharp
public enum ViewState
{
Pending,
Loading,
Opening,
Open,
Covered,
Closing,
Cached,
Failed,
Destroyed
}
这组名称与 FUI 当前公开源码一致。Pending 表示已经分配 Handle、尚未开始加载;Destroyed 表示实例和资源已经释放,或者 Handle 从未存在。它们不是为了让枚举显得完整,而是让每个 Handle 在任意时刻都有可查询、可拒绝非法操作的状态。
合法迁移可以写成表:
| 当前状态 | 事件 | 下一个状态 | 关键动作 |
|---|---|---|---|
| Pending | Open | Loading | 取得资源 Lease |
| Loading | Loaded | Opening | 创建并 Bind |
| Opening | EnterDone | Open | 加入历史、开放输入 |
| Open | CoveredByOther | Covered | 暂停输入/可选暂停逻辑 |
| Covered | Reveal | Open | 恢复显示与输入 |
| Open/Covered | Close | Closing | 取消命令、执行退出 |
| Closing | ExitDone | Cached | 解绑但保留可复用实例 |
| Closing | ExitDone | Destroyed | 解绑并释放 Lease |
| 非终态 | Error | Failed | 逆序回滚 |
状态机的价值在于拒绝非法动作。例如 Destroyed 再 Close 可以幂等返回;Failed 不应被 Reveal;Closing 不应再次启动退出动画。
4. Covered 为什么不能等同于 Hidden 或 Closed
战斗暂停菜单上再打开"退出战斗确认"弹窗时,暂停菜单可能仍然:
- 保留 ViewModel 和未保存输入;
- 保留资源和绑定;
- 视觉上被遮挡;
- 不再接收输入;
- 返回时无需重新加载。
如果把覆盖等同 Close,恢复成本和状态丢失都会增加;如果只 SetActive(false),Presenter 又无法知道页面暂停显示。
Coverage 应成为导航策略的一部分。概念上可以把覆盖、隐藏、关闭都列入决策表;但 FUI 当前公开的 CoverageMode 只表达"下层是否继续可见":
csharp
public enum CoverageMode
{
KeepVisible,
Hide
}
无论选择哪一种,下层都会进入 Covered/Unfocus 一类导航状态;直接关闭下层属于显式导航行为,不是当前 CoverageMode 的第三种枚举值。覆盖者的策略决定下层页面如何迁移,但迁移仍由 Navigator 统一执行。
5. 异步竞态的核心:提交前校验版本
每个 Entry 保存操作版本:
csharp
entry.OperationVersion++;
int version = entry.OperationVersion;
var lease = await provider.CreateAsync(route.Descriptor, null, token);
if (entry.OperationVersion != version || entry.State != ViewState.Loading)
{
lease.Dispose();
return;
}
Close 到来时递增版本并取消令牌:
csharp
entry.OperationVersion++;
entry.LifetimeCancellation.Cancel();
版本号和 CancellationToken 解决的问题不同:
- CancellationToken 尽力通知底层停止工作;
- 版本号保证即使底层无法取消,迟到结果也不能提交。
两者应该同时使用。
6. Open 应该分为准备、提交和回滚
教学化简版:
csharp
async ValueTask<ViewHandle> OpenCoreAsync(Route route, CancellationToken token)
{
var tx = new OpenTransaction();
var entry = CreatePendingEntry(route);
int version = BeginOperation(entry, ViewState.Loading);
try
{
tx.Dependencies = await OpenDependencies(route, entry.Handle, token);
tx.Lease = await provider.CreateAsync(route.Descriptor, null, token);
EnsureCurrent(entry, version);
tx.ViewModel = route.CreateViewModel();
tx.View = tx.Lease.View;
tx.Binding = route.CreateBinding(tx.View, tx.ViewModel);
tx.Presenter = route.CreatePresenter();
tx.Binding.Bind();
entry.State = ViewState.Opening;
await transition.EnterAsync(tx.View, token);
EnsureCurrent(entry, version);
Commit(entry, tx);
return entry.Handle;
}
catch
{
tx.Rollback();
MarkTerminal(entry, ViewState.Failed);
throw;
}
}
关键不是 try/catch 本身,而是事务明确记录"本次操作已经获得了哪些东西"。只有 Commit 才把所有权转移到 NavigationEntry。
7. Close 为什么比 Open 更难
Close 可能发生在任何阶段:
| Close 到达时 | 推荐处理 |
|---|---|
| Loading | 取消加载资格,等待/忽略迟到结果 |
| Opening | 取消或快速完成进入,再执行退出 |
| Open | 正常进入 Closing |
| Covered | 从 Covered 直接进入 Closing |
| Closing | 幂等复用当前 Close 任务 |
| Cached/Destroyed/Failed | 返回终态,不重复释放 |
一个常见实现是为 Open/Close 都保存共享任务,重复调用复用同一结果,避免同一页面启动两个退出动画和两次 Dispose。
8. Transition、输入门和焦点恢复
动画不是生命周期的装饰,而是迁移的一部分。
- Opening 期间是否允许点击?通常不允许。
- Exit 动画取消后,最终位置由谁恢复?
- 上层弹窗关闭时,下层页面何时重新获得焦点?
- 两个并行动画异常时,状态怎样收敛?
可以抽象输入门:
csharp
using var gate = inputGate.Acquire(handle, OperationPhase.Open);
await transition.EnterAsync(view, token);
Dispose 时释放输入阻塞。输入门必须支持嵌套计数,不能由任意页面简单设置全局 blocksRaycasts = true/false,否则一个动画提前结束会放开另一个动画仍需阻塞的输入。
9. Presenter 生命周期如何与页面状态对齐
Presenter 适合处理页面进入/退出时的流程。下面使用的是 FUI 当前 Presenter<TObservableObject> 可覆盖的方法,而不是另造一套生命周期接口:
csharp
public sealed class PausePresenter : Presenter<PauseViewModel>
{
protected override void OnOpen(object parameter) { }
protected override void OnCovered() { }
protected override void OnRevealed() { }
protected override ValueTask OnCloseAsync(CancellationToken token)
=> default;
}
需要明确:
- Binding 完成后再让 Presenter 更新 ViewModel;
- Covered 不是 Closing;
OnClose/OnCloseAsync只能进入一次关闭流程;- Presenter 异常属于 Open/Close 事务的一部分;
- Presenter 不应自行 Destroy View 或释放 Lease。
10. 缓存状态的真实含义
Cached 不是"把 GameObject 关掉就完了"。框架要决定保留哪些组件:
| 对象 | 常见策略 | 原因 |
|---|---|---|
| Prefab/资源 Lease | 保留 | 避免重新加载 |
| GameObject/View | 保留 | 复用实例化成本 |
| ViewModel | 可配置 | 状态是否应跨打开保留 |
| BindingContext | 通常解绑后重建/重绑 | 防止离线期间接收事件 |
| Presenter | 通常释放 | 避免持有业务服务订阅 |
| ViewHandle | 不保留 | 新打开必须新代际 |
不要把缓存策略写死在 SetActive(false) 中。
11. 最容易踩的坑
坑一:在回调里直接修改 Navigator 集合
OnClose 内再次打开/关闭页面可能在遍历时修改列表。应把状态提交与用户回调的顺序固定,必要时使用操作队列。
坑二:异常后状态停在 Opening
每条失败路径都必须落到 Failed/Destroyed 等终态,并释放输入门。
坑三:把取消当错误提示给用户
页面离开导致的 OperationCanceledException 是控制流,不应弹"加载失败"。
坑四:动画组件拥有页面生命周期
Transition 只能执行视觉变化,Navigator 才是状态的唯一写入者。
坑五:只测试顺序流程
生命周期 Bug 多发生在交错操作。测试必须主动在每个 await 点插入 Close、Back、场景切换和异常。
12. 建议的测试矩阵
- Loading 时 Close,资源返回后不会实例化。
- Opening 时 Close,最终只能是 Cached/Destroyed,不能回到 Open。
- Open 连续 Close 两次,只执行一次动画和释放。
- Covered 页面 Reveal 后保留原 ViewModel 状态。
- Transition 抛异常,输入门和 Lease 都被释放。
- Presenter 进入失败,Binding 订阅被回滚。
- 缓存复用产生新 Handle,旧异步任务无法提交。
- 依赖页面打开失败,主页面和已打开依赖全部回滚。
下一篇:Unity 异步 UI 实战:取消令牌、版本校验与旧句柄隔离