首先,如果有过cocos2dx lua开发经验的朋友都知道,为什么使用Lua而不是C++,最重要的原因就是因为下面这三个原因

  • 热更新(在线更新代码和资源)
  • 比C++简单很多,入门和实战
  • 轻量级,最小最轻的脚本语言

今天就从Lua热更新,捣鼓一下其中的原理,并具体实战一下!

什么是热更新?

热更新也叫不停机更新,是在游戏服务器运行期间对游戏进行更新。实现不停机修正bug、修改游戏数据等操作。也可以这样讲:一辆车以时速150km跑着,突然爆胎了,然后司机告诉你,我不停车,你去把轮胎换了,小心点。

热更新的作用

Lua模块热更新原理,能很好的支持代码热更新机制,是大部分选择要嵌入脚本语言的原因之一。好处很简单,脚本代码可以热更新的话,调试和线上解决问题都可以不用重启程序了,对开发效率有很大的帮助。

热更新原理

Lua内部提供了一个require函数,来实现模块的加载,它做的事情主要是以下几个:

在registry[“_LOADED”]表中判断该模块是否已经加载过了,如果是则返回,避免重复加载某个模块代码。

依次调用注册的loader来加载模块,将加载过的模块赋值给registry[“_LOADED”]表。

而如果要实现Lua的代码热更新,其实也就是需要重新加载某个模块,因此就要想办法让Lua认为它之前没有加载过。查看Lua代码发现,registry[“_LOADED”]表,实际上对应的是package.loaded表,这在以下函数中有体现:

