十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity UI 异步竞态:用取消令牌与版本号阻止旧回调覆盖新页面

Unity UI 异步竞态:用取消令牌与版本号阻止旧回调覆盖新页面 英雄详情页快速切换两名英雄后头像偶尔会跳回上一名页面关闭后加载回调还会继续赋值。这类 Unity UI 问题通常不是加载失败而是旧请求晚于新请求完成。取消令牌、操作版本、资源所有权和页面句柄需要一起设计。下面先复现错误时序再给出最小修复并说明 FUI 如何把导航层的过期操作统一挡住。先看一段很常见的错误实现publicasyncvoidSelectHero(stringheroId){loading.SetActive(true);varportraitawaitportraits.LoadAsync(heroId);portraitImage.spriteportrait;loading.SetActive(false);}只点一次时它完全正常。连续选择时请求完成顺序不受调用顺序约束t0 SelectHero(knight) t1 SelectHero(mage) t2 mage 加载完成显示法师 t3 knight 加载完成又显示剑士如果玩家在t2关闭页面t3还可能访问已经解绑、销毁或进入缓存的对象。把空引用改成if (image ! null)只能避免异常不能避免旧结果污染新状态。为什么async void会放大问题UI 事件入口有时不得不使用async void但业务过程不应沿用它。调用方拿不到任务就无法等待、复用、取消或观察异常// 教学化简事件入口只负责转交。asyncvoidOnHeroClicked(stringheroId){try{awaitSelectHeroAsync(heroId,lifetimeToken);}catch(OperationCanceledException)when(lifetimeToken.IsCancellationRequested){}catch(Exceptionerror){ReportError(error);}}asyncTaskSelectHeroAsync(stringheroId,CancellationTokentoken){// 可测试、可等待、可捕获异常。}把返回类型改成Task并不会自动消除竞态但它恢复了管理这次操作的能力。四种常见方案分别解决什么方案能解决不能单独解决CancellationToken通知可取消的底层操作停止底层可能不响应完成回调仍可能到达操作版本号判断结果是否仍属于最新意图不会主动减少已经开始的工作串行队列保证请求按顺序执行旧请求会阻塞新意图不适合“最后一次选择优先”忽略旧任务新操作可以立即开始若没有版本校验旧任务仍能提交英雄选择属于“最后一次意图获胜”最合适的是取消旧任务同时在提交前校验版本。下载奖励列表等必须按顺序处理的工作才更适合队列。最小闭环取消加版本校验下面代码是独立于 FUI 的教学实现用来说明机制publicsealedclassHeroPortraitLoader:IDisposable{readonlyIHeroPortraitServiceservice;CancellationTokenSourcerequestCts;intversion;booldisposed;publicHeroPortraitLoader(IHeroPortraitServiceservice)this.serviceservice??thrownewArgumentNullException(nameof(service));publicSpriteCurrent{get;privateset;}publicboolIsLoading{get;privateset;}publicasyncTaskSelectAsync(stringheroId,CancellationTokenlifetime){if(disposed)thrownewObjectDisposedException(nameof(HeroPortraitLoader));requestCts?.Cancel();varmineCancellationTokenSource.CreateLinkedTokenSource(lifetime);requestCtsmine;varmyVersionversion;vartokenmine.Token;IsLoadingtrue;try{varspriteawaitservice.LoadAsync(heroId,token);if(token.IsCancellationRequested||myVersion!version)return;Currentsprite;}catch(OperationCanceledException)when(token.IsCancellationRequested){// 用户改变选择或关闭页面不显示错误提示。}finally{if(myVersionversion)IsLoadingfalse;if(ReferenceEquals(requestCts,mine))requestCtsnull;mine.Dispose();}}publicvoidDispose(){if(disposed)return;disposedtrue;version;requestCts?.Cancel();requestCtsnull;IsLoadingfalse;}}示例约定所有调用与状态提交都发生在 Unity 主线程IHeroPortraitService的签名为TaskSprite LoadAsync(string heroId, CancellationToken token)返回的 Sprite 由服务统一持有和释放。它不是 FUI 的公开接口。若服务返回的是调用方拥有的资源租约过期结果必须立即释放替换 Current 和关闭页面时也要释放旧租约只写一个return会泄漏资源。每次请求自己的 CancellationTokenSource 在自己的finally中释放新请求只取消旧请求。这里有两个容易忽略的细节。第一finally也要检查版本。否则旧请求完成时会把新请求仍需要的 Loading 提前关闭。第二关闭页面时既取消令牌又增加版本。即便资源后端无法真正取消旧结果也失去了提交资格。页面打开也要遵守同样的规则异步加载不是某个业务页面的特殊问题。Navigator 打开页面时通常经历创建待处理记录 → 打开依赖 → Provider 创建 ViewLease → 创建 ViewModel、BindingContext、Presenter → 建立绑定 → 播放进入过渡 → 提交到活动集合与历史用户可能在任一步骤发出 Close。FUI 当前 Navigator 会维护打开版本和进行中的操作缓存复用时产生新的ViewHandle旧句柄不会被分配给新的打开操作。这里的设计意图不是让调用方记住更多状态而是让框架替调用方拒绝过期操作。可以把打开过程理解成一笔事务。以下是教学伪代码不是 FUI 的公开 APIasyncTaskViewHandleOpenCoreAsync(Routeroute,CancellationTokentoken){vartxnewOpenTransaction();varpendingBeginOpen(route);varversionpending.Version;try{tx.Leaseawaitprovider.CreateAsync(route.Descriptor,null,token);EnsureCurrent(pending,version);tx.InstanceCreateInstance(route,tx.Lease.View);tx.Instance.Bind();awaitEnterAsync(tx.Instance,token);EnsureCurrent(pending,version);returntx.CommitTo(pending);}catch{tx.Rollback();MarkFailed(pending);throw;}}Commit之前资源和实例都归临时事务所有提交之后所有权才移交给活动页面记录。这样 Bind 失败、动画取消或依赖打开失败都能沿同一条路径逆序释放。为什么只在回调里判断activeInHierarchy不够下面的补丁很常见varspriteawaitLoadAsync(heroId);if(!gameObject.activeInHierarchy)return;portraitImage.spritesprite;它至少有四个漏洞页面可能进入缓存后又被复用此时对象重新激活但已经是新的一次打开页面被上层弹窗隐藏不代表请求应该失效同一页面内部切换英雄时GameObject 一直处于激活状态提前返回没有说明刚加载到的资源由谁释放。视觉状态不是操作身份。应比较句柄、版本或明确的业务请求 ID。失败、取消和关闭不是同一件事这三种结果应该进入不同路径失败当前意图仍有效但工作无法完成需要记录或展示错误取消意图已经改变通常安静退出不应弹“加载失败”关闭页面生命周期结束要取消命令、解绑监听并移交或释放资源。把三者都放进catch (Exception)会让正常取消污染错误监控也会诱导开发者在每个页面里复制异常分支。缓存复用为什么需要新句柄假设英雄详情页关闭后进入缓存。旧请求保存着第一次打开获得的句柄随后同一个对象被第二次打开并展示另一名英雄。如果缓存复用沿用旧句柄迟到回调会被误认为仍然合法。FUI 的ViewHandle表示一次页面实例生命周期而不是 GameObject 的永久身份证。缓存对象可以复用但新的打开必须获得新句柄。这个约束把“对象相同”和“操作仍属于当前代际”分开了。怎么验证这个设计真的有效至少覆盖以下时序A 请求未完成时选择 BA 后返回最终仍显示 B。请求未完成时关闭页面资源返回后不会重新显示页面。旧请求的finally不会关闭新请求的 Loading。底层忽略 CancellationToken 时版本校验仍能拒绝旧结果。Bind 或进入动画失败已经取得的 Lease、监听和依赖全部回滚。缓存页面重新打开后旧 Handle 不能关闭新代际。连续 Close 复用同一终止过程不会执行两次退出动画或 Dispose。异步 UI 的核心不是“到处传 CancellationToken”而是让每一个结果在提交前证明发起它的意图仍然有效它取得的资源仍有明确所有者它面对的还是同一次页面生命周期。资料与源码索引FUI 仓库FUINavigator.Operations.csFUIViewHandle.csFUIResourceContracts.cs下一篇Unity Source Generator 选型为什么生成绑定代码还不够
返回列表