技巧 · EXTRA
订阅的生命周期:什么时候订、什么时候退订
订阅写在哪一行、cancelable 凭证怎么收着、重复订阅为什么会让一次按下触发 N 次。
进入拓展 →蓝图作者做解耦靠 Event Dispatcher:一个东西喊一声,别的东西自己决定听不听。Verse 里同一件事叫 Subscribe。这一课把「广播 / 订阅」这套模型从节点图搬进代码,顺带讲清怎么引用另一个对象、怎么退订,以及为什么事件几乎总比 Tick 划算。
想想你上一次在蓝图里用 Event Dispatcher 的场景。门被打开了,门自己不知道谁关心这件事——它只管 Call 一下自己的 OnOpened 分发器,喊一声「我开了」。谁关心,谁自己去 Bind:计分板 Bind 一下,音效管理器 Bind 一下,成就系统 Bind 一下。门从头到尾没听说过它们中的任何一个。
这就是广播 / 订阅模型,也是蓝图里最重要的解耦工具。它值钱的地方不在「能触发别的逻辑」——直接持有一个引用去调函数也能触发;它值钱在方向:发布者不认识订阅者,所以你能随时增删一个监听者,而不用回去改门。
Verse 里,同一套模型用三个词表达:
Signal();Subscribe(处理函数);Subscribe 交回来的凭证调 Cancel()。另外 Verse 还多给了一件蓝图没有的东西:Await()——不注册处理函数,而是让当前这条执行线停在这里等这个事件响。蓝图里没有对应节点,因为蓝图的函数图不能中途停下(第 23 课讲过);Verse 有 <suspends>,于是「等一个事件」可以像等一个 Delay 那样直接写在执行线上。这一课后面会两条路都走一遍。
但在订阅之前,还有一件更基础的事要解决:你的代码怎么才能拿到那扇门?
蓝图里的做法你闭着眼都能做:在类里加一个变量,类型选 Actor 引用(或某个具体的 Actor 类),勾上 Instance Editable 那只小眼睛;摆进关卡后,在 Details 面板里点那个下拉框,从关卡里挑一个 Actor 拖进去。代码不认识具体是哪扇门,门是配置进来的。
Verse 一模一样,只是那只小眼睛现在写成一行属性:@editable。
using { /Fortnite.com/Devices }
# 一台带插槽的「遥控器」
remote_device := class(creative_device):
# 插槽一:关卡里的一个按钮。@editable 独占一行,写在字段上方
@editable
TriggerButton:button_device = button_device{}
# 插槽二:普通数值也能暴露,策划改数字不用碰代码
@editable
Delay:float = 2.0
逐行对照蓝图:@editable 那一行 = 勾上 Instance Editable;TriggerButton:button_device = 一个「按钮类型的 Actor 引用变量」;= button_device{} = 默认值。Delay:float = 2.0 就是变量面板里一个默认 2.0 的 Float,策划直接在 Details 面板改。
三条规则要记牢。第一,@editable 独占一行,写在字段上方。第二,必须给默认值——引用类的字段用一个空原型占位(= button_device{}),漏了直接 Compile 报红;蓝图里对象引用默认是 None,Verse 不允许这种「空着」的状态,这是 Verse 从根上消灭 Accessed None 的一环(第 18 课讲过 option)。第三,能挂 @editable 的不止对象引用:int、float、logic、string 以及它们的数组都行;想把你自己写的类整个摆上面板,那个类还得加 <concrete> 标记(条件是它每个字段都有默认值)。
和蓝图完全一致的一点:改面板上的值不用重新 Compile。冷却时间从 2 秒调到 3 秒,策划拖一下就完事。也正因如此,「在代码里按名字硬找关卡里的对象」在两边都是坏习惯——关卡随时会改,而插槽只要重新拖一次。
插槽里有了按钮,回答开头那个问题:按钮被按下的那一刻,代码怎么知道?蓝图里你会在事件图上放一个 Bind Event to On Interacted,把红色的事件引脚连到一个 Custom Event 上。Verse 里就是一次函数调用:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 警报器:订阅、退订、干等,三种姿势一次看全
alarm_device := class(creative_device):
@editable
AlarmButton:button_device = button_device{}
OnBegin<override>()<suspends>:void =
# 订阅:按钮被按下时,自动调用 OnAlarm
Sub := AlarmButton.InteractedWithEvent.Subscribe(OnAlarm)
# Subscribe 交回一个 cancelable;想退订就 Sub.Cancel()
# 另一条路:不订阅,直接「等」——Await 是异步调用
FirstAgent := AlarmButton.InteractedWithEvent.Await()
Print("等到了第一次按下,OnBegin 继续往下走")
OnAlarm(Agent:agent):void =
Print("警报!有人按下了按钮!")
订阅写在哪?和蓝图一样:写在「开场只跑一次」的地方。蓝图里是 Event BeginPlay,Verse 里是 OnBegin。写在别处不是不行,但要小心——这也是本课最常见的坑,见下面第三条。
处理函数的签名必须匹配。蓝图里 Bind Event 时,如果签名对不上,连线根本连不上去;Verse 里对不上就是 Compile 报红。InteractedWithEvent 会把「按按钮的那个人」当成一个 agent 递过来(相当于蓝图事件上的 Instigator 引脚),所以处理函数必须写成 OnAlarm(Agent:agent):void——少一个参数不行,把类型写成 player 也不行。还有一个细节值得敲黑板:Subscribe(OnAlarm) 括号里填的是函数名字,后面不加括号、不填参数。你是把这张函数图交给按钮保管,让它以后需要时再运行,不是现在就跑一遍——接线 ≠ 现在就拉一次。
退订。Subscribe 会交回一个 cancelable(可取消凭证),对它调 Cancel() 就等于蓝图的 Unbind Event。蓝图里你还有一个 Unbind All Events 可以一键清场,Verse 没有对应物——凭证得你自己收好,绑得多了就存进一个数组 var Subs:[]cancelable,清场时挨个 Cancel()。什么时候必须退订、什么时候可以不管,拓展页 x1 有一整页。
处理函数里不能等。你订阅上去的那个函数是「立即跑完」类型,签名由官方定死,不带 <suspends>——里面直接放 Sleep 一定报错。这和蓝图那条「Delay 不能放进自定义 Function」的限制是同一种味道。解法是请第 24 课的分身术出场:在里面 spawn{ SomeAsyncFunc() } 岔出一条新线,让那条线去睡去等。spawn 恰好是唯一允许待在「不能等的执行线」上的并发表达式,这正是它的主场。
把零件拼起来看一次完整链路:插槽 + 一个计数变量 + 订阅 + 签名对得上的处理函数,就是一台会记账的门铃。点「运行下一步」。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 计数门铃:每按一次,记一次账
doorbell_device := class(creative_device):
@editable
Bell:button_device = button_device{}
var PressCount:int = 0
OnBegin<override>()<suspends>:void =
Bell.InteractedWithEvent.Subscribe(OnRing)
Print("门铃就绪,等你来按。")
OnRing(Agent:agent):void =
set PressCount += 1
Print("叮咚!第 {PressCount} 次按铃")
点「运行下一步」,看代码怎么一行行执行。
留意 OnBegin 跑完之后发生的事:设备并没有「结束」。它守着接好的线,玩家什么时候按,OnRing 就什么时候被叫醒,按十次记十次账——和蓝图里 BeginPlay 跑完、Bind 好的事件继续生效,是完全一样的心智模型。
原生设备自带事件;那你自己写的两个 Verse 类之间怎么喊话?这正是蓝图里 Event Dispatcher 的主场:在类里 Add Event Dispatcher,起个名字,配好输入参数,然后在需要的地方 Call 一下。
Verse 里对应的是 event(t)。声明有一条铁律:「类型 = 原型」必须成对写全,即 RaceStartEvent<public>:event(agent) = event(agent){}。只写左半边,Compile 会抱怨这是个抽象的、没法直接用的东西。括号里的 agent 就是这个分发器的输入参数类型(蓝图里你在 Event Dispatcher 面板上配的那些 Inputs);没参数就写 event() = event(){}。<public> 是为了让别的类也访问得到——通信的另一端通常在另一个类里。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# event(t) 的 Signal / Await 来自 /Verse.org/Concurrency
# 以 Verse API Reference 为准(不同版本模板的 using 路径可能有差异)
using { /Verse.org/Concurrency }
# 发令端:按钮一按,向所有听众广播「比赛开始」
starter_device := class(creative_device):
@editable
StartButton:button_device = button_device{}
# 相当于蓝图里 Add Event Dispatcher,输入参数是一个 agent
RaceStartEvent<public>:event(agent) = event(agent){}
OnBegin<override>()<suspends>:void =
StartButton.InteractedWithEvent.Subscribe(OnPressed)
OnPressed(Agent:agent):void =
# Signal 就是 Call Dispatcher:喊一声,把 Agent 一起带上
RaceStartEvent.Signal(Agent)
Print("发令枪响!")
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# 听令端:等发令枪响,再放行屏障
gate_device := class(creative_device):
# @editable 也能引用你自己写的 Verse 类
@editable
Starter:starter_device = starter_device{}
@editable
Gate:barrier_device = barrier_device{}
OnBegin<override>()<suspends>:void =
# Await 是异步调用,OnBegin 带 suspends,正好合法
Racer := Starter.RaceStartEvent.Await()
Gate.Disable()
Print("发令枪响,屏障放行!")
整条链路对照着蓝图读一遍:玩家按下发令台按钮 → OnPressed 这张图跑起来(蓝图里那个 Custom Event 被点亮) → RaceStartEvent.Signal(Agent) 就是 Call Dispatcher,把玩家一起带上 → 另一端那条 Await 着的执行线被叫醒,拿到 Agent → Gate.Disable() 放行。注意 gate_device 面板上那个插槽引用的是你自己写的 starter_device——进编辑器把发令台拖进去,两者就配对了,和蓝图里拖一个 Actor 引用是同一个动作。
有一处语义要留个心眼:同一个事件同时被多条 Await 等着时,唤醒顺序与「叫醒一个还是叫醒全部」在不同版本的文档里表述并不一致。需要「一次广播、多个监听者都收到」这种确定行为时,用 Subscribe 注册多个处理函数,而不是让多条线各自 Await 同一个事件——这也更贴近你在蓝图里的直觉:Event Dispatcher 的多个 Bind 都会被通知。
轮到你了。下面这台「对讲机」被挖掉了两个关键动作:一个负责喊(Call Dispatcher),一个负责等。把暗号补全:
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
using { /Verse.org/Concurrency }
# 对讲机:按钮一按,巡逻兵立刻收到暗号
relay_device := class(creative_device):
@editable
GoButton:button_device = button_device{}
GoEvent:event() = event(){}
OnBegin<override>()<suspends>:void =
GoButton.InteractedWithEvent.Subscribe(OnGo)
WaitForGo()
OnGo(Agent:agent):void =
# 喊一声(相当于 Call Dispatcher)
GoEvent.____()
WaitForGo()<suspends>:void =
# 停在这里,直到有人喊
GoEvent.____()
Print("收到暗号,出发!")
同一个需求——「玩家按下按钮就开门」——有两种写法。轮询版是这样的:
# ✗ 轮询(示意):相当于把逻辑塞进 Event Tick,每帧问一次「按了吗?」
# ButtonIsPressed 是你自己维护的一个 logic 字段,这里只为对比结构
PollForPress()<suspends>:void =
loop:
if (ButtonIsPressed?):
OpenDoor()
Sleep(0.0)
# ✓ 事件驱动:登记一次,剩下的交给引擎
OnBegin<override>()<suspends>:void =
Bell.InteractedWithEvent.Subscribe(OnRing)
轮询版每秒要跑约 30 次(第 23 课讲过服务器的模拟帧节奏),而这 30 次里通常 30 次都在做同一件事:确认「没按」。事件版一整局可能只跑两次——玩家真按了两次。差距不是几个百分点,是几个数量级;而且它还是随设备数量线性增长的:20 台门,轮询就是 20 条每帧空转的执行线。
更麻烦的是延迟和正确性。轮询最多晚一帧才发现状态变了,而且只能看到「现在是什么状态」,看不到「刚刚发生了什么」——玩家在同一帧内按下又松开,轮询直接漏掉,事件则一次不落。这就是为什么 Unreal 的性能优化文章几十年如一日地重复同一句话:能不用 Tick 就别用 Tick。这条建议在 Verse 里一字不改地成立,只是 Tick 换了个名字,叫「循环体里的 Sleep(0.0)」。
给蓝图作者的明确工程建议,三句话:
Subscribe,没有就自己声明一个 event(t) 让上游 Signal。loop + Sleep(0.0);而且做完就退出循环,别让它整局空转。Await,不要用循环去等。race 里挂一条 Await 加一条 Sleep,就是第 24 课那道超时配方——比「每帧查一次还剩几秒」干净得多。这一课的坑有个共同特点:一半在代码外面。逐条对照,能省下大量排查时间。
@editable 插槽忘了在 Details 面板里拖入实际对象。Compile 全绿,一进游戏就报「访问无效对象」的运行时错误。@editable 只负责开槽,连线要进编辑器手动拖——和蓝图里忘了给 Actor 引用变量赋值,是同一个错误。= button_device{} 这样的空原型占位。agent 参数、或把类型写成 player,都过不了 Compile。签名以官方 API 为准,别凭感觉猜——蓝图里连线连不上,Verse 里就是这个报错。spawn 岔出一条新线去等。Cancel() 再重订。@editable 字段。另外,重命名 @editable 字段可能让面板上已配置的引用丢失,需要重新拖。event(t) 不会出现在原生设备的事件下拉框里——它只能被其它 Verse 代码 Signal / Await / Subscribe。这一课几乎每个概念在蓝图里都有一个同名的东西。差异那一列才是重点。
| 蓝图里的做法 | Verse 里的写法 | 差异 |
|---|---|---|
| 在类里 Add Event Dispatcher,配好 Inputs | MyEvent<public>:event(agent) = event(agent){} |
Verse 必须「类型 = 原型」成对写全,少右半边就报抽象错误;括号里的类型就是分发器的 Inputs |
| Bind Event to XXX 节点,红色引脚连到 Custom Event | Button.InteractedWithEvent.Subscribe(OnPressed) |
蓝图靠连线校验签名(对不上连不上);Verse 靠编译器校验(对不上 Compile 报红)。传的是函数名,不带括号 |
| Unbind Event / Unbind All Events | Sub := ...Subscribe(...),之后 Sub.Cancel() |
Verse 没有「一键全退」:凭证得你自己收着,通常存进 var Subs:[]cancelable |
| Call Dispatcher 节点 | MyEvent.Signal(Agent) |
语义一致。但 Verse 的事件还能被 Await——这是蓝图没有的第三种用法 |
| Actor 引用变量(Object Reference 类型的变量) | @editable 字段,如 Button:button_device = button_device{} |
蓝图的对象引用默认是 None;Verse 强制给默认值,不允许「空着」这个状态 |
| 在 Details 面板里从关卡拖一个 Actor 进那个变量槽 | 同一个 Details 面板,拖进 @editable 槽 |
完全一致——这一步 Verse 没有代码替代品,也不该有:配置和逻辑分开才是对的 |
差异的来源是两件事。其一,蓝图把「签名匹配」交给连线这个动作去保证,连不上就是错;Verse 没有连线,只好把这份保证移交给编译器,于是你会看到更多、更早的红字。其二,Verse 有 <suspends>,所以事件除了「注册一个回调」之外还多了一种消费方式:让一条执行线停在事件上等。蓝图的 Bind 没有这种形态,因为蓝图的执行线停不下来。
还有一处蓝图更方便,得承认:蓝图的 Event Dispatcher 面板上,「谁 Bind 了我」是可以点开看的;Verse 里没有这样的可视化——想知道一个事件有多少订阅者,只能靠搜代码。这是文本代码换来编译期安全所付出的代价之一。
布线手艺练完了,三道关卡验收。答错零惩罚,可以一直重试。
Compile 全绿,但一进游戏按下按钮就报「访问无效对象」的运行时错误。最可能的原因是?
想在 Subscribe 上去的那个处理函数里等 3 秒再开门,正确做法是?
要在 Verse 里写一个相当于 Event Dispatcher 的东西,哪个写法能 Compile 通过?