事件订阅想传额外参数?Handler 类模式
十个按钮共用一个回调,却想知道「按的是几号」?Subscribe 只吃一种形状的函数,额外参数根本递不进去——这是 Verse 新手最高频的卡点之一。这一页讲社区总结、Epic 员工确认的标准解法:Handler 类模式。
十个按钮共用一段处理逻辑,却想知道「按的是几号」?Subscribe(把设备事件绑到你的逻辑上,就是蓝图的 Bind Event)只认一种固定形状的函数,想额外多塞点参数根本递不进去——这是 Verse 新手最高频的卡点之一。这一页讲社区总结、Epic 员工确认的标准解法:Handler 类模式。
一、问题:Subscribe 只吃一种形状
回顾一下事件订阅:按钮设备的 InteractedWithEvent.Subscribe 只接受签名为 (Agent:agent):void 的函数——事件发生时,引擎会把触发的 agent 递给你,除此之外一个字节都不多给。于是这种直觉写法必然碰壁:
回顾一下事件订阅(蓝图里就是 Bind Event):按钮设备的 InteractedWithEvent.Subscribe 只接受一种固定形状的函数——签名 (Agent:agent):void,意思是「进来一个 agent(触发事件的那个玩家引脚,类似 Instigator),不往外吐任何值」。事件发生时,引擎就把触发的那个玩家递给你,除此之外一个字节都不多给。于是这种直觉写法必然碰壁:
# 直觉写法:想顺手把按钮编号一起传进去
# ButtonA.InteractedWithEvent.Subscribe(OnPressed, 1) # 编译报错
# ButtonB.InteractedWithEvent.Subscribe(OnPressed, 2) # 编译报错
# Subscribe 只收 (Agent:agent):void 形状的函数,多一个参数都不行
读图翻译:这段(全是注释、故意不能编译)展示的就是那个会碰壁的直觉写法——想在 Subscribe 后面多塞一个按钮编号 1、2。可 Subscribe 的插槽形状是焊死的,多一个参数它就不收。
在有闭包的语言里,你会随手包一层匿名函数把编号捕获进去。但第 15 课说过:当前的 Verse 没有 lambda,也没有闭包捕获。难道要为每个按钮复制粘贴一个回调函数?当然不。
在有闭包的语言里,你会随手包一层没名字的小函数,把编号「捕获」进去。但第 15 课说过:当前的 Verse 既不能就地捏这种小函数(lambda),也不能让函数顺手把周围的变量打包带走。难道要为每个按钮复制粘贴一份回调?当然不。
二、标准解法:数据装进类,回调写成方法
社区指南给出的模式漂亮又通用:定义一个 handler 类,把「额外参数」存成字段,把回调写成它的方法;订阅时现场实例化一个对象,递上它的方法。方法调用时通过 Self 就能摸到字段里的数据——签名依旧是 (Agent:agent):void,Subscribe 挑不出任何毛病。
社区指南给出的模式漂亮又通用:定义一个 handler 类(一个小蓝图类),把「额外参数」存成它的字段,把回调写成它的一个方法;订阅时当场 new 一个这个类的实例,把它的方法递上去。方法跑起来时,通过 Self(它自己)就能摸到字段里存的数据——而对外的签名依旧是 (Agent:agent):void,Subscribe 挑不出任何毛病。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# Handler 类:把「额外参数」存成字段
button_handler := class:
Label:string
# 回调是方法:签名保持 (agent):void,数据从字段里拿
OnInteracted(Agent:agent):void =
Print("按钮 {Label} 被按下!")
button_hub_device := class(creative_device):
@editable
ButtonA:button_device = button_device{}
@editable
ButtonB:button_device = button_device{}
OnBegin<override>()<suspends>:void =
ButtonA.InteractedWithEvent.Subscribe(button_handler{Label := "A"}.OnInteracted)
ButtonB.InteractedWithEvent.Subscribe(button_handler{Label := "B"}.OnInteracted)
读图翻译:button_handler 是个小类,里面存了一个 Label 字段和一个回调方法 OnInteracted(签名正好是 Subscribe 要的 (agent):void)。button_hub_device 是你摆进关卡的设备,两个 @editable 的按钮引用就是暴露到 Details 面板、可以在关卡里指定具体按钮的槽位(蓝图里那个 Instance Editable 小眼睛)。OnBegin(Event BeginPlay)里,给每个按钮各绑一个带不同 Label 的 handler 实例。
逐层拆开看订阅那一行:button_handler{Label := "A"} 用原型语法当场造了一个实例(下一课细讲这个花括号);.OnInteracted 取出这个实例的方法——不带括号,所以没有调用,递过去的是方法本身。按钮 A 被按下时,引擎调用的是 A 那个实例的方法,它读到的 Label 自然是 "A"。十个按钮就造十个实例,回调函数只写一份。
逐层拆开看订阅那一行:button_handler{Label := "A"} 当场造了一个实例,并把它的 Label 字段填成 "A"(这个花括号写法下一课细讲);.OnInteracted 取出这个实例的方法——不带括号,所以没有当场调用,递过去的是方法本身,让引擎将来在事件发生时再去跑它。按钮 A 被按下时,引擎跑的是 A 那个实例的方法,它读到的 Label 自然是 "A"。十个按钮就造十个实例,回调只写一份。
三、它为什么等效于闭包
闭包的本质是「函数 + 一份被捕获的环境」。Handler 模式手工搭出了同一结构:实例的字段就是环境,方法就是函数,对象把两者钉在一起随身携带。每次实例化都是一次「捕获」——想带多少数据就加多少字段,设备引用、倍率、颜色都可以塞进去,比如把 Device := Self 存进字段,回调里就能反过来操作设备本体。
闭包的本质是「函数 + 一份被它记住的环境」。Handler 模式用手搭出了同一个结构:实例的字段就是那份环境,方法就是那个函数,一个对象把两者钉在一起随身携带。每 new 一个实例,就相当于做了一次「记住当前数据」——想带多少数据就加多少字段,设备引用、倍率、颜色都能塞进去,比如把 Device := Self 存进字段,回调里就能反过来操作设备本体。
这个模式的出处是论坛指南 Guide to event subscribing with additional parameters,被社区引用了数千次,Epic 员工(DiG)也在跟帖中确认官方教程同样使用这个模式——换句话说,这不是野路子,而是当前版本的「钦定」写法。后续跟帖里还有人给出用扩展方法进一步压缩样板代码的变体,学完本课另一篇扩展方法拓展的你,不妨自己试着推一遍。
最后一句忠告:每次 Subscribe 都会造一个小对象,这是刻意的、也是廉价的——不要为了「省对象」把数据塞进全局 var 再让回调去猜,那才是 bug 的温床。
最后一句忠告:每次 Subscribe(绑事件)都会造一个小对象,这是刻意的、也是廉价的——别为了「省对象」把数据塞进一个全局变量再让回调去猜,那才是 bug 的温床。
想给按钮回调带一个「奖励倍率」参数,当前 Verse 里的标准做法是?
订阅时写 button_handler{Label := "A"}.OnInteracted,末尾为什么不带括号?
来源与延伸
本文整理自 Epic 开发者论坛:
- Guide to event subscribing with additional parameters (handler functions) ↗ —— Handler 模式的原始指南,含 Epic 员工确认与扩展方法变体。