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

系列第 4/15 篇

前提:已有 Unity UI 异步加载、弹窗覆盖或页面缓存经验。

目标:把页面打开、覆盖、恢复、关闭和失败建模为可验证状态迁移,并用 Operation Version 与事务回滚处理交错操作。

战斗暂停菜单上再打开"退出战斗确认"弹窗时,下层暂停页不应该被当成已经关闭。它仍可能保留 ViewModel、绑定和资源,只是失去焦点,或者根据策略暂时隐藏。

如果框架只提供 OnOpenOnShowOnHideOnClose,却没有统一的状态与迁移规则,重复订阅、动画重入和迟到回调仍然会发生。回调只是通知,状态机才是规则。

先给结论

页面生命周期至少要同时表达三件事:

  • 资源与对象处在哪个阶段;
  • 页面当前是否可见、是否可交互;
  • 当前异步操作是否仍有资格提交结果。

推荐把生命周期建模为显式状态迁移,并为 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. 最容易踩的坑

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 实战:取消令牌、版本校验与旧句柄隔离

资料与源码索引

相关推荐
leoZ2316 小时前
第 8 篇:与 AI 协作的工作流 + 完整案例
前端·人工智能·神经网络·自然语言处理·性能优化·c#·php
_zhourui_h_7 小时前
EasyECS 最慢的地方,居然是 Resize
unity3d
fujisheng6618 小时前
Unity 异步 UI 实战:取消令牌、版本校验与旧句柄隔离
c#·unity3d
SmalBox8 小时前
01-02-认知篇-基础-Unity资源管理发展史
unity3d·游戏开发
额鹅恶饿呃9 小时前
随着CentOS官方停服的时间越来越久,大量仍在使用CentOS7的企业和运维从业者
java·python·算法·c#·ruby
乌药ice9 小时前
c#中一个多线程安全的HashSet
开发语言·c#
lfSeanDragon9 小时前
数据结构-前缀树(Trie)
开发语言·c#
张人玉10 小时前
基于 C# WinForms + .NET 8 + SQLite + TCP Socket 的多连接通讯调试工具——TCP通讯助手(TcpAssistant)
tcp/ip·sqlite·c#·.net
mldong18 小时前
C# 开发者也有自己的轻量工作流引擎了:NuGet 装包,5 分钟跑通一条审批流
后端·c#·.net