最后更新于 .

其实打算做游戏内热更新也是几个月之前的事情了,在方案经历了数次变迁之后,最近才终于应用到了外网的bugfix中。
但是就目前数据来看,热更新由于要下载资源,会使新用户的进入门槛变高,所以留存收到了一定影响,基本降低了10个点。
当然,也可能是热更新的功能存在bug。

好了,我们还是进入正题吧!

方案一

最早的时候,我们想用一种类似打patch的方式来更新。
即将lua代码和资源打成一个zip包,而每个zip包只要代码或者资源发生变化,都会有一个自增的小版本号(大版本号为打在apk或者ipa里的版本号)。
不同的zip包之间diff就可以生成一个patch文件,而为了开发的简单,这个patch列表只会将文件路径列出来,之后客户端要去完整的下载新的文件覆盖。

然而这个方案有很多的问题。

  1. 小版本号不好维护 因为代码和资源会不停的修改,而如果人工来维护小版本号,会极其复杂。如果通过机器来维护也是个很麻烦的工程

  2. patch包太多 如果我们将小版本号的patch包先生成好放到服务器上,那么如果我们有100个小版本,就会有非常多 1->100, 2->100, 3->100 ... 这种格式的patch包。 并且如果小版本号继续增长,patch包的增长基本会失控。

  3. 消耗大量流量,速度缓慢 也许有同学会说,不如只有 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值。这个客户端要用来做存储目录的。

客户端

客户端的代码逻辑要稍微复杂一些:

  1. 在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;
    
  2. 当与服务器进行网络交互后,服务器告知客户端需要更新时。
    先创建一个以新md5值为名的目录,并将新的md5.txt下载其中,之后将所有不同的文件下载到这个目录里。
    当所有文件下载完成后,将新的md5.txt文件拷贝update目录,之后重新进入游戏。

由于lua操作文件系统的限制,我们需要将一个lfs的库编译近来,具体可以去cocos2d-quick中找。

另外要注意,当更新完毕重新进入游戏时,需要将所有引入的lua代码删除再重新导入。 而如果热更新的代码本身被修改了的话,则需要将热更新代码本身也重新导入。并重新走一遍热更新判断逻辑。

基本就是这样子了,很多细节代码不方便放出,大家自己想想应该写起来也不难。

Pingbacks

Pingbacks已打开。

Trackbacks

引用地址

评论

  1. Joker

    Joker on #

    我之前做的cocos2d-js的热更新方案也是差不多的方式。

    游戏启动的时候卡出加载检查更新确实不大好,会增加启动时间。我们的做法是先进游戏,后台检查更新,如果发现更新就弹一个窗出来说有更新,更新完成后重启游戏。

    另一个问题是,更新文件列表可能会很大,每次启动都要下载不大好,可以加一个版本号文件,每次更新完把版本号写到本地,启动的时候先从服务器读一个版本号,如果版本号一致就什么都不干了。这个版本号我们一开始是用一个自增id,多人开发经常冲突,后来换成了更新生成的unix timestamp。

    Reply

    1. Dante

      Dante on #

      嗯啊,我们也不是每次都下载,文中有写,是比较这两个列表文件的md5值

      Reply

  2. Joker

    Joker on #

    我把对比资源列表的逻辑放到了客户端,没有服务器逻辑,服务器上只放两个静态文件一个版本号、一个资源列表,和更新资源部署到一起,做CDN

    Reply

  3. sw

    sw on #

    我们以前也是这么干的,另外patch的手法也还是有,大版本直接下服务器准备好的patch包了,小版本就这么直接下文件~

    Reply

发表评论