CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

别再全局变量满天飞:996引擎消息通知机制入门

2026-10-03 05:55 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状态,一层穿一层,最后谁都动不得。996 引擎里有一套成熟的解药——消息通知机制,整棵 PureMVC 架构的地基。今天从一根穿墙的线开始,把这套机制讲到你敢拿去重构老代码。

一、先看穿墙的下场

举个真事。有团队做"击杀BOSS全服公告",实现是:在服务端下发死亡消息的处理函数里,直接拿到公告 UI 面板对象,调它的 show 方法。三个月后要做"公告改成走马灯",改 UI 的同时发现死亡处理函数也得动;半年后做"击杀统计",又得再开一次死亡函数。一个死亡函数被穿了一管道,每次改公告都提心吊胆。

问题的根子在于:死亡这个"事实"和公告这个"反应"被焊死在了一起。 事实应该广播出去,谁关心谁自己听着,这才是解耦。996 引擎整套架构就是围着这个思路搭的,术语叫观察者模式,具体载体就是 Facade 的 sendNotification。

先把角色表摆清楚,四个名字别混。Facade 是总前台,所有通知从它手里过;Mediator 是中介者,管一块界面的表现逻辑,比如 BuffManager、各个 UI 面板;Proxy 是数据代理,管一份状态和它的读写;Notification 是信使本体,名字加载荷。四者的关系一句话串起来:Mediator 和 Proxy 都注册在 Facade 名下,Mediator 之间靠 Notification 传话,谁要数据就向 Facade 要 Proxy 的引用。你只要记住"前台、化妆师、账房、信使"这四个身份,后面所有 API 名字都能对号入座。

二、兴趣列表:不登记就不打扰

引擎里每个 Mediator(中介者,管一块表现逻辑)都要回答一个问题:"你对什么消息感兴趣?"看 BuffManager 的回答:

lua
function BuffManager:listNotificationInterests()
    return {
        global.NoticeTable.AddBuffEntity,       -- 上Buff
        global.NoticeTable.ActorBuffPresentUpdate,
        global.NoticeTable.DropItemOutOfView,
        global.NoticeTable.ActorOutOfView,      -- 角色离开视野
        global.NoticeTable.RefreshActorHP,      -- 血量刷新
        global.NoticeTable.RefreshBuffVisible,
        global.NoticeTable.MainPlayerActionBegan
    }
end

这张兴趣列表在 Mediator 注册时交给 Facade。发布方 sendNotification 的时候,Facade 拿消息名逐个比对各 Mediator 的兴趣,登记了的才收到,没登记的连门铃都不响。这就是通知机制的省力精髓:订阅成本是一行字符串,取消订阅是删掉一行,发布方全程无感。

收到之后怎么处理,统一走 handleNotification,消息名逐个比对:

lua
function BuffManager:handleNotification(notification)
    local id = notification:getName()
    local data = notification:getBody()

    if global.NoticeTable.AddBuffEntity == id then
        self:AddBuff(data)                       -- 挂Buff
    elseif global.NoticeTable.RefreshActorHP == id then
        self:OnRefreshActorHP(data)              -- 血变了,看要不要撤Buff
    elseif global.NoticeTable.ActorOutOfView == id then
        self:OnActorOutOfView(data)              -- 人出视野了,Buff表现冻结
    end
end

注意 getName 和 getBody 这对搭档:名字用来分流,body 是随便什么载荷(一张表、一个 ID、一组坐标)。消息名是"什么事",body 是"细节",一发一收全靠这俩对齐。

mermaid
flowchart LR
A[发布者:死亡处理/服务端回包] -->|sendNotification| B[Facade 消息中枢]
B --> C{遍历已注册 Mediator}
C --> D[兴趣列表有这个名?]
D -->|是| E[handleNotification 执行]
D -->|否| F[跳过,零打扰]
E --> G[各系统各干各的]
F --> G

三、NoticeTable:全引擎的事件户口本

消息名字符串不能随手写,全部登记在 config/NotificationTable.lua。这个文件就是全引擎的户口本:

lua
NotificationTable = {
    AddBuffEntity      = "AddBuffEntity",
    RefreshActorHP     = "RefreshActorHP",
    ActorOutOfView     = "ActorOutOfView",
    MainPlayerActionBegan = "MainPlayerActionBegan",
    -- ...几百条
}

