本文面向有 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 的永久身份证。缓存对象可以复用,但新的打开必须获得新句柄。这个约束把"对象相同"和"操作仍属于当前代际"分开了。
怎么验证这个设计真的有效
至少覆盖以下时序:
- A 请求未完成时选择 B,A 后返回,最终仍显示 B。
- 请求未完成时关闭页面,资源返回后不会重新显示页面。
- 旧请求的
finally不会关闭新请求的 Loading。 - 底层忽略 CancellationToken 时,版本校验仍能拒绝旧结果。
- Bind 或进入动画失败,已经取得的 Lease、监听和依赖全部回滚。
- 缓存页面重新打开后,旧 Handle 不能关闭新代际。
- 连续 Close 复用同一终止过程,不会执行两次退出动画或 Dispose。
异步 UI 的核心不是"到处传 CancellationToken",而是让每一个结果在提交前证明:发起它的意图仍然有效,它取得的资源仍有明确所有者,它面对的还是同一次页面生命周期。
资料与源码索引
下一篇将讨论 Source Generator:稳定的绑定和装配关系,为什么值得在编译期确定。