【语法算法】
上个月组里小 A 找我,说他热更了一个 Mediator,界面行为变得特别邪门:同一个按钮,点一下弹一次面板,再点一下弹两次,第三次直接弹四次。他发誓新代码里绝对没有重复注册,我信他,因为锅不在新代码里,在旧的身上——旧模块创建的回调闭包还挂在通知分发器上,新模块加载完又注册了一遍,一条通知进来,一新一旧两个闭包都响应。这就是热重载的头号坑,我们内部叫幽灵回调。
原理其实一句话:require 查的是 package.loaded 这张哈希表,键置 nil 之后下次 require 会重新 loadfile 没错,但已经发出去的旧模块表引用不会被收回。分发器持有旧闭包,闭包的 upvalue 拽着旧模块的 self,GC 三色标记跑一圈,每个都有人引用,谁也回收不了。你眼里换掉了模块,机器眼里只是多了一份。
flowchart TD
A["驱逐 package.loaded[mod] = nil"] --> B["require 加载新模块表 B"]
B --> C["新闭包注册上分发器"]
D["旧模块表 A"] --> E["旧闭包仍挂在分发器"]
E --> F["upvalue 拽着旧 self"]
D --> F
F --> G["GC 扫描: 人人被引用<br/>旧表安然无恙"]
C --> H["一条通知<br/>新旧闭包各执行一次"]
G --> H
所以解法不是玄学,是把旧的摘干净再挂新的。我们的做法是给模块留一对钩子,OnUnloaded 里把注册进全局的通知、命令、定时器全部反注册,然后才驱逐,require 完再走 Onloaded 重新挂。听着啰嗦,但这是唯一能睡安稳觉的写法:
local function reloadMediator(files)
for _, v in pairs(files) do
if package.loaded[v] then
package.loaded[v].OnUnloaded() -- 旧闭包全部撤下分发器
package.loaded[v] = nil
end
require(v).Onloaded() -- 新模块重新注册
end
end
第二个坑藏得更深。有天热更完技能模块,测试反馈说怪物的技能表现没变化,但玩家技能全好了。查了半天发现技能行为类在旧实例身上——class 造出来的实例,metatable 指向的是加载那一刻的 class 表。你热更了类定义,新 new 的对象用新表,已经活在场景里的几百个怪,元表还连着旧类表,调方法全走旧逻辑。想明白这点后我们的规矩是:行为类热更完,场景里的活动实例一律重建,别心疼,重建比排查便宜。
第三个坑是 upvalue 缓存。很多文件顶部习惯写 local utils = require("util/utils"),这个 local 在重载别的东西时活得好好的,指的还是旧的 utils 表。你热更了 utils 本体,调用方纹丝不动。这种属于"热更了等于没热更",比崩了还难受,因为没人发现。同理还有存进全局表里的模块引用、定时器里捕获的旧函数,一个都跑不掉。
顺带一提定时器。schedule 持有的回调是引擎侧引用,GC 根本看不见它,模块驱逐了定时器还在每帧空转。我们排查过一次"越玩越卡",最后在 OnUnloaded 里挨个 unschedule 才了事。从那以后组内规矩就一条:注册了什么,退场时就必须反注册什么,对不上就不许合码。热重载看着是省时间的技术,实际上是检验你模块边界干不干净的一面镜子——边界不干净的项目,热更完的每一个诡异 bug 都在替你补课。别问怎么知道的。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
【语法算法】 闭包工厂的本质就这一行: 外层函数接收配置,返回一个闭包——闭包捕获配置,后续调用使用捕获的配置——闭包工厂的…
【语法算法】 闭包工厂的本质就这一行: 外层函数接收配置,返回一个闭包——闭包捕获配置,后续调用使用捕获的配置——闭包工厂的…
【语法算法】 泛型 for 的四种形态就这两行: 泛型 for 的四种形态覆盖了 Lua 所有的遍历需求——从无序遍历到有序…
【语法算法】 string.find 的起始偏移就这一行: 第三个参数 init 是搜索的起始偏移——从字符串的第 init…
【语法算法】 CPU 时间和墙钟时间的分界就这两行: os.clock 返回 CPU 时间——程序实际占用处理器的秒数——o…
【语法算法】 元方法 __unm 的触发就这一行: 对带 __unm 的表做一元负号操作 -t 时,Lua 调用 __unm…