看着就是字符串映射,为什么非要过一道手?三个理由。第一,拼错字母直接 nil,加载期就报错,比"运行到那行才发现没人响应"好查一百倍。第二,加新通知时在户口本里一搜,有没有重名冲突一目了然。第三,新人读代码,顺着 NoticeTable 能把整个引擎的"事件地图"扫一遍——这文件就是架构说明书。

附带一个团队协作的好处:户口本天然就是协作边界图。两个策划要的功能都要动死亡链路,以前是两拨人抢一个函数,现在是各自申请新通知名,在 NoticeTable 里一登记,代码合并时冲突点只有几行表项。我们跨服项目十一个人的团队并行开发两个月,NoticeTable 的合并冲突加起来不到三十行,搁在穿墙式写法里,早就天天互相覆盖了。

二开团队必须立规矩:业务代码里禁止出现裸字符串消息名,必须走 NoticeTable 常量。我们查过一次事故,两个模块都发了名叫 "refresh" 的通知,一个是刷好友列表一个是刷聊天框,互相触发互相刷新,玩家界面闪成迪厅。裸字符串就是这种事故的温床。

三点五、通知管告诉,Proxy 管询问

光有通知还不完整,PureMVC 的另一半是 Proxy。数据和状态住在 Proxy 里,Mediator 是化妆师,通知是化妆师之间的传声筒。分个实战例子:你想知道"当前背包里有没有回城卷",这不是事件,是查询,走 global.Facade:retrieveProxy(...) 直接问数据层;你想让背包界面刷新一下,这才是事件,发通知。

判断用哪个就一句话:需要答案的走 Proxy,不需要答案的走通知。 乱用的典型症状是"发个通知问数据",对面 Mediator 收到还得回一条通知,一来一回比函数调用慢十倍还难查。引擎自己的代码就是范本——BuffManager 里取配置永远是 retrieveProxy 现场拿,而"有新 Buff 进来"这种动态事件才走通知。两种通道各司其职,谁也不抢谁的活。

下面这个演示把消息中枢搬到了眼前:点三个按钮发布不同通知,看包裹从发布者飞进 Facade,再按各 Mediator 的兴趣列表分流——感兴趣的发绿光执行 handleNotification,没兴趣的直接忽略。注意看 AddBuffEntity 会点亮 BuffManager 而邮箱面板纹丝不动,这就是"不登记就不打扰":

demo
skill-notice-bus-1003a

四、实战案例:击杀公告的解耦改造

回头把开头那根穿墙线拆掉。改造分三步,工时半天,效果立竿见影。

第一步,在 NoticeTable 登记两条新通知:MonsterKilled(谁死了)和 KillAnnounce(要发公告了)。死亡处理函数只发前者,塞上击杀者、死者、武器三个字段,它不知道也不需要知道世界上有没有公告这回事。

第二步,新建一个 AnnounceMediator,兴趣列表就一行 MonsterKilled。handleNotification 里判断死的是不是 BOSS、击杀者是不是玩家,条件满足再发 KillAnnounce,走马灯面板只订阅这一条。UI 改版只动 AnnounceMediator 和面板,死亡函数一个字不碰。

第三步,顺手把"击杀统计"也挂上:统计 Mediator 同样订阅 MonsterKilled,各记各的账。同一个事实喂了两个模块,死亡函数还是只有一行 sendNotification。

改造前,死亡函数对外的耦合点是 7 处(公告、统计、任务、成就、活动、掉落、日志全都直接调);改造后是 1 处(一条通知)。后来做"跨服远征"要在死亡链路上再挂四个新反应,零改动老代码,全是新 Mediator 自愿来订阅。耦合点从乘法变加法,这就是事件化的全部红利。

四点五、第二个案例:聊天频道的被动刷新改造

聊天模块是通知机制的重灾区,再给一个改法样本。老代码里,聊天框 UI 收到新消息后,直接遍历"当前在线好友"逐个刷新红点、逐个重绘列表。好友两千人的服,一条世界频道消息进来,两千次红点计算,高峰期聊天刷屏就是帧率悬崖。

改造思路是把"谁关心"翻个面。原来 UI 主动去问所有人,改成各模块自己订阅 ChatMessageReceived:好友系统收到后自己算"发消息的人是不是我好友、要不要亮红点",帮派频道模块收到后自己判断要不要闪图标。聊天框只负责显示消息本身,别人的红点它一个都不碰。改造后单条消息的处理耗时从 18 毫秒降到 1.2 毫秒,而且新增"师徒频道"时只是多写一个订阅者,聊天框照旧。

