https://github.com/mxyf/lua_hotupdate fork自 asqbtcupid/lua_hotupdate: my lua hotudpate(hot swap) implement 原作者 asqbtcupid
一、核心流程概述
单文件刷新模式,逐个刷新。整体流程:
- 读取更新列表 → 确定哪些文件需要热更
- BuildNewCode → 用
loadstring将新文件代码转为 Lua 函数,在沙箱环境中执行,获得新的返回对象(不清除package.loaded) - ReplaceOld → 递归对比新旧对象,更新函数体、上值、元表,并记录所有变更的函数对
- Travel_G → 遍历整个虚拟机(
_G、注册表、自定义全局),将所有引用旧函数的地方替换为新函数
Update()
├─ AddFileFromHUList() -- 读取更新文件列表
└─ for each file: HotUpdateCode(LuaPath, SysPath)
├─ check_is_data() -- [新增] 数据模块走单独路径
├─ BuildNewCode() -- 沙箱执行,得到 NewObject
├─ ReplaceOld() -- 递归替换函数
│ ├─ UpdateAllFunction() -- 表级递归
│ │ └─ UpdateOneFunction() -- 单函数替换
│ │ └─ UpdateUpvalue() -- 上值迁移
│ └─ UpdateOneFunction() -- 函数级直接替换
├─ 处理 RequireMap 中的子模块
├─ 同步 FakeENV → ENV
└─ Travel_G() -- 全虚拟机扫描替换
二、沙箱环境详解(InitFakeTable)
热更时不会清除 package.loaded,而是用 loadstring 加载新代码并在沙箱中执行。沙箱的目的是:让新代码”空跑”一遍,只提取函数定义和返回对象,不产生副作用。
沙箱拦截的内容
| 全局名 | 沙箱行为 | 原因 |
|---|---|---|
pairs / ipairs | 返回空迭代器 | 阻止遍历(沙箱中的表都是假的) |
next | 返回空函数 | 同上 |
setmetatable | 记录到 MetaMap,不真正设置 | 收集元表信息供后续对比 |
getmetatable | 返回 setmetatable({}, t) | 配合假 setmetatable |
| 其他全局变量 | 返回 FakeTable | 通过 __index 自动生成 |
FakeTable 的所有元方法(__call, __add, __concat 等)都返回新的 FakeTable,保证任何操作都不会报错。
原版 vs 修改版的关键差异
1. require:假 → 真
原版(his):沙箱内定义了假 require,返回 FakeTable 并记录到 RequireMap:
local function require(LuaPath)
if not HU.RequireMap[LuaPath] then
HU.RequireMap[LuaPath] = FakeTable
end
return HU.RequireMap[LuaPath]
end
修改版:移除假 require,meta.__index 中直接返回真正的 require:
elseif k == "require" then
return require -- 真实的 require
原因:假 require 导致新增函数无法正常工作。例如:
-- module.lua 热更后新增了一个函数
local Utils = require("utils") -- 假 require → FakeTable
function M.newFunc() -- 新增函数
return Utils.helper() -- Utils 是 upvalue,指向 FakeTable
end
-- Utils.helper() 实际上调用 FakeTable,返回空,无法生效
使用真 require 后,Utils 是真实模块,helper 可以正常调用。
2. MeGlobal:新增自定义全局变量表
修改版新增了 HU.MeGlobal 表,在 meta.__index 中优先查找:
elseif HU.MeGlobal[k] then
return HU.MeGlobal[k]
原因:某些全局变量(如 Class 类框架)在沙箱执行阶段就需要返回真实值,否则会导致结构对比出问题:
-- module.lua
local M = Class("MyModule") -- 没有 MeGlobal 时,Class 返回 FakeTable
function M:OnInit() ... end
return M
-- M 是 FakeTable,后续 ReplaceOld 比较 OldObject 和 FakeTable 时
-- 由于结构不匹配,对比可能出问题
注意:对于直接在函数体内通过 _ENV/_G 访问的全局变量(不通过顶层 local 捕获),setfenv 切回 _G 后就能正常工作,不需要 MeGlobal。MeGlobal 专门解决顶层 local 捕获全局变量作为 upvalue 的问题。
三、BuildNewCode 详解
读取新代码文本 → 与旧代码比对 → loadstring → setfenv(沙箱) → xpcall 执行 → 返回 NewObject
修改版新增内容
- 黑名单检查:
HU.BlackList[LuaPath]命中则跳过 - loadstring 带 chunkname:
loadstring(chunk, LuaPath:gsub("%.", "/")),原版不传 chunkname,调试信息不友好 - 成功时通知:
HU.NotifyFunc(LuaPath..'脚本更新!')
四、ReplaceOld → UpdateAllFunction → UpdateOneFunction
UpdateAllFunction(表级递归)
遍历 NewTable 的所有 key-value:
- value 是函数且 OldTable 有同名函数 →
UpdateOneFunction - value 是表且 OldTable 有同名表 → 递归
UpdateAllFunction - OldTable 没有该 key 且 value 是函数 → 新增函数,直接
setfenv后写入 OldTable
遍历完成后处理元表:
原版:
local NewMeta = HU.MetaMap[NewTable] -- 只从 MetaMap 取(假 setmetatable 记录的)
修改版:
local NewMeta = HU.MetaMap[NewTable]
if true then -- 兜底:一些 require 之后返回的内容有元表
NewMeta = debug.getmetatable(NewTable)
end
原因:require 改为真实后,setmetatable 走的是真正的 setmetatable(不是沙箱假的),不会记录到 MetaMap。debug.getmetatable 作为兜底,不管元表怎么设的都能取到。
UpdateOneFunction(单函数替换)
核心步骤:
- 检查 Protection(保护列表中的函数不替换)
- 签名去重(
tostring(Old)..tostring(New)防止循环) setfenv(NewObject, getfenv(OldObject))— 将新函数的环境设为旧函数的环境UpdateUpvalue— 迁移旧函数的上值到新函数- 记录到
ChangedFuncList,供 Travel_G 使用
修改版新增 ChangedFuncListFastMap:
if not HU.ChangedFuncListFastMap[OldObject] then
HU.ChangedFuncListFastMap[OldObject] = {}
end
table.insert(HU.ChangedFuncListFastMap[OldObject], #HU.ChangedFuncList)
用旧函数作为 key 建立索引,Travel_G 查找时从 O(n) 降为 O(1)。
UpdateUpvalue(上值迁移)
遍历新函数的每个 upvalue:
- 旧函数有同名 upvalue:
- 类型不同 → 用旧值覆盖(保持旧数据)
- 都是函数 → 递归
UpdateOneFunction - 都是表 → 递归
UpdateAllFunction,然后用旧表覆盖 - 其他 → 用旧值覆盖
- 旧函数没有该 upvalue(新增的) →
ResetENV重置环境
ResetENV
修改版新增了 debug.getinfo 检查:
local info = debug.getinfo(object, "S")
if info.what == 'Lua' then
setfenv(object, HU.ENV)
end
只对 Lua 函数调用 setfenv,避免对 C 函数调用导致错误。
五、Travel_G(全虚拟机遍历替换)
在 ReplaceOld 阶段,只是更新了 package.loaded 中的模块对象内部的函数。但整个虚拟机中可能有其他地方也持有旧函数的引用(比如事件回调、定时器、其他模块的 upvalue 等),这些引用还指向旧函数。
Travel_G 的作用:扫描整个 Lua 虚拟机,找到所有持有旧函数引用的地方,替换为新函数。
遍历范围
| 遍历目标 | 说明 |
|---|---|
_G | 全局表及其所有子表 |
debug.getregistry() | 注册表(如 xlua 在此存储数据) |
HU.MeGlobal | [新增] 自定义全局变量表 |
遍历逻辑
对每个遇到的对象:
- 函数:遍历其所有 upvalue,如果 upvalue 值是旧函数 →
debug.setupvalue替换 - 表:
- 递归遍历元表
- 遍历所有 key-value:
- value 是旧函数 → 直接
t[k] = newFunc替换 - key 是旧函数 → 收集到 changeIndexs,遍历完后再替换
- value 是旧函数 → 直接
为什么 key 替换要延迟?
-- 在 pairs 遍历中替换 key 的危险:
for k, v in pairs(t) do
if k == oldFunc then
t[newFunc] = v -- 插入新 key ← 违反 Lua 规范!
t[oldFunc] = nil -- 删除旧 key
-- pairs 的内部迭代器行为未定义:
-- 可能跳过元素、重复访问、或 "invalid key to 'next'" 错误
end
end
Lua 官方文档:遍历中可以修改已有 key 的 value、可以删除 key(置 nil),但不能插入新 key。替换 key = 删旧 + 插新,包含插入操作,所以不安全。
正确做法:先收集索引,遍历完后统一替换:
if changeIndexs ~= nil then
for _, index in ipairs(changeIndexs) do
local funcs = HU.ChangedFuncList[index]
t[funcs[2]] = t[funcs[1]] -- 新key = 旧value
t[funcs[1]] = nil -- 删除旧key
end
end
原版 vs 修改版的性能差异
原版:Travel_G 中每遇到一个函数值,都要线性扫描整个 ChangedFuncList:
for _, funcs in ipairs(HU.ChangedFuncList) do
if value == funcs[1] then ...
复杂度:O(引用数 × 变更函数数)
修改版:使用 ChangedFuncListFastMap(以旧函数为 key 的哈希表):
if HU.ChangedFuncListFastMap[value] then
for _, index in ipairs(HU.ChangedFuncListFastMap[value]) do ...
复杂度:O(引用数),查找为 O(1)
修改版还将 pairs 改为 rawpairs(return next, t, nil),避免触发自定义的 __pairs 元方法,确保遍历行为可预测。
六、数据模块热更(修改版新增)
修改版新增了 check_is_data 和 HotUpdateData,对纯数据模块(不含函数逻辑)走单独的热更路径:
function HU.check_is_data(str)
if string.find(str, "^data%.") or string.find(str, "^lua_shared%.") then
return true, false -- 是数据,不需要替换旧引用
end
if HU.SpecialData[str] then
return true, true -- 是数据,需要替换旧引用
end
return false, false
end
HotUpdateData 直接清除 package.loaded 后重新 require。对于 SpecialData 中的模块(如 common.event_const),还会清空旧表并将新数据写入旧表,保证所有持有旧表引用的地方自动拿到新数据。
七、文件映射方式差异
原版 InitFileMap:Init 时用 io.popen("dir /S/B") 扫描所有根目录,一次性建立完整的文件名 → 路径映射。
修改版 TryAddToFileMap:不在 Init 时扫描目录,而是按需添加。Init 只接收单个 rootPath,当更新列表中出现未知文件时再调用 TryAddToFileMap 补充映射。
修改版还新增了 remove_prefix 函数,去除 lua 路径的前缀(you., lua., file., dir.),适配不同项目的路径约定。
八、完整改动对照表
| 功能点 | 原版 (his) | 修改版 |
|---|---|---|
| 文件扫描 | InitFileMap + io.popen("dir") 批量扫描 | TryAddToFileMap 按需添加 |
| require | 假 require,返回 FakeTable | 真 require |
| 自定义全局 | 无 | MeGlobal 表 |
| 黑名单 | 无 | BlackList 阻止特定模块更新 |
| 数据模块 | 无区分 | check_is_data + HotUpdateData 单独处理 |
| 元表获取 | 仅 MetaMap | debug.getmetatable 兜底 |
| Travel_G 查找 | O(n) 线性扫描 | O(1) ChangedFuncListFastMap |
| Travel_G 遍历 | pairs | rawpairs(绕过元方法) |
| Travel_G 范围 | _G + 注册表 | _G + 注册表 + MeGlobal |
| ResetENV | 直接 setfenv | 先检查 info.what == 'Lua' |
| loadstring | 无 chunkname | 传入 LuaPath 作为 chunkname |
| 路径前缀 | 无处理 | remove_prefix 去除前缀 |
_ALL_ 全量更新 | 支持 | 移除(AddFileFromHUList 简化) |