【语法算法】
线上事故引入:早上线上又出了个事故——活动入口的按钮点了没反应,排查发现两个模块先后给同一个按钮绑回调,后绑的把先绑的覆盖了,先绑模块的功能静默失联。信号槽的思路是松绑——信号只管广播,谁关心谁挂槽,一个信号可以连多个槽互不覆盖。这篇把信号槽整套写法拆开:连接表、编号管理、触发广播、断开卸载,一段一段照抄能跑。
一、效果演示
演示场中央一个信号源,四周挂着槽位。点「连信号」往连接表挂一个监听者,点「发信号!」:信号波纹从源点扩散,每个已连接的槽依次亮起并在自己的面板上记一笔。点「断信号」拔掉一个槽,再发信号时它不再响应。
flowchart TD
A[信号触发] --> B[遍历连接表]
B --> C{槽还连着吗}
C -- 否 --> D[跳过]
C -- 是 --> E[调用回调]
E --> F{回调报错吗}
F -- 是 --> G[隔离错误继续]
F -- 否 --> H[继续下一个]
G --> I{遍历完了吗}
D --> I
H --> I
I -- 否 --> B
I -- 是 --> J[广播结束]
fx-signslot
二、底层原理
信号槽的机关是一张连接表——信号源只维护谁订了自己,触发时挨个调用,此外一概不知。发送方和接收方彻底解耦:发送方不用认识接收方,接收方随时插拔,覆盖事故从根上绝迹。编号管理让断开有据可查——连接时发一个编号,断开按编号摘除,动不到别人家的连接。
工程上必须给回调套隔离——任何一家的回调报错都不能炸掉整场广播,逐个包裹、出错记痕迹继续走下一个。容量上限也要设:连接表无限膨胀是内存事故的温床,满了直接拒绝并报警。热重载环境里断开尤其重要——卸载入口把自家连接全摘干净,重载才不会双份响应。
这套机制的代码量不到八十行,难的不是广播,而是把连接的生命周期管住——挂上去容易,记得摘下来才是本事。
广播的次序也是约定的一部分——默认按连接的先后依次调用,先挂的槽先响应。如果槽之间有依赖,比如要先刷新数据再刷新进度条,靠默认次序就够;依赖再复杂就该拆成多个信号串联,而不是让槽之间互相等待。信号里携带的数据保持只读——槽拿到数据只许看不许改,改了就污染后面槽收到的内容,查起来像幽灵。
三、核心代码:完整模块(上·骨架)
-- @file SignalSlot.lua
-- 信号槽 —— 信号发出 槽自动响应
local SignalSlot = {}
local CONST = {
MAX_SLOTS = 4,
AUTOINC_BASE = 1198400,
}
local _slots = {}
local _autoInc = 0
local function ShowTip(msg)
if msg and msg ~= "" then SL:ShowSystemTips(msg) end
end
local function GenID()
_autoInc = _autoInc + 1
return CONST.AUTOINC_BASE + _autoInc
end
function SignalSlot.Connect(callback)
if #_slots >= CONST.MAX_SLOTS then return nil end
local id = GenID()
_slots[id] = callback
return id
end
function SignalSlot.Disconnect(id)
_slots[id] = nil
end
function SignalSlot.Emit(data)
for _, cb in pairs(_slots) do
local ok, err = pcall(cb, data)
if not ok then ShowTip("槽报错已隔离: " .. tostring(err)) end
end
end
四、核心代码:完整模块(下·推进与卸载)
SL:ScheduleOnce(function()
local id1 = SignalSlot.Connect(function(data)
ShowTip("一号槽收到: " .. tostring(data))
end)
SignalSlot.Connect(function(data)
ShowTip("二号槽收到: " .. tostring(data))
end)
SL:BindDebugButton("发信号", function()
SignalSlot.Emit("开饭了")
end)
SL:BindDebugButton("断开一号", function()
SignalSlot.Disconnect(id1)
end)
ShowTip("技能已加载: 信号槽")
end, 1.0)
function SignalSlot.Unload()
_slots = {}
end
return SignalSlot
五、机制问答
问:一个信号能连几个槽?
答:看容量上限——演示里限四个,满了拒绝新连接并报警,防止连接表无限膨胀。
问:槽的回调报错会怎么样?
答:单槽隔离——逐个包裹回调,一家的错误记痕迹之后继续广播下一家,全场不炸。
问:断开连接靠什么定位?
答:连接时发的编号——按编号摘除,动不到别人家的连接。
问:和事件总线怎么分工?
答:总线管全局广播,信号槽管点对点的定向通知——跨系统的用总线,模块内部的用信号槽。
问:热重载的时候要做什么?
答:卸载入口把自家连接全部断开——否则重载后旧新两份同挂,一次触发响应两次。
六、调参与实战怎么用
参数层面可调的是容量上限和回调次序——次序默认按连接先后,需要优先级就给连接加权重字段再排序。实战里这类机制最适合按钮、开关、进度条这类多点订阅的界面事件,以及成就系统这种谁都关心却不该互相认识的场景。常见坑两个:连接成功后编号没保存,想断开时无从下手;广播途中又连接新槽,遍历到被改动的表引发跳变——先快照再遍历可免。
补充一个设计层面的思考——这类机制的核心难点不在于代码怎么写,而在于"什么时候触发、触发后做什么、做完了怎么收场"这三个问题的答案。答案清楚了,代码只是把答案翻译成脚本的过程;答案含糊,写出来的东西改来改去都在原地打转。动手前先把这三个问题各用一句话答出来,答不出的那个就是设计缺口,先补设计再动手。
补充一个维护层面的思考——所有的状态变量都收在模块的闭包里,不摆到全局表。全局表的变量在热重载时会残留旧值,闭包的变量每次加载都是新的,这层隔离是热重载环境里防事故的第一道墙。卸载入口要把定时器、连接、标记一揽子清干净,清不干净的下场就是旧事故原样重演。这篇的模块照抄能跑,改的就是参数表。
补充一个工程层面的思考——模块的可测试性和耦合度成反比。耦合越低越能单独验证,越高越要整套环境才能跑起来。降耦合的通用手法是依赖注入:框架接口通过参数传进来,而不是在模块里直接调死,验证的时候传一组假接口就能脱离框架独立运行。平时多花一分钟做注入,排查的时候省下的是一整晚。
写完留一句给做界面系的同学:信号只管喊一嗓子,听不听是槽的事——解耦做足,按钮才不会再吞谁的回调。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
接手二开的兄弟几乎都撞过同一堵墙:玩家嫌走路"一格一格像机器人",老板拍板要"丝滑移动",改服务端速度?不对。改客户端动画帧…
【游戏功能】 先看一个熟场面:法师的火球特效挂在怪的脚后跟上,明明技能说明写着"焚烧其面",火星子却在地上打滚。群里有位策划…
【游戏功能】 上周有个开服的老板在群里发了一段录屏:他的战士明明刀刀都砍中了,观众却一片"这刀挥空了吧"。他原话是"判定日志…
【游戏功能】 先说结论放在开头:怪的皮一会儿绿一会儿红,不是美术换了几套贴图,是染色系统在怪身上盖了一层"色镜"。开服群里有…
前两天群里有个架设的朋友问了个需求:中毒的人要变绿、每两秒掉一次血、毒还能叠五层,问是不是要在服务端把整套状态机重写。我说你…
【游戏功能】 先抛一个坑:五口钟一模一样大,为什么一口比一口疼?先后次序动不得吗?倒过来敲会怎样?这篇把编钟纹整套写法拆开:…