技术博客

AbilityTask 机制:异步事件怎么挂进技能生命周期

第 66 / 100 篇 · 2025-05-12 · ue5 · GAS异步编程源码分析

技能激活是同步函数,但技能流程大半是异步的:等攻击 Montage 到第 12 帧才出判定框、等目标选取 UI 回传才发射、等服务器确认才进下一段。如果让 ActivateAbility 自己轮询这些事件,技能实例就得一直活着占着资源;如果激活完就结束,事件来了又没人在听。AbilityTask 是这个矛盾的解:激活期内存在的临时订阅者,任务存活期精确等于"技能关心这件事的时间段"。源码在 AbilityTask.cpp / Abilities/Tasks/(UE5.3)。

是什么:不是线程任务,是延迟回调的载体

先澄清一个普遍误解:AbilityTask 和异步没有任何线程关系,它是单线程上的延迟回调注册。UAbilityTask 继承自 UGameplayTask,核心结构就三块:

  1. 静态工厂方法(蓝图里就是那些 WaitXXX 节点)创建任务实例,挂到当前激活技能的 ActiveTasks 列表;
  2. Activate() 里把任务的成员委托绑到外部事件源(AnimInstance 的 Montage 通知、ASC 的 GameplayEvent、目标选取器的回传通道);
  3. 事件触发时任务收到回调,先过 ShouldBroadcastAbilityTask 三重检查,通过则广播给自己的 OnXXX 动态多播委托——技能的 ActivateAbility 里绑定的后续逻辑被执行。

任务实例的生命周期被技能严格托管:技能 EndAbility / CancelAbility 时遍历 ActiveTasks 全部销毁,委托自动解绑。反过来任务也可以主动结束技能——EndTask() 内部调 Ability->EndAbility(...) 的语义通道是双向的。任务的存活期就是订阅期,这把"技能活着"和"技能在等事件"两个概念解耦了。

三重门:ShouldBroadcastAbilityTask

事件触发 ≠ 回调执行。每个任务的广播前都过 ShouldBroadcastAbilityTask() 三道检查,这是 AbilityTask 机制里最值得逐条理解的部分:

  1. 技能还在激活吗:Ability->IsActive()。技能被眩晕打断了,挂着的 Wait 任务收到事件也必须沉默——否则"被打断的技能继续出判定"就是逻辑穿帮。技能结束销毁任务时这个检查是兜底而不是主防线,但 Cancel 路径上它是最先拦的;
  2. 预测键还在吗(预测路径):任务创建时记下当时的 FPredictionKey,广播时检查 key 是否已被回滚(rejected)。服务器拒绝了这次激活,客户端这个 key 名下的一切都作废——包括挂在这个 key 上的任务回调。任务回调也是预测副作用,这条检查保证被回滚的预测不会从任务侧"诈尸";
  3. 网络权限对吗:GetNetExecutionPolicy() 与当前端匹配。只在服务器执行的任务(ServerOnly)在客户端收到事件不广播,反之亦然。

三重门对应三类失效场景:技能结束、预测回滚、网络角色不符。写自定义 AbilityTask 时,如果你的回调不走 ShouldBroadcastAbilityTask(比如直接绑了外部系统的原生委托),这三类防护就全丢了——这是自定义任务最常见的 bug 来源。

预测键在任务链上的传播

任务回调里应用的 GE、触发的 cue,PredictionKey 从哪来?答案是任务创建时的作用域。ActivateAbility 在预测窗口内(FScopedPredictionWindow 活着)创建任务,任务构造时把当时的 ScopedPredictionKey 存下来;回调执行时如果还在同一个预测上下文中,应用的 GE 自动归到这个 key 名下。链式技能("命中后触发二段")靠这个传播保持回滚一致性:一段的预测 key 是二段任务回调里 GE 的归属 key,一段被拒时二段的所有副作用沿依赖链级联作废(7040 篇的 Dependents 链)。

但这个传播有边界:任务回调执行时如果没有打开预测窗口,拿到的 key 是任务创建时缓存的旧 key——它可能已被 caught up(并账)甚至过期。此时应用 GE 不挂新预测,而是直接走权威路径。规范做法和技能本体一致:预测性副作用在创建路径内做完,异步回调里的操作当 authoritative 处理。这个边界也正是"技能表现预测正常、伤害判定偶尔不同步"排查清单的第一项:确认判定 GE 是在激活同步路径应用的,还是在 WaitMontage 回调里补的。

为什么 Task 是写玩法的主力

对比"不用 Task 的替代方案",Task 的主力地位就清楚了:

  • Coroutines / async:UE 没有原生协程进 UFUNCTION 体系,蓝图里的 Delay 节点在技能里会逃出预测管辖;
  • 定时器轮询:GetWorldTimerManager 的回调没有预测键概念、不受技能生命周期托管——技能被打断时定时器还在跑,且回调里的 GE 应用不在任何预测窗口内;
  • 直接把逻辑塞进 Notify:动画通知不复制(6080 篇),网络语义全丢。

Task 的优势是把"等事件"这件异构的事纳入了 GAS 的统一管辖:生命周期随技能、广播前三重门、预测键自动传播。官方内置任务覆盖了玩法的高频等待场景:WaitGameplayEvent(等 GameplayEvent tag)、WaitGameplayTag(等 tag 出现/消失)、WaitTargetData(目标选取回传)、WaitMontageNotify(等 Montage 通知)、WaitDelay(带预测语义的延迟)。

WaitTargetData 单独值得一段:客户端发起目标选取(鼠标点选/扇形预览),选取结果不是直接回调,而是序列化成 FGameplayAbilityTargetData 通过 RPC 发给服务器,服务器反序列化后才走任务的确认回调。这个"数据先行、回调后置"的结构让目标选取天然支持预测——客户端可以先用本地选取结果跑表现,服务器的权威 TargetData 到达后再校正。

机制全景

AbilityTask:激活期内的订阅者,广播前过三重门,预测键随任务传播 ActivateAbility 预测窗口内 创建 Task,缓存 key UAbilityTask 实例 挂入 ActiveTasks 委托绑外部事件源 外部事件 Montage 通知 TargetData 到达 ShouldBroadcastAbilityTask ① 技能仍激活 ② 预测 key 未回滚 ③ 网络权限匹配 OnXXX 委托广播 技能后续逻辑执行 回调内 GE 归缓存 key 管辖 生命周期托管 EndAbility/Cancel → 遍历 ActiveTasks 销毁 任务存活期 = 订阅期 对比替代品 定时器:无预测键、不受 Cancel 管辖 动画通知:不复制,网络语义全丢