Lua 热更新系统笔记

https://github.com/mxyf/lua_hotupdate fork自 asqbtcupid/lua_hotupdate: my lua hotudpate(hot swap) implement 原作者 asqbtcupid

一、核心流程概述

单文件刷新模式,逐个刷新。整体流程:

  1. 读取更新列表 → 确定哪些文件需要热更
  2. BuildNewCode → 用 loadstring 将新文件代码转为 Lua 函数,在沙箱环境中执行,获得新的返回对象(不清除 package.loaded
  3. ReplaceOld → 递归对比新旧对象,更新函数体、上值、元表,并记录所有变更的函数对
  4. 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

修改版:移除假 requiremeta.__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

修改版新增内容

  1. 黑名单检查HU.BlackList[LuaPath] 命中则跳过
  2. loadstring 带 chunknameloadstring(chunk, LuaPath:gsub("%.", "/")),原版不传 chunkname,调试信息不友好
  3. 成功时通知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(不是沙箱假的),不会记录到 MetaMapdebug.getmetatable 作为兜底,不管元表怎么设的都能取到。

UpdateOneFunction(单函数替换)

核心步骤:

  1. 检查 Protection(保护列表中的函数不替换)
  2. 签名去重(tostring(Old)..tostring(New) 防止循环)
  3. setfenv(NewObject, getfenv(OldObject)) — 将新函数的环境设为旧函数的环境
  4. UpdateUpvalue — 迁移旧函数的上值到新函数
  5. 记录到 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,遍历完后再替换

为什么 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 改为 rawpairsreturn next, t, nil),避免触发自定义的 __pairs 元方法,确保遍历行为可预测。


六、数据模块热更(修改版新增)

修改版新增了 check_is_dataHotUpdateData,对纯数据模块(不含函数逻辑)走单独的热更路径:

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 单独处理
元表获取MetaMapdebug.getmetatable 兜底
Travel_G 查找O(n) 线性扫描O(1) ChangedFuncListFastMap
Travel_G 遍历pairsrawpairs(绕过元方法)
Travel_G 范围_G + 注册表_G + 注册表 + MeGlobal
ResetENV直接 setfenv先检查 info.what == 'Lua'
loadstring无 chunkname传入 LuaPath 作为 chunkname
路径前缀无处理remove_prefix 去除前缀
_ALL_ 全量更新支持移除(AddFileFromHUList 简化)

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

大纲

Share the Post:
滚动至顶部