Lua篇 — Lua热重载

Posted by Xun on Tuesday, March 29, 2022

由于lua的弱语言特性,不需要编译即可运行,因此使用lua开发的项目,如果修改lua代码,不需要等待编译的时间,如果修改完能直接重载,则开发效率会更高。

简介

  • Unity + lua 开发的项目,开发过程中,修改完lua代码,不需要重新编译,重启Unity即可执行新逻辑。随着项目内容逐渐增大,重启Unity的耗时也会日益增长,往往会出现修改一个小参数,就需要等较长的时间才能重新执行,开发效率较低,所以热重载就显得比较重要。

加载方式

  • 加载lua脚本,常用的方式一般有两种:dostring、require(其他的如 dofile、loadstring 等这里暂不分析),即将lua代码加到内存中并执行。这里以lua 5.3.5 版本为例,查看其具体的实现。

dostring

  • xlua中CSharp侧的 DoString 方法为
        // LuaEnv.cs

        public object[] DoString(byte[] chunk, string chunkName = "chunk", LuaTable env = null)
        {
#if THREAD_SAFE || HOTFIX_ENABLE
            lock (luaEnvLock)
            {
#endif
                var _L = L;
                int oldTop = LuaAPI.lua_gettop(_L);
                int errFunc = LuaAPI.load_error_func(_L, errorFuncRef);
                if (LuaAPI.xluaL_loadbuffer(_L, chunk, chunk.Length, chunkName) == 0)
                {
                    if (env != null)
                    {
                        env.push(_L);
                        LuaAPI.lua_setfenv(_L, -2);
                    }

                    if (LuaAPI.lua_pcall(_L, 0, -1, errFunc) == 0)
                    {
                        LuaAPI.lua_remove(_L, errFunc);
                        return translator.popValues(_L, oldTop);
                    }
                    else
                        ThrowExceptionFromError(oldTop);
                }
                else
                    ThrowExceptionFromError(oldTop);

                return null;
#if THREAD_SAFE || HOTFIX_ENABLE
            }
#endif
        }
  • tolua中CSharp侧的 DoString 方法为
        // LuaState.cs

        public void DoString(string chunk, string chunkName = "LuaState.cs")
        {
#if UNITY_EDITOR
            if (!beStart)
            {
                throw new LuaException("you must call Start() first to initialize LuaState");
            }
#endif
            byte[] buffer = Encoding.UTF8.GetBytes(chunk);
            LuaLoadBuffer(buffer, chunkName);
        }

        protected void LuaLoadBuffer(byte[] buffer, string chunkName)
        {
            LuaDLL.tolua_pushtraceback(L);
            int oldTop = LuaGetTop();

            if (LuaLoadBuffer(buffer, buffer.Length, chunkName) == 0)
            {
                if (LuaPCall(0, LuaDLL.LUA_MULTRET, oldTop) == 0)
                {                    
                    LuaSetTop(oldTop - 1);
                    return;
                }
            }

            string err = LuaToString(-1);
            LuaSetTop(oldTop - 1);                        
            throw new LuaException(err, LuaException.GetLastError());
        }

        ...
  • 可以看到,DoString的流程都是一致的。
    • 调用lua侧的 luaL_loadbuffer 方法,加载一段 Lua 代码块。如果没有错误,则把一个编译好的代码块作为一个 Lua 函数压到栈顶。
    • 调用lua侧的 lua_pcall 方法,执行栈顶的函数。

require

  • require 方法,在lua库中默认注册,其实现为
// loadlib.c

static const luaL_Reg ll_funcs[] = {
#if defined(LUA_COMPAT_MODULE)
  {"module", ll_module},
#endif
  {"require", ll_require},
  {NULL, NULL}
};

