首先,什么是热更新?
于是热更新机制应运而生。其实现方式有两种:
(1)简单版但是有缺陷
package.load(“modelname”) = nil
-- 修改modelname.lua的数据
require(“modelname”)
(2)复杂版但是很有用
function reload_module(module_name)
local old_module = package.loaded[module_name] or {}
package.loaded[module_name] = nil
require (module_name)
local new_module = package.loaded[module_name]
for k, v in pairs(new_module) do
old_module[k] = v
end
package.loaded[module_name] = old_module
return old_module
end
Lua 5.2/5.3 hotfix. Hot update functions and keep old data.
https://github.com/jinq0123/hotfix
例如 test.lua:
local M = {}
local a = "old"
function M.get_a() return a end
return M
local M = {}
local a = "new"
function M.get_a() return a .. "_x" end
return M
local hotfix = require("hotfix")
local test = hotfix.hotfix_module("test")
test.get_a() -- "old_x"
数据 a 作为函数的upvalue得到了保留,但是函数得到了更新。
可查看 test/main.lua 中的测试用例。
运行测试:
E:\Git\Lua\hotfix\test>d:\Tools\lua\lua53.exe
Lua 5.3.2 Copyright © 1994-2015 Lua.org, PUC-Rio
require("main").run()
Test OK!
任何一款手游上线之后,我们都需要进行游戏Bug的修复或者是在遇到节日时发布一些活动,这些都需要进行游戏的更新,而且通常都会涉及到代码的更新。
热更新是指用户直接重启客户端就能实现的客户端资源代码更新操作。游戏热更新会减少游戏中的打包次数,提升程序调试效率,游戏运营时候减少大版本更新次数,可以有效防止用户流失。
lua语言在热更新中会被广泛使用,我们这里采取的是ulua,ulua是unity+lua+cstolua组成的,开发者已经为我们封装成SimpleFramework_UGUI和SimpleFramework_NGUI框架,让开发者在使用的时候更加快捷方便。
1.Lua语言
再热更新功能开发过程中,我们需要用到一款新的语言:Lua语言。
Lua和C#对比:C#是编译型语言,Lua是解析型语言
Lua语言不可以单独完成一个项目的开发,Lua语言出现的目的是“嵌入式”,为其他语言开发出来的项目进行功能的扩展和补丁的更新。
2.Lua语言与C#语言交互
3.AssetBundle
4.ULua和XLua热更新框架
整理一下热更新的思路主要有两点:
下面以一个demo为例,这也是抽取 snax 模块中热更新部分:
./main.lua 调用 test.lua,做为运行文件,显示最终运行效果
./test.lua 一个简单模块文件,用于提供热更新的来源
./test_hot.lua 用于更新替换 test 模块中的某些函数,更新文件
./hotfix.lua 实现热更新机制
通过这幅关系图,可以了解到,test 模块和 test_hot 之间的关系,test_hot 负责更新 test 模块中的某些函数,但更新后的这些函数依然属于 test 模块中的一部分,并没有脱离 test 模块的掌控,而独立出来。
现在我们看看 test.lua 包含了哪些内容,分别有 一个局部变量 index,两个函数 print_index,show ,函数体分别是圆圈1和2,两个函数都引用到了这个局部变量 index。
假设当前,我们想更新替换掉 print_index 函数,让其 index 加1 操作,并打印 index 值,那么我们可以在 test_hot.lua 文件中这么写,见下图黄色框部分:
我们希望在 print_index 更新后, index 加 1 后,show 函数获取到的 index 值是 1,即把更新函数也看作是 test.lua 模块中的一部分。而不应该是 index 加 1 后,show 函数获取到的还是原值 0。
假设我们希望更新 print_index 后,再一次更新,把 index 值直接设置为 100,那么它又应该是这样子的,见下图最左侧黄色部分:
Lua 语言是在一个名为 _ENV 的预定义上值(一个外部的局部变量,upvalue)存在的情况下编译所有的代码段的。因此,所有的变量要么绑定到一个名称的局部变量,要么是 _ENV 中的一个字段,而 _ENV 本身是一个局部变量。
例如:
local z = 10
x = 0
y = 1
x = y + z
等价于
local z = 10
_ENV.x = 0
_ENV.y = 1
_ENV.x = _ENV.y + z
-- xxx.lua 文件
local _ENV = the global environment (全局环境)
return function(...)
local z = 10
_ENV.x = 0
_ENV.y = 1
_ENV.x = _ENV.y +z
end
当一个局部变量被内层的函数中使用的时候, 它被内层函数称作上值,或是外部局部变量。引用 Lua 5.3 参考手册
例如:
local x = 10
function hello(a, b)
local c = a + b + x
print(c)
end
那么在这段代码中,hello 函数的上值有 变量 x,_ENV,而我们刚刚讲到,print 没有经过声明,就可以直接使用,那么它肯定是保存于 _ENV 表中,print(c) 等价于 _ENV.print(c),而变量 a、b、c 都是做为 hello 函数的局部变量。
--强制重新载入module
function require_ex( _mname )
log( string.format("require_ex = %s", _mname) )
if package.loaded[_mname] then
log( string.format("require_ex module[%s] reload", _mname))
end
package.loaded[_mname] = nil
require( _mname )
end
local Old = package.loaded[PathFile]
local func, err = loadfile(PathFile)
--先缓存原来的旧内容
local OldCache = {}
for k,v in pairs(Old) do
OldCache[k] = v
Old[k] = nil
end
--使用原来的module作为fenv,可以保证之前的引用可以更新到
setfenv(func, Old)()
-- 查找函数的local变量
function get_local( func, name )
local i=1
local v_name, value
while true do
v_name, value = debug.getlocal(func,i)
if not v_name or v_name == name then
break
end
i = i+1
end
if v_name and v_name == name then
return value
end
return nil
end
-- 修改函数的local变量
function set_local( func, name, value )
local i=1
local v_name
while true do
v_name, _ = debug.getlocal(func,i)
if not v_name or v_name == name then
break
end
i = i+1
end
if not v_name then
return false
end
debug.setlocal(func,i,value)
return true
end
一个函数的局部变量的位置实际上在语法解析阶段就已经能确定下来了,这时候生成的opcode就是通过寄存器的索引来找到局部变量的,了解这一点应该很容易理解上面的代码。修改upvalue的我就不列举了,同样的道理,这时你一定已经看出来了,这种方式可以实现某种程度的数据更新。
明白了debug api操作后,还是对问题的解决毫无头绪,先看看skynet怎么对代码进行热更新的吧,上面的代码是我对skynet进行修改调试时候写的。skynet的热更新并不是对文件原地修改更新,而是先把将要修改的函数打成patch,再把patch inject进正在运行的服务完成更新,skynet里面有一个机制对patch文件中的upvalue与服务中的upvalue做了重新映射,实现原来的upvalue继续有效。可惜它并不打算对所有闭包upvalue做继承的支持,skynet只是把热更新用作不停机的bug修复机制,而不是系统的热升级。通过inject patch的方式热更新可以看出来,云风并不认为热更新所有的闭包是完全可靠的。对热更新的定位我比较赞同,但是我想通过另外方式完成热更新,毕竟管理各种patch的方式显得不够干净。
function UpdateUpvalue(OldFunction, NewFunction, Name, Deepth)
local OldUpvalueMap = {}
local OldExistName = {}
-- 记录旧的upvalue表
for i = 1, math.huge do
local name, value = debug.getupvalue(OldFunction, i)
if not name then break end
OldUpvalueMap[name] = value
OldExistName[name] = true
end
-- 新的upvalue表进行替换
for i = 1, math.huge do
local name, value = debug.getupvalue(NewFunction, i)
if not name then break end
if OldExistName[name] then
local OldValue = OldUpvalueMap[name]
if type(OldValue) ~= type(value) then -- 新的upvalue类型不一致时,用旧的upvalue
debug.setupvalue(NewFunction, i, OldValue)
elseif type(OldValue) == "function" then -- 替换单个函数
UpdateOneFunction(OldValue, value, name, nil, Deepth.." ")
elseif type(OldValue) == "table" then -- 对table里面的函数继续递归替换
UpdateAllFunction(OldValue, value, name, Deepth.." ")
debug.setupvalue(NewFunction, i, OldValue)
else
debug.setupvalue(NewFunction, i, OldValue) -- 其他类型数据有改变,也要用旧的
end
else
ResetENV(value, name, "UpdateUpvalue", Deepth.." ") -- 对新添加的upvalue设置正确的环境表
end
end
end
-- main.lua
local hotfix = require "hotfix"
local test = require "test"
local test_hot = require "test_hot"
print("before hotfix")
for i = 1, 5 do
test.print_index() -- 热更前,调用 print_index,打印 index 的值
end
hotfix.update(test.print_index, test_hot) -- 收集旧函数的上值,用于新函数的引用,这个对应之前说的归纳第2小点
test.print_index = test_hot -- 新函数替换旧的函数,对应之前说的归纳第1小点
print("after hotfix")
for i = 1, 5 do
test.print_index() -- 打印更新后的 index 值
end
test.show() -- show 函数没有被热更,但它获取到的 index 值应该是 最新的,即 index = 5。
-- test.lua
local test = {}
local index = 0
function test.print_index()
print(index)
end
function test.show( )
print("show:", index)
end
return test
-- test_hot.lua
local index -- 这个 index 必须声明,不用赋值,才能够引用到 test 模块中的局部变量 index
return function () -- 返回一个闭包函数,这个就是要更新替换后的原型
index = index + 1
print(index)
end
-- hotfix.lua
local hotfix = {}
local function collect_uv(f, uv)
local i = 1
while true do
local name, value = debug.getupvalue(f, i)
if name == nil then -- 当所有上值收集完时,跳出循环
break
end
if not uv[name] then
uv[name] = { func = f, index = i } -- 这里就会收集到旧函数 print_index 所有的上值,包括变量 index
if type(value) == "function" then
collect_uv(value, uv)
end
end
i = i + 1
end
end
local function update_func(f, uv)
local i = 1
while true do
local name, value = debug.getupvalue(f, i)
if name == nil then -- 当所有上值收集完时,跳出循环
break
end
-- value 值为空,并且这个 name 在 旧的函数中存在
if not value and uv[name] then
local desc = uv[name]
-- 将新函数 f 的第 i 个上值引用旧模块 func 的第 index 个上值
debug.upvaluejoin(f, i, desc.func, desc.index)
end
-- 只对 function 类型进行递归更新,对基本数据类型(number、boolean、string) 不管
if type(value) == "function" then
update_func(value, uv)
end
i = i + 1
end
end
function hotfix.update(old, new)
local uv = {}
collect_uv(old, uv)
update_func(new, uv)
end
return hotfix
debug.getupvalue (f, up)
此函数返回函数 f 的第 up 个上值的名字和值。 如果该函数没有那个上值,返回 nil 。
debug.upvaluejoin (f1, n1, f2, n2)
让 Lua 闭包 f1 的第 n1 个上值 引用 Lua 闭包 f2 的第 n2 个上值。
[root@instance test]# lua main.lua
before hotfix
0
0
0
0
0
after hotfix
1
2
3
4
5
-------------
show: 5
热更新,通俗点说就是补丁,玩家那边知道重启客户端就可以更新到了的,不用卸载重新安装app,相对于单机游戏,这也是网络游戏用得比较多的一个东西吧。
luaFileList.json文件内容一般是lua文件的键值对,key为lua文件路径+文件名,value为MD5值:
lua中的require会阻止多次加载相同的模块。所以当需要更新系统的时候,要卸载掉响应的模块。(把package.loaded里对应模块名下设置为nil,以保证下次require重新加载)并把全局表中的对应的模块表置 nil 。同时把数据记录在专用的全局表下,并用 local 去引用它。初始化这些数据的时候,首先应该检查他们是否被初始化过了。这样来保证数据不被更新过程重置。
function reloadUp(module_name)
package.loaded[modulename] = nil
require(modulename)
end
function reloadUp(module_name)
local old_module = _G[module_name]
package.loaded[module_name] = nil
require (module_name)
local new_module = _G[module_name]
for k, v in pairs(new_module) do
old_module[k] = v
end
package.loaded[module_name] = old_module
end