Defold 消息传递架构:message.post 与解耦设计

系统讲透 Defold 的消息传递架构:msg.post 与 URL 寻址、on_message 回调与分发顺序、父子层级与集合代理(proxy)、事件总线与集中式分发的解耦模式、请求-响应与订阅式通信、消息与直接调用的性能取舍,并给出可复用的消息驱动架构实践。

引言

游戏里最容易被写烂的东西不是渲染,也不是物理,而是「对象之间怎么说话」。新手常把一个全局单例塞满所有状态,player.hp 被 UI、敌人 AI、存档系统同时读写,改一处炸一片。Defold 给的答案很克制:一切跨对象通信都走消息——msg.post 发、on_message 收、URL 寻址。这套机制看起来只是「函数调用的另一种写法」,但它的真正价值在于强制解耦:发送方不需要知道接收方是谁、是否存在、在哪个集合里。

本文把 Defold 消息传递讲成一个可落地的架构:从 msg.post 与 URL 寻址讲起,覆盖 on_message 的回调与分发顺序,接着是父子层级、集合(Collection)与代理(Proxy)中的消息穿透,然后是事件总线(Event Bus)与集中式分发两种解耦模式,再对比消息与直接调用的性能取舍,最后给出请求-响应与一次性订阅的实战模板。

前置:脚本生命周期与 msg 、ECS 与组件解耦 。消息队列的通用理论见 消息队列设计 、Actor 模型详解 。

1. 消息传递的核心:msg.post 与 URL 寻址

Defold 里没有「对象引用」,只有 URL。每个游戏对象(Game Object)、组件、集合都有一个地址(URL),消息就是「寄到这个地址的信」。