static int ll_require (lua_State *L) {
  const char *name = luaL_checkstring(L, 1);
  lua_settop(L, 1);  /* LOADED table will be at index 2 */
  lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
  lua_getfield(L, 2, name);  /* LOADED[name] */
  if (lua_toboolean(L, -1))  /* is it there? */
    return 1;  /* package is already loaded */
  /* else must load package */
  lua_pop(L, 1);  /* remove 'getfield' result */
  findloader(L, name);
  lua_pushstring(L, name);  /* pass name as argument to module loader */
  lua_insert(L, -2);  /* name is 1st argument (before search data) */
  lua_call(L, 2, 1);  /* run loader to load module */
  if (!lua_isnil(L, -1))  /* non-nil return? */
    lua_setfield(L, 2, name);  /* LOADED[name] = returned value */
  if (lua_getfield(L, 2, name) == LUA_TNIL) {   /* module set no value? */
    lua_pushboolean(L, 1);  /* use true as result */
    lua_pushvalue(L, -1);  /* extra copy to be returned */
    lua_setfield(L, 2, name);  /* LOADED[name] = true */
  }
  return 1;
}

static void findloader (lua_State *L, const char *name) {
  int i;
  luaL_Buffer msg;  /* to build error message */
  luaL_buffinit(L, &msg);
  /* push 'package.searchers' to index 3 in the stack */
  if (lua_getfield(L, lua_upvalueindex(1), "searchers") != LUA_TTABLE)
    luaL_error(L, "'package.searchers' must be a table");
  /*  iterate over available searchers to find a loader */
  for (i = 1; ; i++) {
    if (lua_rawgeti(L, 3, i) == LUA_TNIL) {  /* no more searchers? */
      lua_pop(L, 1);  /* remove nil */
      luaL_pushresult(&msg);  /* create error message */
      luaL_error(L, "module '%s' not found:%s", name, lua_tostring(L, -1));
    }
    lua_pushstring(L, name);
    lua_call(L, 1, 2);  /* call it */
    if (lua_isfunction(L, -2))  /* did it find a loader? */
      return;  /* module loader found */
    else if (lua_isstring(L, -2)) {  /* searcher returned error message? */
      lua_pop(L, 1);  /* remove extra return */
      luaL_addvalue(&msg);  /* concatenate error message */
    }
    else
      lua_pop(L, 2);  /* remove both returns */
  }
}

...

  • 从上面看出,require 方法的大致流程为
    • 检查第一个参数 name 是否为字符串,第一个参数为模块名。
    • 将 LUA_REGISTRYINDEX 表中的 LUA_LOADED_TABLE 表入栈(即 “_LOADED” 表)。
    • 将 LOADED 表中模块名对应的值入栈。
    • 如果栈顶不为nil、false,表示已经加载过此模块,则返回,不再进行二次加载。
    • 移除栈顶的数据,调用findloader方法加载并执行lua代码
      • 根据 package.searchers 中的 loader 方法,加载对应的lua脚本到栈顶。
      • 如果栈顶为function,表示模块加载成功。
    • 将模块名插入到栈顶方法前,执行栈顶方法,即将模块名作为参数,执行加载的模块代码。
    • 如果有返回值,则设置 LOADED[name] 为对应的返回值。
    • 如果没有返回值,则设置 LOADED[name] 为 true(所以判断是否加载过需要检查 nil 和 false )。因此,一般情况下,执行完返回, LOADED[name] 中会缓存模块对应的 table。

热重载流程

  • 从加载实现可以看到,dostring 每次都会重新加载并执行代码,而 require 会先检查是否已经加载过代码,如果加载过则不会再加载。
  • xlua中默认是采用 dostring 方式来加载lua代码,一般每个UI界面都会绑定一个lua脚本,所以修改lua代码后,只要重新加载UI界面,触发 dostring 方法,即可实现热重载。
  • require 方法,由于会检测是否已经加载过对应 module,所以热重载大致流程为:
    • 获取已加载的 module 。
    • 移除lua缓存的 LOADED[name] 对象。
    • 执行 require 方法,重新加载 module 。
    • 将新的 module 数据更新到旧的 module 中。
    • 将 LOADED[name] 设置为修改后的旧 module。
  • 由于 LUA_LOADED_TABLE 表(即 LOADED)是存在 LUA_REGISTRYINDEX 表中,lua侧不能直接调用,所以需要通过其他方式调用。 loadlib.c 中将 LUA_LOADED_TABLE 表绑定到 package 中,给lua侧调用。
