task 与 awaitable:spawn 到底返回什么
蓝图的 Set Timer by Event 会交给你一个 Timer Handle,握着它才能回头 Clear;Verse 的 spawn 也会递给你一张回执:task 对象。这一页讲清 task 怎么 Await、为什么 Fortnite 里的 task 没有 Cancel(),以及社区总结的「自制取消」标准姿势。
一、task:分身的回执
正课说 spawn「不等待」,但没说它一无所获。spawn{ Fn() } 这个动作会往外递一个值:一个 task 对象。如果 Fn 完事后会送出类型 t 的东西,你拿到的就是 task(t)——一张写着「分身正在干活,凭此回执领取成果」的凭条。task 实现了 awaitable 接口(可以把它想成一个 Bind 好之后等它响一声的事件分发器;官方 API 参考归在 /Verse.org/Concurrency 下),核心能力只有一个:Await()<suspends>:t——停下来等这个任务跑完,再把它送出的值接到手里。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
potion_lab_device := class(creative_device):
BrewPotion()<suspends>:int =
Sleep(2.0)
Print("药剂出炉")
42
OnBegin<override>()<suspends>:void =
PotionTask := spawn{ BrewPotion() }
Print("分身去炼药,主线先布置货架")
Sleep(3.0)
Result := PotionTask.Await()
Print("取货:效力 {Result} 的药剂")
读图:BrewPotion 是个会等的自定义函数(Delay 2 秒、Print String、最后送出整数 42)。OnBegin 里 spawn 岔出一条线去跑它,并把那张 task 回执接进变量 PotionTask(:= = 新建变量并接上);主线接着 Print String、再 Delay 3 秒;然后对 PotionTask 调 Await() 领回结果,存进 Result,最后 Print String 打出来。
时间线:第 0 秒,分身领命出发(先一口气跑到第一个会等的节点 Sleep(2.0)),主线打出「布置货架」;第 2 秒,药剂出炉,分身收工,成果 42 存进回执;第 3 秒,主线睡醒来 Await()——任务早已完成,所以 Await 立刻返回 42,一秒都不多等。这就是回执模式的妙处:「启动」和「取结果」可以分开,中间随便插别的活。你也可以一口气 spawn 好几个任务、把回执都存下来,稍后逐个 Await——「先全部发射、再统一等待」,并行加载的常用套路。
二、没有 Cancel():自制取消事件
看到 task,老练的开发者会立刻找那个熟悉的按钮:Cancel()。坏消息——Fortnite 环境里公开的 task 接口没有 Cancel() 方法,这也是社区抱怨已久的一点。想中途叫停一个 spawn 出去的分身,标准姿势是:在被 spawn 的那个函数内部开一场 race,一条线干正事,另一条线等一个「取消事件」——外面想叫停时,朝这个事件 Signal(喊)一声,事件那条线立刻获胜,干活那条线就在它下一个会等的节点处被自动取消。等于借 race 的取消规则,亲手给任务装了个刹车。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
patrol_device := class(creative_device):
StopEvent:event() = event(){}
PatrolLoop()<suspends>:void =
race:
block:
loop:
Print("巡逻中……")
Sleep(1.0)
StopEvent.Await()
OnBegin<override>()<suspends>:void =
spawn{ PatrolLoop() }
Sleep(3.5)
StopEvent.Signal()
Print("已叫停巡逻")
读图:StopEvent 是一个不带负载的事件分发器(event())——专门用来喊「停」。PatrolLoop 里摆一个 race,一条线是「loop:每圈 Print String『巡逻中』再 Delay 1 秒」,另一条线是 StopEvent.Await()(Bind 上去等它响)。OnBegin 里 spawn 出这条巡逻线,主线 Delay 3.5 秒后朝 StopEvent Signal 一声,巡逻就被叫停。
拆解一下:loop 那条线每圈都有一次 Sleep(1.0)——这既是「让出控制权喘口气」的礼貌(上一课的规矩),也是取消能够落地的会等节点;StopEvent.Await() 那条线平时安静沉睡,直到第 3.5 秒主线朝它 Signal(),它瞬间完成并赢下 race,巡逻循环随即在自己的 Sleep 处被取消。从外面看,效果就是「这个 spawn 的任务被取消了」。最后提醒一句边界:如果任务本来就该跟那段图同生共死,直接用 branch 就好,犯不着自己造取消——自制刹车是留给「必须 spawn、又想收得回来」的场合的。
spawn{ BrewPotion() } 这个动作往外吐出来的值是什么?
三、常见坑:spawn 与 branch,谁管分身的命
拿着回执上路之前,先绕开两个新手常撞的坑。坑一:spawn 的花括号里只能放一个「带小钟表」的函数调用,不能塞一堆节点——想「先打印、再 Delay、再打印」?写 spawn{ 一串节点 } 会直接让 Compile 亮红。改法很朴素:把这几步装进一个 <suspends> 函数,再 spawn 那个函数。坑二:别指望 spawn 的线会随那段图一起谢幕。它是彻底逃逸的:启动它的函数早就结束了,分身还在跑;等回合结束、设备被销毁,残留的线再去碰那些已经不存在的对象,就会撞出运行时错误。也正因如此,官方的指导原则是:能用结构化并发(sync / race / rush / branch)就不用 spawn。
| 对比项 | spawn | branch |
|---|---|---|
| 可用位置 | 在「不能等的执行线」上也能用(如绑定事件的响应) | 只能在「能等的执行线」上用 |
| 块内写法 | 只收一个「带小钟表」的函数调用 | 块内可以放好几个节点 |
| 任务寿命 | 逃逸——启动它的那段图结束后,它照样独立跑 | 外面那段图一退出,这条线就被撤销 |
| 回执 | 返回 task 对象,稍后可 Await() | 不给回执,后面的节点立即继续 |
一句话记法:branch 的分身拴着绳,那段图散场就被收走;spawn 的分身没有绳,但你手里多一张 task 回执。该拴绳的场合拴绳,该要回执的场合要回执——顺带还能解释一个「灵异现象」:在函数最后一步写 branch,任务看起来「什么都没干」,其实是函数随即结束、那段图退出,分身刚出门就被收走了。
本文整理自官方文档与社区教程:Spawn in Verse ↗、awaitable — Verse API Reference ↗、社区视频 What does spawn return?(UEFN Verse Boost Ep. 13)↗。