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

Lua热重载踩坑:旧代码阴魂不散的三个现场

2026-09-28 01:26 作者:996 技术组 996引擎Lua教程传奇脚本高级技巧语法算法996引擎

【语法算法】

上个月组里小 A 找我,说他热更了一个 Mediator,界面行为变得特别邪门:同一个按钮,点一下弹一次面板,再点一下弹两次,第三次直接弹四次。他发誓新代码里绝对没有重复注册,我信他,因为锅不在新代码里,在旧的身上——旧模块创建的回调闭包还挂在通知分发器上,新模块加载完又注册了一遍,一条通知进来,一新一旧两个闭包都响应。这就是热重载的头号坑,我们内部叫幽灵回调。

原理其实一句话:require 查的是 package.loaded 这张哈希表,键置 nil 之后下次 require 会重新 loadfile 没错,但已经发出去的旧模块表引用不会被收回。分发器持有旧闭包,闭包的 upvalue 拽着旧模块的 self,GC 三色标记跑一圈,每个都有人引用,谁也回收不了。你眼里换掉了模块,机器眼里只是多了一份。

mermaid
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 重新挂。听着啰嗦,但这是唯一能睡安稳觉的写法:

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

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

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

LATEST ARTICLES

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

进阶实战游戏功能

Lua+string.len:字符串长度的字节本质

【语法算法】 闭包工厂的本质就这一行: 外层函数接收配置,返回一个闭包——闭包捕获配置,后续调用使用捕获的配置——闭包工厂的…

2026-09-29 16:47 996 技术组
进阶实战游戏功能

Lua+闭包工厂:函数返回函数的工厂本质

【语法算法】 闭包工厂的本质就这一行: 外层函数接收配置,返回一个闭包——闭包捕获配置,后续调用使用捕获的配置——闭包工厂的…

2026-09-29 16:47 996 技术组
进阶实战游戏功能

Lua+泛型for的四种形态:从简单到完整的进化本质

【语法算法】 泛型 for 的四种形态就这两行: 泛型 for 的四种形态覆盖了 Lua 所有的遍历需求——从无序遍历到有序…

2026-09-29 16:46 996 技术组
进阶实战游戏功能

Lua+string.find的起始偏移:模式搜索的范围本质

【语法算法】 string.find 的起始偏移就这一行: 第三个参数 init 是搜索的起始偏移——从字符串的第 init…

2026-09-29 16:46 996 技术组
进阶实战游戏功能

Lua+CPU时间与墙钟时间:两种计时的分界本质

【语法算法】 CPU 时间和墙钟时间的分界就这两行: os.clock 返回 CPU 时间——程序实际占用处理器的秒数——o…

2026-09-29 16:46 996 技术组
进阶实战游戏功能

Lua+元方法__unm:一元负号的自定义本质

【语法算法】 元方法 __unm 的触发就这一行: 对带 __unm 的表做一元负号操作 -t 时,Lua 调用 __unm…

2026-09-29 16:46 996 技术组