// loadlib.c

LUAMOD_API int luaopen_package (lua_State *L) {
  createclibstable(L);
  luaL_newlib(L, pk_funcs);  /* create 'package' table */
  createsearcherstable(L);
  /* set paths */
  setpath(L, "path", LUA_PATH_VAR, LUA_PATH_DEFAULT);
  setpath(L, "cpath", LUA_CPATH_VAR, LUA_CPATH_DEFAULT);
  /* store config information */
  lua_pushliteral(L, LUA_DIRSEP "\n" LUA_PATH_SEP "\n" LUA_PATH_MARK "\n"
                     LUA_EXEC_DIR "\n" LUA_IGMARK "\n");
  lua_setfield(L, -2, "config");
  /* set field 'loaded' */
  luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
  lua_setfield(L, -2, "loaded");
  /* set field 'preload' */
  luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE);
  lua_setfield(L, -2, "preload");
  lua_pushglobaltable(L);
  lua_pushvalue(L, -2);  /* set 'package' as upvalue for next lib */
  luaL_setfuncs(L, ll_funcs, 1);  /* open lib into global table */
  lua_pop(L, 1);  /* pop global table */
  return 1;  /* return 'package' table */
}
...

  • 所以,lua侧可以直接通过 package.loaded 获取已加载的模块。热重载流程实现大致如下:
function Reload(moduleName)
	  local loadedName = string.gsub(moduleName, '/', '.');
	  local oldModule = package.loaded[loadedName];
	  package.loaded[loadedName] = nil;
    local ok,err = pcall(require, loadedName)
	  if not ok then
        package.loaded[loadedName] = oldModule;
        error('reload error : ' .. moduleName .. '\n' .. tostring(err));
        return;
    end
	  local newModule = package.loaded[loadedName];
	  if oldModule == nil then
        oldModule = newModule;
    else
        local reload_tables = {};
        UpdateTable(oldModule, newModule, reload_tables);
    end

	  package.loaded[loadedName] = oldModule;
    print('reload completed : '.. moduleName);
end
  • 如果 dostring 想要不重新加载实现热重载,流程和 require 的也相似,就需要自己做缓存和更新缓存。
  • 注意,如果lua脚本没有返回值,则 package.loaded 为 true,而对应的 table 需要根据自己的业务进行更新。

