其实打算做游戏内热更新也是几个月之前的事情了,在方案经历了数次变迁之后,最近才终于应用到了外网的bugfix中。
但是就目前数据来看,热更新由于要下载资源,会使新用户的进入门槛变高,所以留存收到了一定影响,基本降低了10个点。
当然,也可能是热更新的功能存在bug。
好了,我们还是进入正题吧!
方案一
最早的时候,我们想用一种类似打patch的方式来更新。
即将lua代码和资源打成一个zip包,而每个zip包只要代码或者资源发生变化,都会有一个自增的小版本号(大版本号为打在apk或者ipa里的版本号)。
不同的zip包之间diff就可以生成一个patch文件,而为了开发的简单,这个patch列表只会将文件路径列出来,之后客户端要去完整的下载新的文件覆盖。
然而这个方案有很多的问题。
-
小版本号不好维护 因为代码和资源会不停的修改,而如果人工来维护小版本号,会极其复杂。如果通过机器来维护也是个很麻烦的工程
-
patch包太多 如果我们将小版本号的patch包先生成好放到服务器上,那么如果我们有100个小版本,就会有非常多 1->100, 2->100, 3->100 ... 这种格式的patch包。 并且如果小版本号继续增长,patch包的增长基本会失控。
-
消耗大量流量,速度缓慢 也许有同学会说,不如只有 1->2, 2->3, 3->4 这样的单个小版本之间的升级patch,客户端自己挨个下载就好。
但是这样客户端如果是个很小的版本号,就不会不停的重复下载,导致迟迟进入不了游戏,并且流量也大量消耗。
综上所述,方案一有太多的弊端。
这也是为什么我们做完了之后迟迟不敢上线的原因,那么我们来看看方案二。
方案二
方案二相对就简单了很多。
说白了,我们希望有一种方式能够快速的判断出客户端的代码资源,与服务器端的代码资源是否完全一致。
怎么做呢?对的,md5.
依托于我们目前的打包系统,我们现在每个渠道打出来的apk里面的代码资源都是不尽相同的。而我在其打包之后,自动将所有的代码资源,进行了一次md5,生成了一个如下格式的文件md5.txt:
1644c6d212b0e03cbf33a3872d1ef52b|src/cache/User.lua|4982
009634c68017aefb1c9387f6bf105905|src/cache/WordMsg.lua|2441
第一列为文件的md5值,用来判断文件是否有变化。
第二列为文件路径,拼接上服务器返回的url前缀即为完整的下载地址。因为我们使用cdn存储文件,所以url前缀随时可能变化。
第三列为文件大小。当提示用户更新的时候,可以算出看到更新的总大小,并在下载中显示文件进度。
那么我们怎么判断客户端的代码资源和服务器是否完全一致呢?
还是md5.
我们只需要判断在相同渠道和大版本的情况下,客户端md5.txt的md5值与服务器的是否相等即可。
为了节省流量,客户端每次进入游戏的时候会将本地的md5.txt的md5值发给服务器,服务器在判断之后告知客户端更新状态是无需更新、建议更新、还是强制更新。
如果不是无需更新,就要把服务器端md5.txt的下载路径返回给客户端,客户端下载下来之后,将两个md5.txt对比后列出不同的文件列表,并计算需要下载的总大小。之后提示用户更新或者直接进入更新逻辑。
大概的说明就是这样,我们接下来说下服务器端和客户端代码的具体实现。
服务器
我们后台是使用django的,所以天生带了一个管理后台,在models中添加我们自己的逻辑也比较简单。
大家如果是别的框架,其实原理也是一样的。
数据库表:
class LuaSource(models.Model):
file = models.FileField(u'文件', upload_to=partial(get_source_zip_path, 'lua_source', 'zip'))
version = models.IntegerField(u"大版本号", blank=True, default=0, null=True)
channel = models.CharField(u"渠道", max_length=128, blank=True, default='')
md5sum = models.CharField(u"列表md5值", max_length=128, blank=True, default='')
status = models.IntegerField(u'更新状态', default=config.PACKAGE_STATUS_NOT_UPDATE,
choices=config.PACKAGE_UPDATE_STATUS)
create_time = models.DateTimeField(u'创建时间', default=datetime.datetime.now)
网络参数:
input:
channel 渠道
version 大版本号
md5 md5.txt的md5值
output:
status 更新状态
url_prefix url前缀
file_list 新的md5.txt下载链接
md5 新的md5.txt的md5值。这个客户端要用来做存储目录的。
客户端
客户端的代码逻辑要稍微复杂一些:
-
在cpp层,创建一个update目录,并将lua的search优先级调到最前面。
这里我们很不幸的踩过一个坑,就是我们把这个update目录的名字固定为update。但这样会带来一个问题,就是当用户更新了大版本之后,还是会优先去读取这个update目录的代码,从而导致进入不了游戏。
正确的方式应该是将这个将渠道和大版本号也拼上,比如 update_CN_IOS_APP_91。
ios和android的具体代码如下:std::string strChannel = "UNKNOWN"; std::string strVersion = "UNKNOWN"; #if (CC_TARGET_PLATFORM == CC_PLATFORM_IOS) NSString * channel = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"Channel"]; NSString * version = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; strChannel = [channel UTF8String]; strVersion = [version UTF8String]; #endif #if (CC_TARGET_PLATFORM == CC_PLATFORM_ANDROID) JniMethodInfo methodVersion; if (JniHelper::getStaticMethodInfo(methodVersion, JAVA_CLASS_NAME.c_str(), "getVersionCode", "()I")) { jint version = methodVersion.env->CallStaticIntMethod(methodVersion.classID, methodVersion.methodID); std::stringstream ss; ss << version; strVersion = ss.str(); methodVersion.env->DeleteLocalRef(methodVersion.classID); } JniMethodInfo methodChannel; if (JniHelper::getStaticMethodInfo(methodChannel, JAVA_CLASS_NAME.c_str(), "getChannel", "()Ljava/lang/String;")) { jstring str = (jstring)methodChannel.env->CallStaticObjectMethod(methodChannel.classID, methodChannel.methodID); strChannel = JniHelper::jstring2string(str); methodChannel.env->DeleteLocalRef(methodChannel.classID); methodChannel.env->DeleteLocalRef(str); } #endif _updatePath = CCFileUtils::getInstance()->getWritablePath() + UPDATE + "_" + strChannel + "_" +strVersion;
-
当与服务器进行网络交互后,服务器告知客户端需要更新时。
先创建一个以新md5值为名的目录,并将新的md5.txt下载其中,之后将所有不同的文件下载到这个目录里。
当所有文件下载完成后,将新的md5.txt文件拷贝update目录,之后重新进入游戏。
由于lua操作文件系统的限制,我们需要将一个lfs的库编译近来,具体可以去cocos2d-quick中找。
另外要注意,当更新完毕重新进入游戏时,需要将所有引入的lua代码删除再重新导入。 而如果热更新的代码本身被修改了的话,则需要将热更新代码本身也重新导入。并重新走一遍热更新判断逻辑。
基本就是这样子了,很多细节代码不方便放出,大家自己想想应该写起来也不难。
Joker on #
我之前做的cocos2d-js的热更新方案也是差不多的方式。
游戏启动的时候卡出加载检查更新确实不大好,会增加启动时间。我们的做法是先进游戏,后台检查更新,如果发现更新就弹一个窗出来说有更新,更新完成后重启游戏。
另一个问题是,更新文件列表可能会很大,每次启动都要下载不大好,可以加一个版本号文件,每次更新完把版本号写到本地,启动的时候先从服务器读一个版本号,如果版本号一致就什么都不干了。这个版本号我们一开始是用一个自增id,多人开发经常冲突,后来换成了更新生成的unix timestamp。
Reply
Dante on #
嗯啊,我们也不是每次都下载,文中有写,是比较这两个列表文件的md5值
Reply
Joker on #
我把对比资源列表的逻辑放到了客户端,没有服务器逻辑,服务器上只放两个静态文件一个版本号、一个资源列表,和更新资源部署到一起,做CDN
Reply
sw on #
我们以前也是这么干的,另外patch的手法也还是有,大版本直接下服务器准备好的patch包了,小版本就这么直接下文件~
Reply