Unity 异步 UI 实战:取消令牌、版本校验与旧句柄隔离

本文面向有 Unity UI 异步加载经验的开发者,用"快速切换英雄头像"验证最后一次选择优先。教学代码使用 C# Task 与 CancellationToken;FUI 的导航版本与句柄部分按当前源码说明,独立示例不会冒充框架 API。

预期结果很具体:B 后发先完成时,A 的迟到结果不能覆盖 B;旧请求的 finally 不能关闭新请求的 Loading;页面关闭或缓存复用后,旧操作不能继续提交。

先看一段很常见的错误实现

csharp 复制代码
public async void SelectHero(string heroId)
{
    loading.SetActive(true);
    var portrait = await portraits.LoadAsync(heroId);
    portraitImage.sprite = portrait;
    loading.SetActive(false);
}

只点一次时它完全正常。连续选择时,请求完成顺序不受调用顺序约束:

text 复制代码
t0  SelectHero("knight")
t1  SelectHero("mage")
t2  mage 加载完成,显示法师
t3  knight 加载完成,又显示剑士

如果玩家在 t2 关闭页面,t3 还可能访问已经解绑、销毁或进入缓存的对象。把空引用改成 if (image != null) 只能避免异常,不能避免旧结果污染新状态。

为什么 async void 会放大问题

UI 事件入口有时不得不使用 async void,但业务过程不应沿用它。调用方拿不到任务,就无法等待、复用、取消或观察异常:

csharp 复制代码
// 教学化简:事件入口只负责转交。
async void OnHeroClicked(string heroId)
{
    try { await SelectHeroAsync(heroId, lifetimeToken); }
    catch (OperationCanceledException) when (lifetimeToken.IsCancellationRequested) { }
    catch (Exception error) { ReportError(error); }
}

async Task SelectHeroAsync(string heroId, CancellationToken token)
{
    // 可测试、可等待、可捕获异常。
}

把返回类型改成 Task 并不会自动消除竞态,但它恢复了管理这次操作的能力。

四种常见方案分别解决什么

方案 能解决 不能单独解决
CancellationToken 通知可取消的底层操作停止 底层可能不响应,完成回调仍可能到达
操作版本号 判断结果是否仍属于最新意图 不会主动减少已经开始的工作
串行队列 保证请求按顺序执行 旧请求会阻塞新意图,不适合"最后一次选择优先"
忽略旧任务 新操作可以立即开始 若没有版本校验,旧任务仍能提交

英雄选择属于"最后一次意图获胜",最合适的是取消旧任务,同时在提交前校验版本。下载奖励列表等必须按顺序处理的工作,才更适合队列。

最小闭环:取消加版本校验

下面代码是独立于 FUI 的教学实现,用来说明机制:

csharp 复制代码
public sealed class HeroPortraitLoader : IDisposable
{
    readonly IHeroPortraitService service;
    CancellationTokenSource requestCts;
    int version;
    bool disposed;

    public HeroPortraitLoader(IHeroPortraitService service)
        => this.service = service ?? throw new ArgumentNullException(nameof(service));

    public Sprite Current { get; private set; }
    public bool IsLoading { get; private set; }

    public async Task SelectAsync(string heroId, CancellationToken lifetime)
    {
        if (disposed) throw new ObjectDisposedException(nameof(HeroPortraitLoader));
        requestCts?.Cancel();
        var mine = CancellationTokenSource.CreateLinkedTokenSource(lifetime);
        requestCts = mine;

        var myVersion = ++version;
        var token = mine.Token;
        IsLoading = true;

        try
        {
            var sprite = await service.LoadAsync(heroId, token);
            if (token.IsCancellationRequested || myVersion != version)
                return;

            Current = sprite;
        }
        catch (OperationCanceledException) when (token.IsCancellationRequested)
        {
            // 用户改变选择或关闭页面,不显示错误提示。
        }
        finally
        {
            if (myVersion == version)
                IsLoading = false;
            if (ReferenceEquals(requestCts, mine))
                requestCts = null;
            mine.Dispose();
        }
    }

    public void Dispose()
    {
        if (disposed) return;
        disposed = true;
        ++version;
        requestCts?.Cancel();
        requestCts = null;
        IsLoading = false;
    }
}

示例约定所有调用与状态提交都发生在 Unity 主线程,IHeroPortraitService 的签名为 Task<Sprite> LoadAsync(string heroId, CancellationToken token),返回的 Sprite 由服务统一持有和释放。它不是 FUI 的公开接口。若服务返回的是调用方拥有的资源租约,过期结果必须立即释放,替换 Current 和关闭页面时也要释放旧租约;只写一个 return 会泄漏资源。每次请求自己的 CancellationTokenSource 在自己的 finally 中释放,新请求只取消旧请求。

这里有两个容易忽略的细节。

第一,finally 也要检查版本。否则旧请求完成时会把新请求仍需要的 Loading 提前关闭。第二,关闭页面时既取消令牌又增加版本。即便资源后端无法真正取消,旧结果也失去了提交资格。

页面打开也要遵守同样的规则

异步加载不是某个业务页面的特殊问题。Navigator 打开页面时通常经历:

text 复制代码
创建待处理记录
→ 打开依赖
→ Provider 创建 ViewLease
→ 创建 ViewModel、BindingContext、Presenter
→ 建立绑定
→ 播放进入过渡
→ 提交到活动集合与历史