热重载修改

  • 热重载的修改内容主要有:
    • table
      • 遍历新加载的 new_table 的所有key,进行修改。
        • 如果 old_table 中没有对应key,则直接将 new_table 的key-value设置到 old_table 中。
        • 如果key对应的值为 table ,则继续递归执行。
        • 如果key对应的值为 function ,则使用 function 规则进行修改。
        • 如果key对应的值为其他,则不处理,即不修改对应变量,保证原数据不受影响。
      • 使用 metatable 规则对元表进行修改。
    -- reload_tables 用来记录已经处理过的 new_table,避免出现循环引用导致崩溃。
    function UpdateTable(old_table, new_table, reload_tables)
        --print('UpdateTable ')
        if type(old_table) ~= 'table' then
            print('UpdateTable Error, old_table is  '.. type(old_table));
            return;
        end
        if type(new_table) ~= 'table' then
            print('UpdateTable Error, new_table is  '.. type(new_table));
            return;
        end
    
        for key,new_value in pairs(new_table) do
            if old_table[key] == nil then
                old_table[key] = new_value;
            else
                local new_type = type(new_value);
                if  new_type == 'table' then
                    if reload_tables[new_value] == nil then
                        reload_tables[new_value] = true;
                        UpdateTable(old_table[key], new_value, reload_tables);
                    end
                elseif new_type == 'function' then
                    UpdateFunc(old_table[key], new_value ,key);
                    old_table[key] = new_value;
                end
            end
        end
    
        UpdateMetatable(old_table, new_table, reload_tables)
    end
    
    • function
      • 使用 debug.getupvalue 方法,获取 old_function 的所有 upvalue 。
      • 使用 debug.getupvalue 方法,获取 new_function 的所有 upvalue 。
      • 检查 new_function 中的所有 upvalue ,如果 old_function 中有对应 key 的 upvalue ,则将 old_function 的 upvalue 值设置为 new_function 的对应 upvalue ,即只更新方法,保持原数据。
    function UpdateFunc(old_func, new_func, key)
        --print('UpdateFunc ' .. key)
        if type(old_func) ~= 'function' then
            print('UpdateFunc Error, old_func :  ' .. key .. '  '.. type(old_func));
            return;
        end
        if type(new_func) ~= 'function' then
            print('UpdateFunc Error, new_func :  ' .. key .. '  '.. type(new_func));
            return;
        end
    
        local old_upvalue = {}
        for i = 1, 99999 do
            local name, value = debug.getupvalue(old_func, i)
            if not name then
                break;
            end
            old_upvalue[name] = value;
        end
    
        for i = 1, 99999 do 
            local name, value = debug.getupvalue(new_func, i)
            if not name then
                break;
            end
            local old_value = old_upvalue[name];
            if old_value ~= nil then
                --print('setupvalue ' .. name)
                debug.setupvalue(new_func, i, old_value);
            end
        end
    
    end
    
    • metatable
      • 使用 debug.getmetatable 方法,获取 old_table 的 old_metatable 。
      • 使用 debug.getmetatable 方法,获取 new_table 的 new_metatable 。
      • 将 old_metatable 作为 old_table , new_metatable 作为 new_table ,使用 table 规则进行修改。因为有些方法是存在元表中,通过元表中 __index 方法来实现调用的,因此修改元表方法保证能使用更新后的方法执行逻辑。
    function UpdateMetatable(old_table, new_table, reload_tables)
        --print('UpdateMetatable ')
        if type(old_table) ~= 'table' then
            print('UpdateMetatable Error, old_table is  '.. type(old_table));
            return;
        end
        if type(new_table) ~= 'table' then
            print('UpdateMetatable Error, new_table is  '.. type(new_table));
            return;
        end
    
        local old_mt = debug.getmetatable(old_table);
        local new_mt = debug.getmetatable(new_table);
        if type(old_mt) == 'table' and type(new_mt) == 'table' then 
            UpdateTable(old_mt, new_mt, reload_tables);
        end
    end
    

lua 5.1 旧版本

  • lua 5.1 旧版本中,经常会使用 module 来管理模块,对于热重载的流程也有一些影响。

