由于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 规则对元表进行修改。
- 遍历新加载的 new_table 的所有key,进行修改。
-- 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
- table
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开发的项目,热重载功能都是必备的。从加载到重载,了解其中的过程,会发现功能并不复杂。由于热重载能大大提高开发效率,一般会在项目初期就加入此功能,降低开发难度。