(loadlib.c)
627 LUALIB_API int luaopen_package (lua_State *L) {

655 /* set field `loaded' */
656 luaL_findtable(L, LUA_REGISTRYINDEX, "_LOADED", 2);
657 lua_setfield(L, -2, "loaded");

因此事情就很简单了,需要提供一个require_ex函数,可以理解为require的增强版,使用这个函数可以动态更新某个模块的代码:

function require_ex( _mname )
print( string.format("require_ex = %s", _mname) )
if package.loaded[_mname] then
print( string.format("require_ex module[%s] reload", _mname))
end
package.loaded[_mname] = nil
require( _mname )
end

这个函数做的事情一目了然。首先判断是否曾经加载过这个模块,如果有则打印一条日志表示需要重新加载某个模块,然后将该模块原来在表中注册的值赋空,然后再次调用require进行模块的加载和注册。

热更新实现细节

以上了解了Lua代码热更新的原理,但是还有一些细节需要提醒一下。

  • 如何组织你的项目中的Lua代码?

    • 我在qnode中使用的方式是,单独使用一个叫main.lua的文件调用require_ex函数来加载需要用到的lua模块,而Lua虚拟机创建之后执行的是这个文件,这样的话,当你需要热更新项目中的Lua代码时,只需要重新执行这个main.lua就行了。
  • 如何通知热更新代码呢?

    • 我在qnode中使用的信号机制,当服务器收到USR1信号时,通知所有工作进程,由工作进程来重新对main.lua进行重新加载,这样就完成了lua代码的热更新,为此我写了一个简单的脚本reload.sh,就是根据当前qnode的服务器进程ID来对其发送USR1信号量的。
  • 一般热更新的都是函数的实现,所以需要对全局变量做一些保护。

    • 比如当前某全局变量为100,表示某个操作已经进行了100次,它不能因为热更新重新置0,所以要对这些不能改变的全局变量做一个保护,最简单的方式就是这样:
1
a = a or 0

很简单的原理,只有当前a这个变量没有初始值的时候才会赋值为0,而后面不管这个Lua文件被加载多少次,a都不会因为重新加载了Lua代码发生改变了。

热更新实战

其实我们平时开发中,可以用简单易懂的方式来理解热更新

    1. 客户端向服务器发送请求,服务器告诉客户端,没更新,你是最新的啦,那就直接跳过喽
    1. 如果是告诉你有更新,那就要告诉我哪些需要更新对吧,你可能需要更新的东西,放在一个文件里,一并发送给客户端
    1. 客户端拿到这个文件,就一个一个去向服务器要,最后把要更新的内容都下载到本地了

cocos2dx-lua中有assetmanagerex的c++实现类,也有绑定到lua。

3.10之前有缺陷,问题是当有文件下载失败时会陷入死循环,导致业务链断裂。不过网上有解决办法,可简单修改源码解决。
建议把高于3.10版本以后的assetmanagerex代码移植到旧的3.x版本,也可以选择新项目使用3.10以后版本。

网上有提到两种热更新的方法

  • 1.只存在一套资源,用一个文件记录所有文件的信息(文件名,路径,大小,MD5)。游戏启动时下载这个文件与本地文件MD5进行对比,不同的和新增的下载下来,没有的删掉。(最好再做个简要信息文件,因为资源多了记录文件信息的文件会有上百KB大小)

  • 2.第二种存在多套资源,客户端每更新一个版本都会有一个内部版本号。更新服务端会有多套压缩包,如1.0-1.5, 1.1-1.5 ,1.2-1.5 ,1.3-1.5,1.4-1.5。此方法需要保留每个版本的文件资源,依次生成每一个版本到最新版本的增量压缩包(依据是文件名和MD5)

但是结合实战第一种和优点是方便管理,从始到终只有一套资源。缺点是玩家下载时流量多一点,因为没有压缩。第二种优点是玩家下载流量小,但每次升级需要保留历史版本为升级依据,版本越多越不好管理。

具体代码实战
local AutoUpdateScene = class("AutoUpdateScene", cc.load("mvc").ViewBase)

local manifestPath = "project.manifest"
local storagePath = "update"

function AutoUpdateScene:onCreate()

self._update_failed_count = 0

local layer = cc.Layer:create()

local am = nil

local function onEnter()

local ttfConfig = {}
ttfConfig.fontFilePath = "fonts/arial.ttf"
ttfConfig.fontSize = 80

local progress = cc.Label:createWithTTF(ttfConfig, "0%", cc.VERTICAL_TEXT_ALIGNMENT_CENTER)
progress:setPosition(cc.p(display.center.x, display.center.y + 50))
layer:addChild(progress)

am = cc.AssetsManagerEx:create(manifestPath, cc.FileUtils:getInstance():getWritablePath() … storagePath)
am:retain()

if not am:getLocalManifest():isLoaded() then
print("Fail to update assets, step skipped.")
self:onFail("本地资源错误,请重新下载游戏。")
else
local function onUpdateEvent(event)
local eventCode = event:getEventCode()
print("====== assetsmanagerex error code:", eventCode)
–[[ cc.EventAssetsManagerEx.EventCode = {
ERROR_NO_LOCAL_MANIFEST = 0,
ERROR_DOWNLOAD_MANIFEST = 1,
ERROR_PARSE_MANIFEST = 2,
NEW_VERSION_FOUND = 3,
ALREADY_UP_TO_DATE = 4,
UPDATE_PROGRESSION = 5,
ASSET_UPDATED = 6,
ERROR_UPDATING = 7,
UPDATE_FINISHED = 8,
UPDATE_FAILED = 9,
ERROR_DECOMPRESS = 10
} ]]
if eventCode == cc.EventAssetsManagerEx.EventCode.ERROR_NO_LOCAL_MANIFEST then
print("No local manifest file found, skip assets update.")
self:onFail(string.format("本地资源错误,请重新下载游戏。(错误码:%d)", eventCode))
elseif eventCode == cc.EventAssetsManagerEx.EventCode.UPDATE_PROGRESSION then
local assetId = event:getAssetId()
local percent = event:getPercent()
local strInfo = ""
if assetId == cc.AssetsManagerExStatic.VERSION_ID then
strInfo = string.format("Version file: %d%%", percent)
elseif assetId == cc.AssetsManagerExStatic.MANIFEST_ID then
strInfo = string.format("Manifest file: %d%%", percent)
else
strInfo = string.format("%d%%", percent)
end
progress:setString(strInfo)
self:setLoadingProgress(event:getPercentByFile())
elseif eventCode == cc.EventAssetsManagerEx.EventCode.ERROR_DOWNLOAD_MANIFEST or
eventCode == cc.EventAssetsManagerEx.EventCode.ERROR_PARSE_MANIFEST then
print("Fail to download manifest file, update skipped.")
self:onFail(string.format("更新失败,请检查网络配置。(错误码:%d)", eventCode))
elseif eventCode == cc.EventAssetsManagerEx.EventCode.ALREADY_UP_TO_DATE or
eventCode == cc.EventAssetsManagerEx.EventCode.UPDATE_FINISHED then
print("Update finished.")
self:onSuccess()
elseif eventCode == cc.EventAssetsManagerEx.EventCode.ERROR_UPDATING then
print("Asset ", event:getAssetId(), ", ", event:getMessage())
– self:onFail(string.format("更新资源失败,请检查网络后重试。(%d)", eventCode))
elseif eventCode == cc.EventAssetsManagerEx.EventCode.UPDATE_FAILED then
print("Fail to download resource files.")
self._update_failed_count = self._update_failed_count + 1
if self._update_failed_count <= 3 then
print("try again")
am:downloadFailedAssets()
else
self:onFail(string.format("更新失败,请检查网络配置。(错误码:%d)", eventCode))
end
elseif eventCode == cc.EventAssetsManagerEx.EventCode.NEW_VERSION_FOUND then
print("new version found.")
–am:update()
elseif eventCode == cc.EventAssetsManagerEx.EventCode.ASSET_UPDATED then
print("assets updated.")
elseif eventCode == cc.EventAssetsManagerEx.EventCode.ERROR_DECOMPRESS then
print("decompress error.")
end
end
local listener = cc.EventListenerAssetsManagerEx:create(am, onUpdateEvent)
cc.Director:getInstance():getEventDispatcher():addEventListenerWithFixedPriority(listener, 1)

am:update()
–am:checkUpdate()
end
end

local function onExit()
am:release()
end

local function onNodeEvent(event)
if "enter" == event then
onEnter()
elseif "exit" == event then
onExit()
end
end
layer:registerScriptHandler(onNodeEvent)

self:addChild(layer)
end

function AutoUpdateScene:onFail(msg)
print("====== update fail ======", msg)

– 热更新失败处理
end

function AutoUpdateScene:onSuccess()
print("====== update success ======")

local writablePath = cc.FileUtils:getInstance():getWritablePath()
package.path = writablePath … "update/src/?.lua;./?.lua;"

– 启动热更新后的场景
end

return AutoUpdateScene