[ZZ]quick-cocos2d-x基于源码加密打包功能的更新策略
Quick-cocos2d-x增加了编译及加密源代码的功能(具体可参考这篇文章)。以此功能为基础,我实现了一个版本更新模块,解决了自己项目中的版本更新需求。现抛砖引玉,与大家分享。
从基本原理和方案讲起比较枯燥,直接从介绍具体用法开始吧。
要能够在线更新文件,一个服务器是必须的。我目前实现的是用HTTP协议取文件,只需要有一个基本的web服务器,能通过类似http://<server>/<filename>的链接取到文件即可。当然,如果需要根据平台和版本进行文件的分发,服务器端还需要进行相应配置。
要调试下面的例子,自己在内网甚至就在本机上搭建一个最简单的web服务器就可以了。
一、基本更新功能
1.创建一个可以自动更新的程序
首先准备好我们的quick-x工程。作为例子,我们直接使用create_project来创建一个新工程helloworld来使用。这一步不用我多说。
接下来,请到这里下载我写的更新模块。只需要根目录下的update.lua文件即可,其他的目录下只是我的一些例子和工具。
将update.lua文件加到helloworld工程的scripts目录下。修改文件中“local server = "http://192.168.1.98:8088/"”这一句,改成自己的服务器地址。
在scripts目录下创建一个新文件appentry.lua,然后打开工程原来的main.lua文件,把里面调用程序入口的代码复制过来。如果你使用的是最新版的quick-x,那么就只有“require("app.MyApp").new():run()”这一句。保存appentry.lua文件。
现在修改main.lua文件,去掉原来的程序入口代码,改成“require "update"”。
这样,更新模块就添加好了。程序在启动时,将先运行更新模块,完成后,自动调用appentry进入原来程序的真正入口。
现在可以先运行看看了,相信大家都会选择最方便的player来调试的。实际上真机也是没问题的。
运行后,界面上首先会显示“Loading...”,然后再进入熟悉的“Hello,world”界面。
接下来我们调试与服务器的连接是否正常。在服务器上创建一个flist文件,内容为:
1 |
local list = { |
2 |
ver = "1.0.1" , |
3 |
stage = { |
4 |
}, |
5 |
remove = { |
6 |
}, |
7 |
} |
8 |
return list |
需要保证用http://<server>/flist这样的网址能取到flist。如果是最基本的web服务器,把flist文件放在web服务目录的根下面就可以。如果希望用http://<server>/getfile?filename=flist这样的形式取文件,请自己修改update.lua里的相关代码。
现在重新运行程序,运行结果看起来没有什么不同。不过,如果是在player上调试,应该能在helloworld工程目录下找到flist文件,和服务器上的文件内容是一样的,说明我们从服务器取到了文件,连接是正常的。如果是真机,请在device.writablePath指向的目录下找flist文件。
现在,我们已经有一个可以自动更新版本的程序了。为了模拟真实的应用环境,我们将把源码加密打包再进行后面的调试。如果还不知道应该怎么打包,请再认真参考前面的文章。另外,推荐使用QuickXDev插件进行打包(可参考这一篇)。
为了后面更好的演示图片资源的更新,打包前可以修改一下MainScene.lua,在界面上添加一张图片,如下:
1 |
display.addSpriteFramesWithFile(GAME_TEXTURE_DATA_FILENAME, GAME_TEXTURE_IMAGE_FILENAME) |
2 |
self.bg = display.newSprite( "#logo.png" , display.cx-200, display.cy-200) |
3 |
self:addChild(self.bg) |
将资源放到res目录下,在config.lua里配置好GAME_TEXTURE_DATA_FILENAME和GAME_TEXTURE_IMAGE_FILENAME的值。运行能正常显示图片后,就可以真正打包源码了。
假如你是完全按照参考文章去打包的,那你应该会将打包后的文件game.zip放在res目录下,并将scripts目录下所有原来的源码全部移走,启动程序,运行结果和没打包之前一致。
2.简单的功能更新
现在,我们希望将显示的文字改成中文。代码的修改是很简单的,将MainScene.lua中显示的字串“Hello, World”改成“你好,世界”就可以了。
但我们怎么让程序自动进行更新呢?当然是打包新代码然后放到服务器上让程序来取了。
因为我们只修改了MainScene.lua,所以我们只需要打包这个文件就可以了。打包时需要注意的是,MainScene.lua是放在app\scenes目录下的,所以它在打包目录下也应该放在相同的两级目录之下才行。
我们将新打包的文件随便改个文件名(其实在这一情境中,仍然叫game.zip也是没问题的),比如update.bin。将它放到服务器上,当然仍然要保证http://<server>/update.bin这样的网址能取到。
接下来我们修改服务器上的flist文件,如下:
1 |
local list = { |
2 |
ver = "1.0.2" , |
3 |
stage = { |
4 |
{name= "update.bin" , code= "8c528975dec8b6da5811e92f10a41be0" , act= "load" }, |
5 |
}, |
6 |
remove = { |
7 |
}, |
8 |
} |
9 |
return list |
可以看出,list文件的版本号变成了1.0.2,这样客户端程序就知道有了更新,准备取新的文件。
stage里指出这个版本里有多少需要下载到客户端的文件。
name是文件名。code是文件的MD5码,用于验证文件。如果不想验证文件,code可以等于nil或者干脆不写这一键值。但不进行文件验证显然是不利的,除了不能保证下载文件的正确性而引起程序崩溃,还有就是不能进行同名文件的更新,如果原来已经下载过update.bin,不进行验证的话默认原来的文件是正确的,客户端程序将不会重新下载update.bin。
文件的md5码取得是很容易的,有很多方法。我实现更新模块时顺便写了个取md5的小程序,放在更新模块工程的tools目录下,使用方法很简单,将要取md5的文件放在getMd5/files目录下,用player打开getMd5工程运行,工程目录下生成的flist.txt里就是需要的内容了。
act="load"表明这一文件是代码文件,需要载入运行,如果是普通的图片资源,可以等于nil或者直接不写这一键值。
现在,可以运行客户端程序了。“Loading...”界面过后,显示的是“你好,世界”,自动更新成功了!
接下来我们将讨论一下更新的基本原理,以确认我们的更新方式是真正可行的,同时进一步演示如何进行更深层次的功能更新(如资源路径变化的处理、framework包修改、update模块自身更新等)。当然,更新机制的健壮性(不能因为更新失败,或者更新文件被破坏造成程序无法工作),还有如何根据平台和机型进行版本文件的分发等,也是我们要讨论的。
------------------------
二、更新原理讨论及更复杂的更新功能
1.更新原理
在前面的更新过程中,从服务器取文件列表,并根据文件列表再更新相关的文件,这都是很好理解的。当然其中还有些流程细节关系到健壮性、续传、文件版本分发等,我们可以后面再讨论。
对于一些刚开始学习Quick-x的朋友来说,可能希望了解的是,这一更新机制的替换原理是什么,为什么新的文件下载后能够替代原来的代码生效呢?
从前面我们参考的源文件编译及加密的相关文章,可以清楚的看到,初始代码打包成的game.zip文件,是在AppDelegate.ccp中,在程序启动前通过loadChunksFromZIP载入的。这一载入工作实际上已经将所有代码都加载了,之后调用require时,将会直接 调用而不会再去找代码文件来载入。也正因为如此,一些朋友会感到迷惑,即使下载了新的代码文件,又如何让它生效呢?
其实很简单,loadChunksFromZIP是可以多次调用的,而且如果第二次载入的包中的代码模块与之前载入的模块有重名,新的模块会覆盖旧的模块。
在Quick-x的Lua代码中,对应的调用接口是CCLuaLoadChunksFromZIP。有了这个接口,我们下载新的代码包后,就可以自己加载了。在update.lua里,在下载完成后,会自动将act标记为load的文件加载一次,这样,模块的新代码就取代了旧的代码,也就实现了我们之前看到的更新功能。
原理很简单,似乎没问题了?可惜问题没这么简单。这是因为require的机制问题。如果在第二次载入包之前,某一个模块文件已经被require过,那么,虽然第二次载入后,这一模块的代码已经被更新了,但再次require时,由于此模块不会被真正调用而是直接取原来的调用结果,将会造成更新不生效。
下面我们通过调试一个新的更新情景来说明这个问题及解决方法。
2.已经require过的模块的更新
假设现在我们希望更新一下界面上的图片。
如果只是更换个别图片,这是很简单的,只需要在代码里newSprite里在图片文件名前加上完整的下载目录路径,然后将新的图片放到服务器上,在flist文件中添加图片文件名即可。不建议在要更新的代码里使用addSearchPath来添加搜索路径,因为在图片同名的情况下并不能保证优先查找到的是新的文件。
如果是更新纹理图,仍然可以使用上述明确指定资源路径的方式,同时也是建议的方式。因此,对于我们之前MainScene.lua的代码,可以做以下修改(假设device.writablePath是下载路径):
1 |
display.addSpriteFramesWithFile(device.writablePath..GAME_TEXTURE_DATA_FILENAME, device.writablePath..GAME_TEXTURE_IMAGE_FILENAME) |
2 |
self.bg = display.newSprite( "#logo.png" , display.cx-200, display.cy-200) |
3 |
self:addChild(self.bg) |
当然这不是唯一的改法。为了说明主题,我们换成修改config模块的方式。这次不修改MainScene.lua,而是在config.lua中,修改原来的纹理图定义如下:
1 |
GAME_TEXTURE_DATA_FILENAME = device.writablePath.. "game.plist" |
2 |
GAME_TEXTURE_IMAGE_FILENAME = device.writablePath.. "game.png" |
要注意的是,如果不是更新代码,这样写是有问题的。通常config模块都是最早被require的,甚至还在framework.init模块之前,此时device都还没有定义,所以如果是正常流程就会出错。但这里是更新代码,在被调用之前,update模块已经调用过framework.init,所以device已经被初始化过了。
这就引出了我们的问题,既然config模块在早期已经被调用,那么,当新的config下载到客户端后被加载时,再require实际上已经不能让它生效了!
最直接的解决办法是,在重新require之前,调用以下的代码:
1 |
package.loaded [ "config" ] = nil |
即将config的调用标记清除。这样,再次require时就能让Lua程序重新去执行新的config模块了。我们将这条语句加到appentry模块的开头,这是最合适的地方了。
我们仍然将修改后的config.lua和appentry.lua打包成update.bin,和新的图片资源文件一起放到服务器上,这次服务器的flist文件如下:
01 |
local list = { |
02 |
ver = "1.0.3" , |
03 |
stage = { |
04 |
{name= "game.plist" , code= "29d103e9831720c1be12d8b33a1ea762" , }, |
05 |
{name= "game.png" , code= "e9dd2797018cad79186e03e8c5aec8dc" , }, |
06 |
{name= "update.bin" , code= "a681c6a002989832645ed26b766c7afa" , act= "load" }, |
07 |
}, |
08 |
remove = { |
09 |
}, |
10 |
} |
11 |
return list |
在客户端启动程序,版本自动被更新,进入MainScene显示的图片变成了新的,成功了!
大家可以自行验证不清除调用标记的情况。要注意的是如果是在player上,显示的图片一样会变成新的,这是因为device.writablePath在player上刚好是最优先的搜索路径,所以下载后的图片会被先搜到。因此这个测试最好下载到其他目录下,或者在新的config模块里加一条调试输出语句,这样就很容易确认新的模块是否真的被执行了。
由上述例子可以看到,更新模块时,困难的地方就在于判断重加载前有没有已经被调用的模块。特别是作在应用运行过程中动态更新的方案时,更需要对流程有清楚的把握。
即使是现在实现的这个预更新的方案,在解决了config模块的重载调用问题后,仍然要面对一个相对更困难的问题:如果framework模块需要更新,应该怎么办?
3.深层逻辑更新的处理方式:强制覆盖式调用
framework模块是Quick-x的核心之一,旧版本里是在main.lua里载入,现在的版本更是将它的载入位置提前到了Lua程序入口被调用之前的C++代码中,而通常framework的init模块也是早早会被调用。即使是update模块,也是先调用了init才能方便的实现更新功能。虽然这一模块更新的机率很小,但作为解决方案,还是要考虑清楚应该如何对它进行更新。
首先会想到的,是象之前处理config模块一样,清除调用标记。然而仔细考虑,会觉得不太行得通。因为看代码可以知道,init模块还调用了framework里很多的其他模块,在修改之后要理清哪些模块需要重调用是很繁琐的,而繁琐就容易出错。因此这不是最好的解决方案。
经过考虑,我觉得最方便的是这样一种解决方式:如果需要更新framework模块,那么,在打包时,不将代码放在framework目录下,而是其他的目录,如“framework1”。这样,所有的模块就变成了framework1.init、framework1.device等等,在新的代码里调用“require "framework1.int"”,将会真正重新运行框架代码,而不必担心有哪个模块没有重新运行生效。
这就是“强制覆盖式调用”的含义,它在很多情形下可以避免过于复杂的更新逻辑的判断,推荐给大家。
再举个例子,如果更新模块本身需要更新,那应该怎么办呢?如果要为此在更新模块中预留更新逻辑,会非常复杂难解。然而,如果跳出来看,其实完全可以在修改完update模块后,打包成一个updateNew模块放到服务器,让原更新模块下载后强制调用,这样一来,整个处理就简单了。
通过上面的讨论,现在我们已经能确认,update模块的更新功能是有效的。接下来我们还要讨论更新方案的另一个要点:安全性。
如果因为更新失败造成客户端无法启动,那无疑是致命的。幸运的是,Quick-x的打包机制能让我们保有一个原始版本,即使是在最不幸的出错情况下,程序仍然能回到最初的版本而不至于崩渍。
------------------------------------------
三、更新流程说明及特性分析
A.更新流程
- 加载初始安装包,载入旧资源列表
- 取最新资源列表文件,载入新资源列表
- 比较两个资源列表版本,如果一致,跳到第8步;以下流程中如果有误也跳到第8步
- 根据新资源列表检查现有文件,逐一下载新增或者有变化的文件,并加.upd后缀保存
- 每个下载的文件在保存后马上进行一次校验
- 所有文件下载完成,更新本地资源列表文件,用新列表替换旧列表
- 将下载的文件去掉.upd后缀,覆盖旧的文件
- 根据资源列表再校验一遍资源文件
- 第8步正确,则按资源列表的指示载入相关资源,启动程序(新版本)
- 第8步有错,说明资源列表与资源文件不匹配,删除本地资源列表文件(保证下次启动时重新更新资源),启动程序(原始安装版本)
B.安全性
可以看出在第6步之前,即使出错,也不会破坏原来的文件。跳到第8步后,一般能够以上一个更新成功的版本启动,除非上一个版本被用户破坏。
第6步和第7步出错,会造成资源列表与资源文件不匹配,跳到第8步后,肯定只有从原始版本启动了。
第8步错误,有可能是因为前面出错,也有可能是用户自己破坏了本地文件。无论如何,还是能从原始版本启动,并保证下次进入能再次更新。
第8步正确,并不一定说明这次更新是成功的,但启动起来的一定是最后一次更新成功的版本。
因此我们可以确认,只要update模块本身流程没有问题,此更新方案是安全的。
C.其他特性
- 对于确定的终端,服务器端只需要维护一个最新版本的文件列表。不管客户端是什么版本,都能直接升级到新版本
- 本地已经下载过的文件不会再次下载,只做增量更新
- 本次更新下载了部分文件但未完成最后更新,下次更新,已经下载成功的文件不会再重复下载
- 代码经过压缩,减少下载量
- 根据客户端请求时的参数能够做到版本分发
后记
对于方案的介绍到这里就告一段落了。后面这部分本来还想展开一下的,但后来觉得必要性没这么大,毕竟和大家分享的主要是解决方案而不是细节。
希望我的分享对大家能有所帮助,也请大家对方案的不足之类多提意见!