URL 结构: [socket:]<path>[#fragment]
  socket    集合代理(Proxy)的 socket 名,跨集合时用
  path      游戏对象 id,"." 表示「自己」
  fragment  组件 id,如 "#sprite"、"#script"

四种常用寻址写法:

写法含义场景
"."当前游戏对象给自己发(延迟一帧执行)
"#sprite"当前对象上的 sprite 组件控制自身组件
"/enemy"同一集合里的 enemy 对象同集合通信
"main:/ui#gui"代理 main 里的 ui 对象跨集合通信

发送与接收的最小闭环:

-- 发送方:给玩家对象发一个「受伤」消息
msg.post("/player", "apply_damage", { amount = 25, source = "trap" })

-- 接收方(player.script):在 on_message 里消费
function on_message(self, message_id, message, sender)
    if message_id == hash("apply_damage") then
        self.hp = self.hp - message.amount
        -- sender 是发送方的 URL,可用于回信
        msg.post(sender, "damage_applied", { hp = self.hp })
    end
end

三个必须记住的事实:

1. message_id 是 hash(整数),比较用 == 而非字符串
2. 消息体(message)是 Lua table,按值传递引用,不要塞大数组
3. sender 参数是 URL,请求-响应模式靠它回信

消息体的设计纪律:消息体是「值语义」的事件描述,不是「引用语义」的共享状态:

-- 好:只带事件必需的数据,接收方自行决定怎么用
msg.post("/player", "apply_damage", { amount = 25, source = "trap" })

-- 坏:把整个对象状态塞进去,接收方和发送方再次耦合
msg.post("/ui", "refresh", { player = self.player_obj, world = self.world })
消息体三条纪律:
  ✓ 只放「标量 + 小表」:数字、字符串、hash、短数组
  ✗ 不放 userdata(如 go.get 拿到的 vector 可以,但别放引用)
  ✗ 不放「以后会变」的对象引用——那是共享状态,不是消息

心智:URL 是地址、msg.post 是信封、on_message 是收件箱——发送方只认地址,不认对象;消息体只装「事件」,不装「状态」。

2. on_message 与消息分发机制

消息不是「立刻执行」的——msg.post 把消息推进队列,引擎在当前帧的脚本更新循环结束后统一派发。这带来两个直接后果:

① 同帧内多次 post 同一消息 → 全部按序派发(不是覆盖)
② 给自己 post(".")→ 下一帧才收到,可用来「延迟一帧」解循环依赖

分发顺序:

帧内顺序:
  1. 所有脚本的 update(self, dt) 执行
  2. 收集本帧产生的消息
  3. 按「post 顺序」逐条派发到 on_message

常用内置消息(由引擎发出,你不必手动 post):

message_id何时收到典型用途
init脚本初始化取组件引用、加载数据
final对象销毁前清理、保存状态
enable / disable组件启停暂停逻辑
update每帧同 update 回调
animation_done动画播完状态机回稳态
collision_response物理碰撞碰撞处理
function on_message(self, message_id, message, sender)
    -- 用 if/elseif 链分发;消息多时改用 table 映射
    if message_id == hash("enable") then
        self.active = true
    elseif message_id == hash("disable") then
        self.active = false
    elseif message_id == hash("animation_done") then
        self:on_anim_done(message.animation_id)
    end
end

消息太多时的表驱动分发:

-- 把 handler 存进 table,避免超长 if 链
local handlers = {}

handlers[hash("enable")]  = function(self, msg) self.active = true end
handlers[hash("disable")] = function(self, msg) self.active = false end
handlers[hash("respawn")] = function(self, msg) self:respawn(msg.pos) end

function on_message(self, message_id, message, sender)
    local h = handlers[message_id]
    if h then h(self, message) end
end

心智:消息走队列、帧末派发、按 post 顺序——「给自己 post」是延迟一帧的标准手法,表驱动分发治超长 if 链。

3. 父子层级与集合代理

Defold 的层级是「集合嵌套集合」,消息可以沿层级向上/向下穿透:

main.collection
├── player.go
├── level.collection        ← 子集合
│   ├── enemy.go
│   └── tilemap.go
└── ui.collection(作为 Proxy 加载)

向上:消息冒泡到父级。子对象的 URL 若只写相对路径,找不到时引擎会沿父级查找:

-- 在 enemy.script 里,向「同集合的 player」发消息
msg.post("/player", "spotted")     -- 先找本集合,找不到再向上冒泡

跨集合:必须用 Proxy(代理)。把 ui.collection 以 Proxy 组件加载进主集合,才能用 socket 寻址:

-- 主集合里的 loader.script 加载 UI 代理
msg.post("#ui_proxy", "load")        -- 异步加载 ui.collection

-- 加载完成后,跨集合给 UI 发消息
msg.post("ui:/hud#gui", "set_hp", { hp = 80 })
--         socket ↑   ↑对象  ↑组件

Proxy 的三个关键点:

1. 跨集合通信必须经 Proxy,直接写 "/ui/hud" 是错的
2. load 是异步的:加载完成前 post 会被丢弃,需等 proxy_loaded 消息
3. 卸载用 msg.post("#ui_proxy", "unload"),卸载后地址失效
-- 等代理加载完成再通信
function on_message(self, message_id, message, sender)
    if message_id == hash("proxy_loaded") then
        msg.post(sender, "enable")               -- 启用代理
        msg.post("ui:/hud#gui", "set_hp", { hp = 100 })
    end
end

Proxy 生命周期的完整时序:

load → proxy_loaded → enable → [正常通信] → disable → unload → proxy_unloaded
         ↑ 此后地址才可用         ↑ 期间消息可达
常见坑:
  1. 在 init 里直接 post 跨集合消息 → 代理还没加载,消息被静默丢弃
  2. 卸载代理后继续 post → 地址失效,同样是静默丢弃
  3. 同一集合被多个 Proxy 引用 → 每个实例独立,socket 名不能重

心智:同集合用相对路径、跨集合必须过 Proxy;Proxy 加载是异步的,先收 proxy_loaded 再发消息——「静默丢弃」是消息模型最容易踩的坑。

4. 解耦模式:事件总线与集中分发

直接 post 到具体对象,发送方仍要知道「谁该收」。真正解耦要引入中间层,两种主流模式:

4.1 事件总线(Event Bus)

一个「总线对象」订阅所有事件,其他对象只往总线发,谁关心谁去总线订阅:

-- bus.script:全局事件总线
function init(self)
    self.listeners = {}     -- event_id -> { url1, url2, ... }
end

function on_message(self, message_id, message, sender)
    if message_id == hash("subscribe") then
        local list = self.listeners[message.topic] or {}
        table.insert(list, sender)
        self.listeners[message.topic] = list
    elseif message_id == hash("publish") then
        local list = self.listeners[message.topic]
        if list then
            for _, url in ipairs(list) do
                msg.post(url, message.topic, message.payload)
            end
        end
    end
end
-- 订阅方:只发 subscribe,不知道谁在发
msg.post("/bus", "subscribe", { topic = hash("player_died") })

-- 发布方:只发 publish,不知道谁在收
msg.post("/bus", "publish", { topic = hash("player_died"), payload = { id = 1 } })

4.2 集中式分发(单入口路由)

一个「游戏管理器」持有所有系统引用,接收统一事件后按游戏状态转发:

-- manager.script:集中路由
function on_message(self, message_id, message, sender)
    if message_id == hash("game_event") then
        if message.type == "level_complete" then
            self:on_level_complete(message.level)
        elseif message.type == "player_died" then
            self:on_player_died()
        end
    end
end

两种模式取舍:

维度事件总线集中式分发
解耦度高(收发互不知)中(都认识管理器)
调试难度较难(链路隐式)易(单点断点)
适合系统多、事件杂状态机清晰的中小项目
风险订阅泄漏、循环触发管理器变上帝对象

心智:总线管「谁关心就订阅」,管理器管「按状态转发」——解耦度与可调试性此消彼长,按项目规模选。

5. 消息 vs 直接调用:性能与取舍

消息不是免费的。每次 post 涉及 URL 解析、队列插入、派发查找:

msg.post 的隐含成本:
  1. URL 字符串 → 哈希查找
  2. 消息入队(分配)
  3. 帧末派发时按 URL 定位脚本

性能量级参考(同一帧内大量通信时才有意义):

通信方式相对开销适用频率
直接函数调用(同一脚本内)1x每帧每对象
组件属性 go.set / go.get~2x每帧少量
msg.post 跨对象510x事件驱动、非每帧
跨集合 Proxy 消息更高低频(UI 更新等)

决策规则:

用消息:  跨对象、跨集合、事件通知、需要解耦、生命周期不确定
用直调:  同一脚本内部、每帧热路径(如更新 1000 个粒子的位置)
用 go.set:同对象组件属性同步(位置/缩放/染色)
-- 反例:每帧给 500 个子弹对象各 post 一次「更新位置」
-- 正例:子弹自己在 update 里读共享数据,只在「命中」时 post 一次事件
function update(self, dt)
    self.pos = self.pos + self.vel * dt
    go.set_position(self.pos)                -- 直接设置,不走消息
    -- 只有命中这种「离散事件」才 post
end

心智:连续状态用直调/go.set,离散事件用消息——把消息留给「事件」,别把它当每帧的遥控器。

6. 实战:请求-响应与一次性订阅

请求-响应(Request-Response):发送方需要回执时,用 sender 回信并携带一个「请求 id」防止串台:

-- 请求方
self.req_id = (self.req_id or 0) + 1
msg.post("/inventory", "query_item", { id = "potion", req_id = self.req_id })

function on_message(self, message_id, message, sender)
    if message_id == hash("query_result") then
        if message.req_id == self.req_id then     -- 只认最新请求
            self.potion_count = message.count
        end
    end
end

一次性订阅(One-shot Listener):订阅后收到第一条就取消,避免重复触发:

msg.post("/bus", "subscribe_once", { topic = hash("level_loaded") })

function on_message(self, message_id, message, sender)
    if message_id == hash("level_loaded") then
        -- 处理一次后不再关心
        msg.post("/bus", "unsubscribe", { topic = hash("level_loaded") })
        self:start_gameplay()
    end
end

防循环触发:A 收到消息后 post 给 B,B 又 post 回 A,会形成消息风暴。工程上的三道闸:

1. 事件里带 source,收到自己的事件直接丢弃
2. 加「正在处理」标志位,处理期间不再发布同类事件
3. 总线侧做去重:同一 tick 内相同 topic+payload 只转发一次
-- 丢弃自产事件,防循环
function on_message(self, message_id, message, sender)
    if sender == msg.url() then return end      -- 自己发的不处理
    -- ...
end

超时兜底:请求-响应模式下,接收方可能已经被销毁,请求方必须设超时,否则状态永远卡在「等待中」:

function init(self)
    self.pending = {}     -- req_id -> { deadline = t }
end

function update(self, dt)
    local now = os.time()
    for req_id, p in pairs(self.pending) do
        if now > p.deadline then
            self.pending[req_id] = nil
            self:on_request_timeout(req_id)     -- 走降级分支
        end
    end
end

心智:请求-响应靠 sender + req_id,一次性订阅靠「收到即退订」,防循环靠 source 过滤与去重,可靠性靠超时兜底。

速查表

需求做法
给自己发消息(延迟一帧)msg.post(".", "topic", {...})
控制自身组件msg.post("#sprite", "play_animation", {...})
同集合跨对象msg.post("/enemy", "spotted")
跨集合通信先 Proxy load,再用 socket:/obj#comp
收消息on_message(self, message_id, message, sender)
回信msg.post(sender, "reply", {...})
解耦事件总线(subscribe/publish)或集中式管理器
热路径直调 / go.set,别用消息
防循环if sender == msg.url() then return end
等异步资源监听 proxy_loaded 后再发消息

一句话记忆:Defold 的通信模型是「URL 寻址 + 队列派发 + 回调消费」——同集合用相对路径、跨集合过 Proxy;解耦靠事件总线或集中管理器,热路径回退到直调与 go.set;请求-响应用 sender 回信加 req_id 去串台,防循环靠 source 过滤——把消息留给离散事件,架构自然就松了。

小结

消息传递是 Defold 架构的「骨架」。它用一点性能换来了三样东西:发送方无需知道接收方(可替换、可缺席)、生命周期安全(对象销毁后消息自然丢弃,不会悬空指针)、天然的帧级同步点(帧末派发,避免一帧内半更新状态)。落地时的三条纪律:一是同集合用相对 URL、跨集合必须走 Proxy 并等 proxy_loaded;二是连续状态用 go.set,离散事件才用 msg.post;三是引入总线或管理器作为唯一事件入口,避免对象之间网状直连。做到这三点,你的 Defold 项目就能在规模变大后依然改得动。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化