这个案例的通用心法:通知要围绕"事实"发,不要围绕"反应"发。 "有新消息"是事实,"请刷新红点"是反应。发事实,各反应模块自己认领;发反应,你就得知道世界上所有的反应,耦合又回来了。判断标准很简单——消息名里出现"刷新"、"更新"、"重绘"这类动词,基本就是发错了。

五、通知不是免费的:三个性能与秩序的坑

用爽了容易翻车,三个坑提前打好预防针。

坑一:通知风暴。 一帧之内发几百条 RefreshActorHP(比如法师一个 AOE 扫中二十个怪,每条血都要刷新),Facade 逐条分发,每个订阅者逐条处理。HUD 血条管理器要是有全表扫描,就是二十乘二十次。规矩:同帧同类型的通知能合并就合并,引擎自己的做法是很多刷新通知带的是"actorID 列表"而不是单 ID,一次广播批量处理。

坑二:循环通知。 A 收到通知处理后发 B,B 处理完又发 A,消息在两个 Mediator 之间打乒乓球,一帧之内无限递归,客户端直接栈溢出闪退。查这种问题看调用栈会看到同一个 handleNotification 套了几十层。预防靠纪律:处理通知时发新通知要过一道脑子,"这是对事实的广播"还是"这是对广播的回应"?后者多半该改成直接函数调用。

坑三:别把通知当函数用。 有人发明了"发一条通知让对方返回值"的写法——通知是单向广播,没有返回值,等的那方永远等不到。要结果就走 Proxy 查询(retrieveProxy 拿数据),要动作才发通知。一句话分清:通知管告诉,Proxy 管询问。

排查通知问题我惯用两条路。第一条,怀疑"发了没人收":在 sendNotification 入口 print 消息名,再在目标 Mediator 的 handleNotification 入口 print,两头一对,断在哪一截清清楚楚——发布端没打是没发,中枢没转发是没注册,接收端没打是兴趣列表漏登记,三级火箭逐段点火测试。第二条,怀疑"收的次数不对":给 handleNotification 加个计数器跑五分钟,期望收 10 次的收了三千次,要么上游在循环里发,要么出现了坑二的乒乓。两条路全是土办法,但通知系统的 bug 从来不在深水区,全在"以为注册了其实没注册"这种浅滩上。

六、常见疑问

问:通知的接收顺序有保证吗?
没有。Facade 按注册顺序遍历,但你不该依赖这个顺序——两个 Mediator 的处理如果要求先后,说明它们有隐藏依赖,要么合并成一个,要么拆成两条通知串起来。把"顺序不保证"当成设计约束,代码自然干净。

问:跨场景的通知会怎样?
Mediator 注销后就收不到了,切地图时引擎会批量注销场景级 Mediator。所以切图瞬间的"最后一条通知丢失"不是 bug,是生命周期。要跨场景留状态,数据放 Proxy 层,Proxy 是全局活着的,Mediator 只是它的化妆师。

问:怎么查"这条通知到底谁在收"?
两个办法。临时办法:在 sendNotification 的实现里加 print 打消息名,跑一遍功能,控制台一目了然。根治办法:维护一份"通知-订阅者"对照表,用脚本扫所有 listNotificationInterests 自动生成,每版本更新一次贴在团队 wiki 上,新人上手和排错都靠它。

问:一个通知带多大的 body 合适?
带上"处理这条事实所需的全部数据",仅此而已。发 MonsterKilled 塞击杀者、死者、坐标三个字段就够,有模块要更多信息让它去 Proxy 查——body 塞得越肥,订阅方越容易偷懒直接依赖 body 结构,日后加字段就变成牵一发动全身。body 是传单不是档案,这是边界。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 06:06 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:55 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:52 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:51 996 技术组
进阶实战游戏功能

背包一卡卡三年:GUIQuickCell懒加载列表救了你

做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实…

2026-10-03 05:46 996 技术组
进阶实战游戏功能

i2、C32、i8s都是什么鬼?996引擎网络协议类型系统速查

新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1 、 I4 、 C32…

2026-10-03 05:40 996 技术组