用户可能在任一步骤发出 Close。FUI 当前 Navigator 会维护打开版本和进行中的操作;缓存复用时产生新的 ViewHandle,旧句柄不会被分配给新的打开操作。这里的设计意图不是让调用方记住更多状态,而是让框架替调用方拒绝过期操作。

可以把打开过程理解成一笔事务。以下是教学伪代码,不是 FUI 的公开 API:

csharp 复制代码
async Task<ViewHandle> OpenCoreAsync(Route route, CancellationToken token)
{
    var tx = new OpenTransaction();
    var pending = BeginOpen(route);
    var version = pending.Version;

    try
    {
        tx.Lease = await provider.CreateAsync(route.Descriptor, null, token);
        EnsureCurrent(pending, version);

        tx.Instance = CreateInstance(route, tx.Lease.View);
        tx.Instance.Bind();
        await EnterAsync(tx.Instance, token);
        EnsureCurrent(pending, version);

        return tx.CommitTo(pending);
    }
    catch
    {
        tx.Rollback();
        MarkFailed(pending);
        throw;
    }
}

Commit 之前,资源和实例都归临时事务所有;提交之后,所有权才移交给活动页面记录。这样 Bind 失败、动画取消或依赖打开失败,都能沿同一条路径逆序释放。

为什么只在回调里判断 activeInHierarchy 不够

下面的补丁很常见:

csharp 复制代码
var sprite = await LoadAsync(heroId);
if (!gameObject.activeInHierarchy)
    return;
portraitImage.sprite = sprite;

它至少有四个漏洞:

  • 页面可能进入缓存后又被复用,此时对象重新激活,但已经是新的一次打开;
  • 页面被上层弹窗隐藏不代表请求应该失效;
  • 同一页面内部切换英雄时,GameObject 一直处于激活状态;
  • 提前返回没有说明刚加载到的资源由谁释放。

视觉状态不是操作身份。应比较句柄、版本或明确的业务请求 ID。

失败、取消和关闭不是同一件事

这三种结果应该进入不同路径:

  • 失败:当前意图仍有效,但工作无法完成,需要记录或展示错误;
  • 取消:意图已经改变,通常安静退出,不应弹"加载失败";
  • 关闭:页面生命周期结束,要取消命令、解绑监听并移交或释放资源。

把三者都放进 catch (Exception),会让正常取消污染错误监控,也会诱导开发者在每个页面里复制异常分支。

缓存复用为什么需要新句柄

假设英雄详情页关闭后进入缓存。旧请求保存着第一次打开获得的句柄;随后同一个对象被第二次打开并展示另一名英雄。如果缓存复用沿用旧句柄,迟到回调会被误认为仍然合法。

FUI 的 ViewHandle 表示一次页面实例生命周期,而不是 GameObject 的永久身份证。缓存对象可以复用,但新的打开必须获得新句柄。这个约束把"对象相同"和"操作仍属于当前代际"分开了。

怎么验证这个设计真的有效

至少覆盖以下时序:

  1. A 请求未完成时选择 B,A 后返回,最终仍显示 B。
  2. 请求未完成时关闭页面,资源返回后不会重新显示页面。
  3. 旧请求的 finally 不会关闭新请求的 Loading。
  4. 底层忽略 CancellationToken 时,版本校验仍能拒绝旧结果。
  5. Bind 或进入动画失败,已经取得的 Lease、监听和依赖全部回滚。
  6. 缓存页面重新打开后,旧 Handle 不能关闭新代际。
  7. 连续 Close 复用同一终止过程,不会执行两次退出动画或 Dispose。

异步 UI 的核心不是"到处传 CancellationToken",而是让每一个结果在提交前证明:发起它的意图仍然有效,它取得的资源仍有明确所有者,它面对的还是同一次页面生命周期。

资料与源码索引

下一篇将讨论 Source Generator:稳定的绑定和装配关系,为什么值得在编译期确定。

相关推荐
SmalBox1 小时前
01-02-认知篇-基础-Unity资源管理发展史
unity3d·游戏开发
额鹅恶饿呃2 小时前
随着CentOS官方停服的时间越来越久,大量仍在使用CentOS7的企业和运维从业者
java·python·算法·c#·ruby
乌药ice2 小时前
c#中一个多线程安全的HashSet
开发语言·c#
lfSeanDragon2 小时前
数据结构-前缀树(Trie)
开发语言·c#
张人玉3 小时前
基于 C# WinForms + .NET 8 + SQLite + TCP Socket 的多连接通讯调试工具——TCP通讯助手(TcpAssistant)
tcp/ip·sqlite·c#·.net
mldong11 小时前
C# 开发者也有自己的轻量工作流引擎了:NuGet 装包,5 分钟跑通一条审批流
后端·c#·.net
samble15 小时前
从零搭建工业控制系统(二十二):线程安全与并发控制
c#·wpf·并发·mvvm·线程安全·工业控制
UIU11416 小时前
scanf与cout的误区:探究其内部的机制
c++·学习·c#·scanf
风云17 小时前
MiniLog 1.0 GA 发布:零分配、零依赖的 .NET 高性能日志框架
开源·c#·.net·日志·高性能·minilog