文章目录
一、lua基础之热更新
首先,什么是热更新?
- 字面意思就是对lua的一些代码进行更新,在介绍热更新之前,我想要和大家分享一下lua的require的机制
- 我们知道lua加载一个文件的方式可以有:dofile,loadfile以及 require。其中loadfile是只编译不执行,dofile和require是同时编译和执行。而dofile和require的区别是dofile同一个文件每次都要加载,也就是说,dofile两次返回来的是两个不同的地址。而require同一个文件,不管多少次都是都返回同一个地址,其原因是lua的地址缓存在了package.load()中。所以效率比dofile要高许多,因而现在一般都是用require加载文件。
- 那么问题来了,如果我在lua文件中改变了一些数值(产生了新的地址),结果你却用之前的地址,那岂不是白给了吗?
于是热更新机制应运而生。其实现方式有两种:
(1)简单版但是有缺陷
package.load(“modelname”) = nil
-- 修改modelname.lua的数据
require(“modelname”)
- 既然你有缓存,我直接置为空不就好了吗?然后重新require一次把修改好的加进来。这样子做的话第二次require的数据可能是正确的,但是之前require过一次的数值却仍然错误,所以说程序除非在之前没有加载过这个文件,否则得到的结果不完善。
(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
-
简单来说就是使用一个全局表存储了新修改后的所有数值,然后循环赋值给旧的值,这样就可以确保同一个旧地址也可以得到正确的数据。
-
最后贴一张热更新项目的流程图把:
二、hotfix 实现了Lua热更新,仅对函数作替换,保留旧数据
Lua 5.2/5.3 hotfix. Hot update functions and keep old data.
https://github.com/jinq0123/hotfix
- hotfix 实现了Lua热更新,仅对函数作替换,保留旧数据。
例如 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!
三、热更新介绍
1.原理
-
任何一款手游上线之后,我们都需要进行游戏Bug的修复或者是在遇到节日时发布一些活动,这些都需要进行游戏的更新,而且通常都会涉及到代码的更新。
-
热更新是指用户直接重启客户端就能实现的客户端资源代码更新操作。游戏热更新会减少游戏中的打包次数,提升程序调试效率,游戏运营时候减少大版本更新次数,可以有效防止用户流失。
-
lua语言在热更新中会被广泛使用,我们这里采取的是ulua,ulua是unity+lua+cstolua组成的,开发者已经为我们封装成SimpleFramework_UGUI和SimpleFramework_NGUI框架,让开发者在使用的时候更加快捷方便。
2.要点分析
1.Lua语言
-
再热更新功能开发过程中,我们需要用到一款新的语言:Lua语言。
-
Lua和C#对比:C#是编译型语言,Lua是解析型语言
-
Lua语言不可以单独完成一个项目的开发,Lua语言出现的目的是“嵌入式”,为其他语言开发出来的项目进行功能的扩展和补丁的更新。
2.Lua语言与C#语言交互
- Unity项目是使用C#开发的,后续热更新的功能需要使用Lua语言实现。而我们在最开始使用C#开发项目的时候,需要预留和Lua代码的“交互接口”,这就涉及到两门语言的代码相互调用访问。
3.AssetBundle
- AssetBundle是Unity内资源的一种打包格式,和电脑上的rar、zip压缩包比较类似,客户端热更新过程中,从服务器上下载下来的资源,都是AssetBundle打包过的资源。
4.ULua和XLua热更新框架
- ULua和XLua是两个热更新框架,专门用于Unity客户端项目热更新开发。其实就是两个“资源包”,导入到我们的项目中,在框架的基础之上,完成我们项目需要的热更新逻辑。
3.Lua热更新的实现
整理一下热更新的思路主要有两点:
- 1.将模块中旧的函数替换成新的函数,这个新的函数可以放到一个lua文件中,或者以字符串的形式给出。
- 2.将模块中旧的函数,当前用到的所有上值,(什么是上值,后面有讲到)保存到起来,用于新函数引用,保证新函数作为模块中的一部分能够正确运行。
下面以一个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,那么它又应该是这样子的,见下图最左侧黄色部分:
4._ENV 环境变量
- 在 lua 程序设计一书中有过这样的解释,lua 语言并没有全局变量,所谓的全局变量都是通过某种手段模拟出来的。
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
- x,y 都是不用 local 声明,z 是 local 声明。
- 所以,我们用到的全局变量其实是保存到 _ENV 变量中。lua 语言在内部维护了一个表来作用全局环境(_G),通常,我们在 load 一个代码段,一个模块时,lua 会用这个表(_G)来初始化 _ENV。如果上面的几行代码是写在一个文件中,那么当 load 调用它时,又会等价于:
-- xxx.lua 文件
local _ENV = the global environment (全局环境)
return function(...)
local z = 10
_ENV.x = 0
_ENV.y = 1
_ENV.x = _ENV.y +z
end
5.上值 upvalue
当一个局部变量被内层的函数中使用的时候, 它被内层函数称作上值,或是外部局部变量。引用 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 函数的局部变量。
6.热更新函数Lua的require函数
- Lua的require(modelname)把一个lua文件加载存放到package.loaded[modelname]中,重复require同一个模块实际还是沿用第一次加载的chunk。因此,很容易想到,第一个版本的热更新模块可以写成这样:
--强制重新载入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
- 可以看到,强制地require新的模块来更新新的代码,非常简单暴力。但是,显然问题很多,旧的引用住的模块无法得到更新,全局变量需要用"a = a or 0"这种约定来保留等等。这种程度的热更新显然不能满足现在的游戏开发需求。
7.热更新函数Lua的setenv函数
- setenv是Lua 5.1中可以改变作用域的函数,或者可以给函数的执行设置一个环境表,如果不调用setenv的话,一段lua chunk的环境表就是_G,即Lua State的全局表,print,pair,require这些函数实际上都存储在全局表里面。那么这个setenv有什么用呢?我们知道loadstring一段lua代码以后,会经过语法解析返回一个Proto,Lua加载任何代码chunk或function都会返回一个Proto,执行这个Proto就可以初始化我们的lua chunk。为了让更新的时候不污染_G的数据,我们可以给这个Proto设置一个空的环境表。同时,我们可以保留旧的环境表来保证之前的引用有效。
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)()
8.热更新函数Lua的debug库函数
- Lua的函数是带有词法定界的first-class value,即Lua的函数与其他值(数值、字符串)一样,可以作为变量、存放在表中、作为传参或返回。通过这样实现闭包的功能,内嵌的函数可以访问外部的局部变量。这一特性给Lua带来强大的编程能力同时,其函数也不再是单一无状态的函数,而是连同外部局部变量形成包含各种状态的闭包。如果热更新缺少了对这种闭包的更新,那么可用性就大打折扣。
下面讲一下热更新如何处理旧的数据,还有闭包的upvalue的有效性问题怎么解决。这时候强大的Lua debug api上场了,调用debug库的getlocal函数可以访问任何活动状态的局部变量,getupvalue函数可以访问Lua函数的upvalues,还有相对应的修改函数。
例如,这是查询和修改函数局部变量写的debug函数:
-- 查找函数的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的方式显得不够干净。
9.深度递归替换所有的upvalue
- 接下来要做的事情很清晰了,递归所有的upvalue,根据一定的替换规则替换就可以,注意新的upvalue需要设置回原来的环境表。
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
10.实例分析
- 下面就来看下具体 demo 的实现。
-- 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 模块内容:
-- 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 内容:
-- test_hot.lua
local index -- 这个 index 必须声明,不用赋值,才能够引用到 test 模块中的局部变量 index
return function () -- 返回一个闭包函数,这个就是要更新替换后的原型
index = index + 1
print(index)
end
- 最后,再看看 hotfix.lua:
-- 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
- 这个用到了 lua 的两个 api 函数,在 Lua 5.3 参考手册 中有介绍。
debug.getupvalue (f, up)
此函数返回函数 f 的第 up 个上值的名字和值。 如果该函数没有那个上值,返回 nil 。
debug.upvaluejoin (f1, n1, f2, n2)
让 Lua 闭包 f1 的第 n1 个上值 引用 Lua 闭包 f2 的第 n2 个上值。
- 我们可以看到, hotfix.lua 做的事也是比较简单的,主要是收集 旧函数的所有上值,更新到新函数中。最后一步替换旧函数是在 main.lua 中完成。
- 最后看看运行结果:
[root@instance test]# lua main.lua
before hotfix
0
0
0
0
0
after hotfix
1
2
3
4
5
-------------
show: 5
四、Lua脚本热更新方案
-
热更新,通俗点说就是补丁,玩家那边知道重启客户端就可以更新到了的,不用卸载重新安装app,相对于单机游戏,这也是网络游戏用得比较多的一个东西吧。
-
首先,大概流程如下:
-
luaFileList.json文件内容一般是lua文件的键值对,key为lua文件路径+文件名,value为MD5值:
五、lua热更新
1.什么是热更新
- 热更新也叫不停机更新,是在游戏服务器运行期间对游戏进行更新。实现不停机修正bug、修改游戏数据等操作。也可以这样讲:一辆车以时速150km跑着,突然爆胎了,然后司机告诉你,我不停车,你去把轮胎换了,小心点。恩
2.热更新原理第一种:
-
lua中的require会阻止多次加载相同的模块。所以当需要更新系统的时候,要卸载掉响应的模块。(把package.loaded里对应模块名下设置为nil,以保证下次require重新加载)并把全局表中的对应的模块表置 nil 。同时把数据记录在专用的全局表下,并用 local 去引用它。初始化这些数据的时候,首先应该检查他们是否被初始化过了。这样来保证数据不被更新过程重置。
-
代码示例:
function reloadUp(module_name)
package.loaded[modulename] = nil
require(modulename)
end
- 这种做法简单粗暴,虽然能完成热更新,但是 问题很多,旧的引用的模块无法得到更新,这种程度的热更新显然不能满足现在的游戏开发需求。
3.热更新原理第二种:
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