module 方法

  • module(“filename”[,package.seeall])
    • 创建一个模块,使用独立的环境 env 。(lua5.1 版本之后废弃
  • 以 lua5.1 为例,module 方法的具体实现为:
// loadlib.c

...

static const luaL_Reg pk_funcs[] = {
  {"loadlib", ll_loadlib},
  {"seeall", ll_seeall},
  {NULL, NULL}
};

static const luaL_Reg ll_funcs[] = {
  {"module", ll_module},
  {"require", ll_require},
  {NULL, NULL}
};

...

LUALIB_API int luaopen_package (lua_State *L) {
  ...

  /* create `package' table */
  luaL_register(L, LUA_LOADLIBNAME, pk_funcs);

  ...

  luaL_register(L, NULL, ll_funcs);  /* open lib into global table */

  ...
  return 1;  /* return 'package' table */
}
  • 可以看到,当初始化调用 luaopen_package 方法时:
    • 将 “seeall” 方法注册到 package 中,对应的方法为 llseeall 。
    • 将 “module” 方法注册到全局表中,对应的方法为 ll_module 。
// loadlib.c

...

static int ll_module (lua_State *L) {
  const char *modname = luaL_checkstring(L, 1);
  int loaded = lua_gettop(L) + 1;  /* index of _LOADED table */
  lua_getfield(L, LUA_REGISTRYINDEX, "_LOADED");
  lua_getfield(L, loaded, modname);  /* get _LOADED[modname] */
  if (!lua_istable(L, -1)) {  /* not found? */
    lua_pop(L, 1);  /* remove previous result */
    /* try global variable (and create one if it does not exist) */
    if (luaL_findtable(L, LUA_GLOBALSINDEX, modname, 1) != NULL)
      return luaL_error(L, "name conflict for module " LUA_QS, modname);
    lua_pushvalue(L, -1);
    lua_setfield(L, loaded, modname);  /* _LOADED[modname] = new table */
  }
  /* check whether table already has a _NAME field */
  lua_getfield(L, -1, "_NAME");
  if (!lua_isnil(L, -1))  /* is table an initialized module? */
    lua_pop(L, 1);
  else {  /* no; initialize it */
    lua_pop(L, 1);
    modinit(L, modname);
  }
  lua_pushvalue(L, -1);
  setfenv(L);
  dooptions(L, loaded - 1);
  return 0;
}

static void dooptions (lua_State *L, int n) {
  int i;
  for (i = 2; i <= n; i++) {
    lua_pushvalue(L, i);  /* get option (a function) */
    lua_pushvalue(L, -2);  /* module */
    lua_call(L, 1, 0);
  }
}

  • module 方法的大致流程为:
    • 检查作为模块名的第一个参数是否为字符串。
    • 从 package.loaded 中获取模块名对应的对象,即 _LOADED[modname] 。
    • 如果 _LOADED[modname] 不是一个 table ,表示没有创建过此模块,则创建对应模块。
      • 在全局表中创建一个新 table ,key 值为 modname。
      • 将 table 存入 package.loaded 中。
    • 初始化模块的 table 。
    • 设置模块 table 的 env 为 modname。
    • 遍历 module 的所有参数,从第二个起参数,依次将其作为 function ,以模块 table 参数执行对应方法。
  • 由于 module 方法设置了新的 env ,所以导致模块无法访问全局表。lua 提供了方便的方法,使用 package.seeall 作为参数传入,即 module(“filename”, package.seeall)。
// loadlib.c

static int ll_seeall (lua_State *L) {
  luaL_checktype(L, 1, LUA_TTABLE);
  if (!lua_getmetatable(L, 1)) {
    lua_createtable(L, 0, 1); /* create new metatable */
    lua_pushvalue(L, -1);
    lua_setmetatable(L, 1);
  }
  lua_pushvalue(L, LUA_GLOBALSINDEX);
  lua_setfield(L, -2, "__index");  /* mt.__index = _G */
  return 0;
}

  • 可以看到,seeall 方法会对传入的 table,创建一个 metatable ,并将 metatable 的 __index 方法设置为全局表(_G)。所以作为 module 方法的第二个参数传入,则可以在模块创建完成后,对模块 table 进行设置,从而实现模块能够访问 _G 中的对象,对于开发者来说比较便利。
  • module 方法虽然对开发者来说很便利,但是也存在有一些问题:
    • 全局环境被污染。由于 module 的创建流程中,会将创建的 table 也存入 _G 表中。
    • 模块的独立性被破坏。由于可以访问 _G 表,所以能够访问所有全局的对象,而且,对于其他模块,由于也存入了 _G 表,所以也能直接通过模块名访问。

module 热重载

  • 从 module 方法的流程,可以看到,require 使用 module 方法的脚本后,在 lua 侧会产生:
    • package.loaded[path] = true 或 table
    • package.loaded[moduleName] = table
    • _G[moduleName] = table
  • 所以热重载的时候,需要将 moduleName 对应的缓存也进行修改,才能保证重新 require 后能重新执行 module 方法更新对应的 table。

总结

  • 热重载一般是针对方法进行修改,保证现有的数据不受影响,所以对 table 中的非方法数据一般不做修改。
  • 热重载修改的内容,也可根据自己项目的需求进行调整,例如:要修改某些常量数据、不修改metatable等。
  • 一般用lua开发的项目,热重载功能都是必备的。从加载到重载,了解其中的过程,会发现功能并不复杂。由于热重载能大大提高开发效率,一般会在项目初期就加入此功能,降低开发难度。