基于cocos creator2.4.x的热更笔记

基于cocos creator2.4.x官方热更方案的使用总结

前言

​ 热更新对国内手游开发来说已经是耳熟能详的事情了,现代原生端的手游迭代更新基本都需要这一套功能,不管从应用更新的角度还是用户体验的角度,热更新都是非常有必要的,本文是对于cocos creator2.4.x官方热更方案实践的一些经验之谈,对于热更的使用以及使用过程中遇到的一些问题做了详细的总结说明,原文中已有的部分不会做太多说明,如果是之前没有了解过的伙伴,配合原文一起阅读会更好,原文链接 资源热更新教程 | Cocos Creator

一 、什么是热更新,以及热更新的原理

​ 首先要说明一下,客户端的热更都是基于原生平台来说的,本文只讨论手游,一般就是指Android和ios。对于网页或者小游戏平台来说,由于资源加载和游戏运行的机制不一样,是不支持热更新的。

​ 以下的用例都以Android来说明,ios热更同Android在使用层面来说基本是一样,需要注意的是ios官方是不允许用户热更新的,在审核阶段发现有热更的话会不通过,所以如果ios要热更的话,我们需要做一些方案绕过审核,使得审核包不带热更,正式包带热更。

不使用热更新的更新流程

​ 先来看一下不使用热更新的更新流程

​ 当我们开发好游戏后,如果要在Android系统上面运行,需要打一个Apk包,Apk是什么文件,感兴趣的可以了解一下,我这边简单的百度了一下。APK(全称:Android application package,Android应用程序包)是Android操作系统使用的一种应用程序包文件格式,用于分发和安装移动应用及中间件。一个Android应用程序的代码想要在Android设备上运行,必须先进行编译,然后被打包成为一个被Android系统所能识别的文件才可以被运行,而这种能被Android系统识别并运行的文件格式便是“APK”。 一个APK文件内包含被编译的代码文件(.dex 文件),文件资源(resources), 原生资源文件(assets),证书(certificates),和清单文件(manifest file)。

​ 总的来说就是给Android系统一个能够安装使用的包,那怎么打Apk包呢?引擎提供了构建Apk程序的工具,我们只需要配置好相关的环境就可以打包了。假如我们现在已经打好了一个包,先要上传到应用商店,玩家从商店下载这个包安装后就能玩了。要注意一个问题就是,Apk包后一但安装到Android系统上就不允许用户修改了,如果这个时候游戏需要更新怎么办?我们又打一个包上传到应用商店,玩家在下载这个包覆盖安装现有的包实现更新。

​ 简单的流程就是:
​ 1、使用引擎工具构建一个Apk包

​ 2、上传这个包到应用商店

​ 3、玩家从商店下载这个包然后安装到手机上就可以体验了

​ 4、如果这个时候我们需要更新,修改完内容后重新打一个整包即可,又回到第一步

​ 这种更新方式的优点就是不需要实现热更等功能,可以使用性能更高效的语言来开发游戏,比如直接使用c++等更接近引擎的编译型语言来做逻辑、游戏也可以随意改动,不需要考虑热更版本迭代之类的东西,(比如升级引擎、改动原生层的东西都没关系)这个方案就是简单粗暴。缺点就是游戏每次更新,都需要上传整包,玩家需要重新下载安装。而且应用商店还要重新审核,如果有多个渠道的话维护起来是很麻烦的事情,用户体验也不太好,在现在国内的手游环境下一般很少采用这种方案。

使用热更新的更新流程

​ 那么热更新是怎么一回事呢?就是玩家安装Apk后,每次游戏内容有变动不需要重新下载整包,而是打开游戏,在游戏中实现下载最新版本内容,下载完后重启即可体验最新内容。

​ 这个是怎么做到的呢?上面有提到Apk包后一但安装到Android系统上就不允许用户修改,所以我们的更新是不能直接修改Apk上面内容的,而是要有一个机制能够使得Apk读取外部文件的资源去替换包内的资源,从而实现更新。这个需要依赖引擎的功能,热更新的本质是用远程下载的文件取代原始游戏包中的文件。Cocos2d-x 的搜索路径恰好满足这个需求,它可以用来指定远程包的下载地址作为默认的搜索路径,这样游戏运行过程中就会使用下载好的远程版本。这一部分的内容在官方文档上面有详细的说明。那各个版本之间是如何更新的呢?我们来看一下官方文档的说明。

​ Cocos Creator 中的热更新主要源于 Cocos 引擎中的 AssetsManager 模块对热更新的支持。它有个非常重要的特点:

Cocos 默认的热更新机制并不是基于补丁包更新的机制,传统的热更新经常对多个版本之间分别生成补丁包,按顺序下载补丁包并更新到最新版本。Cocos 的热更新机制通过直接比较最新版本和本地版本的差异来生成差异列表并更新服务端和本地均保存完整版本的游戏资源,热更新过程中通过比较服务端和本地版本的差异来决定更新哪些内容。这样即可天然支持跨版本更新,比如本地版本为 A,远程版本是 C,则直接更新 A 和 C 之间的差异,并不需要生成 A 到 B 和 B 到 C 的更新包,依次更新。所以,在这种设计思路下,新版本的文件以离散的方式保存在服务端,更新时以文件为单位下载。

​ 我们现在有了热更后,更新流程就变成这样了。

​ 1、使用引擎工具构建一个Apk包 ,带热更功能。

​ 2、上传这个包到应用商店。

​ 3、准备一个远程服务器用来存放需要更新的资源。

​ 4、玩家从商店下载这个包然后安装到手机上。

​ 5、如果这个时候我们需要更新,修改完内容后打一个热更包上传到远程服务器上。

​ 6、当玩家打开游戏发现远程版本文件比本地高的时候,就会开始更新的流程,更新完成后重启游戏(并不是关闭游戏,而是引擎层面的重启)即可体验全新版本,不需要每次都重新下载Apk包。

二 、热更新的流程,和一些准备工作

​ 现在我们对热更已经有一个基本的了解,热更的流程在官方文档上面已经有详细的说明,这里我们主要是从代码层面来看。有两个关键点,首先是如何构建一个带热更的Apk包,其次如何构建后续的热更包。

让Apk包带上热更功能。

​ 在项目下创建一个热更脚本,实现基本的热更功能,代码相关的讲解都在注释里面,官方提供的是js版的,这里改一个ts版本。

const { ccclass, property } = cc._decorator;

@ccclass
export default class HotUpdate extends cc.Component {

    @property(cc.Node) progressNode: cc.Node = null;
    @property(cc.Label) alartLbl: cc.Label = null;
    @property(cc.Asset) manifestUrl: cc.Asset = null;  // 以资源的形式,绑定初始的manifest

    private _am: jsb.AssetsManager; // 热更的对象
    private _storagePath: string = '';
    private _hotPath: string = 'HotUpdateSearchPaths'
    private _progress: number = 0;
    private _downFailList: Map<string, number> = new Map();

    protected onLoad(): void {
        this._showProgress(0);
        this.alartLbl.string = '';
        this._init();
    }

    private _init() {
        if (cc.sys.os != cc.sys.OS_ANDROID && cc.sys.os != cc.sys.OS_IOS) {
            // 非原生平台不处理
            this._enterGame();
            return;
        }

        this._storagePath = ((jsb.fileUtils ? jsb.fileUtils.getWritablePath() : '/') + this._hotPath);
        // 创建一个热更对象,提供一个本地资源路径,用来存放远程下载的资源。一个版本比较方法,用来判断是否需要更新
        this._am = new jsb.AssetsManager('', this._storagePath, this._versionCompareHandle);

        // 设置MD5 校验回调,这里一般用来比对文件是否正常
        // 如果这个方法返回true表示正常, 返回false的会触发文件下载失败,失败时会抛出错误事件ERROR_UPDATING,后面还会提到
        this._am.setVerifyCallback(function (filePath, asset) {
            // When asset is compressed, we don't need to check its md5, because zip file have been deleted.
            let compressed = asset.compressed;
            // Retrieve the correct md5 value.
            let expectedMD5 = asset.md5;
            // asset.path is relative path and path is absolute.
            let relativePath = asset.path;
            // The size of asset file, but this value could be absent.
            let size = asset.size;
            if (compressed) {
                return true;
            }
            else {
                // let expectedMD5 = asset.md5; // 远程project.manifest文件下资源对应的MD5
                // let resMD5: string = calculateMD5(filePath);  // filePath是文件下载到本地的路径,需要自行提供方法来计算文件的MD5
                // return resMD5 == expectedMD5;
                return true;
            }
        });

        if (cc.sys.os === cc.sys.OS_ANDROID) {
            // Some Android device may slow down the download process when concurrent tasks is too much.
            // The value may not be accurate, please do more test and find what's most suitable for your game.
            // this._am.setMaxConcurrentTask(2);
        }

        this._progress = 0;
        //检测更新
        this._checkUpdate();
    }

    /**
     * 版本对比,返回值小于0则需要更新
     * @param versionA 当前游戏内版本  本地
     * @param versionB 需要更新的版本  服务器
     * @returns 
     */
    _versionCompareHandle(versionA: string, versionB: string) {
        cc.log("JS Custom Version Compare: version A is " + versionA + ', version B is ' + versionB);
        let vA = versionA.split('.');
        let vB = versionB.split('.');
        for (let i = 0; i < vA.length; ++i) {
            let a = parseInt(vA[i]);
            let b = parseInt(vB[i] || '0');
            if (a === b) {
                continue;
            } else {
                return a - b;
            }
        }
        if (vB.length > vA.length) {
            return -1;
        } else {
            return 0;
        }
    }

    private async _checkUpdate() {
        // 判断当前热更的状态,没有初始化才加载本地Manifest,加载完成后状态会改变
        if (this._am.getState() === jsb.AssetsManager.State.UNINITED) {
            let url = this.manifestUrl.nativeUrl;
            this._am.loadLocalManifest(url); // 加载本地manifest
        }

        if (!this._am.getLocalManifest() || !this._am.getLocalManifest().isLoaded()) {
            cc.error('Failed to load local manifest ...');
            return;
        }

        this._am.setEventCallback(this._checkCb.bind(this));
        this._am.checkUpdate();
    }

    private _checkCb(event: jsb.EventAssetsManager) {
        switch (event.getEventCode()) {
            case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
            case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
            case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
                // manifest文件相关的错误,这里可以去做一些错误相关的处理
                console.error('加载manifest文件失败', event.getEventCode())
                break;
            case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
                // 当前版本已经是最新版本
                cc.log('Already up to date with the latest remote version.');
                this._enterGame();
                break;
            case jsb.EventAssetsManager.NEW_VERSION_FOUND:
                // 找到新的版本
                cc.log(`New version found, please try to update. (${this._am.getTotalBytes()})`);
                cc.log(`getDownloadedBytes${this._am.getDownloadedBytes()}`);
                cc.log(`getTotalBytes${this._am.getTotalBytes()}`);
                this._am.setEventCallback(null);
                this._downLoadAlart();
                break;
            default:
                return;
        }
    }

    private _downLoadAlart() {
        // 我这边的话是检测到有新版本则直接更新,这一步可以根据自己的需求来处理
        this._hotUpdate();
    }

    // 开始更新
    private _hotUpdate() {
        this._am.setEventCallback(this._updateCb.bind(this));
        this._am.update();
    }

    private _updateCb(event: jsb.EventAssetsManager) {
        var needRestart = false, file = null;
        switch (event.getEventCode()) {
            case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
            case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
            case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
                // manifest文件相关的错误,这里可以去做一些错误相关的处理
                break;
            case jsb.EventAssetsManager.ERROR_UPDATING:
                file = event.getAssetId();
                // 文件更新出错,这里有可能是验证方法没有通过,也有可能是文件下载失败等等
                this._updateFailList(file, false);
                break;
            case jsb.EventAssetsManager.ERROR_DECOMPRESS:
                // 文件解压缩失败
                break;
            case jsb.EventAssetsManager.UPDATE_FAILED:
                cc.error(`Fail to Update -> ${event.getMessage()}`);
                let failed = false;
                this._downFailList.forEach((count) => {
                    if (count > 3) {
                        failed = true;
                    }
                });
                if (failed) {
                    // 超过3次失败,显示下载失败
                    // this._showUpdateFalid();
                } else {
                    cc.log(`HotUpdate failed...Restart Update`);
                    this._am.downloadFailedAssets();  // 重新下载失败的文件
                }
                break;
            case jsb.EventAssetsManager.UPDATE_PROGRESSION:
                // 这里处理正常下载进度显示
                if (event.getPercent()) {
                    let downloadBytes = event.getDownloadedBytes() || 0;
                    let totalBytes = event.getTotalBytes() || 0;
                    this._progress = Math.floor(downloadBytes / totalBytes * 100);
                    if (this._progress <= 0) return;
                    this._showProgress(this._progress);

                    let unit = 1048576;/* 1MB = 1,024 KB = 1,048,576 Bytes */
                    let downloadedMB = (downloadBytes / unit).toFixed(2) + 'MB';
                    let totalMB = (totalBytes / unit).toFixed(2) + 'MB';
                    this.alartLbl.string = `下载资源: ${this._progress}% (${downloadedMB}/${totalMB})`;
                    cc.log('downloadBytes=>', this._progress, downloadedMB, totalMB);
                }
                break;
            case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
                // 已经是最新版本
                cc.log('Already up to date with the latest remote version...');
                this._enterGame();
                break;
            case jsb.EventAssetsManager.UPDATE_FINISHED:
                cc.log(`Update finished. ${event.getMessage()}`);
                // 更新完成,这里去做重启
                needRestart = true;
                break;
            case jsb.EventAssetsManager.ASSET_UPDATED:
                // 每次资源下载成功会回调到这里,可以根据需求做一些处理
                cc.log('Updated file: ' + event.getAssetId() + ' ' + event.getMessage());
                file = event.getAssetId();
                this._updateFailList(file, true);
                break;
            default:
                break;
        }

        if (needRestart) {
            this._am.setEventCallback(null);
            var newPaths = this._storagePath;
            // 将当前路径写入到本地,持久化数据以便下次游戏启动的时候能拿到
            cc.sys.localStorage.setItem(this._hotPath, JSON.stringify([newPaths]));
            cc.game.restart();
        }
    }

    private _updateFailList(file: string, success: boolean) {
        if (success) {
            this._downFailList.delete(file);
            cc.log('更新成功', file);
        } else {
            if (this._downFailList.get(file)) {
                let count = this._downFailList.get(file);
                this._downFailList.set(file, count + 1);
                cc.log(`${file} download fail count ${count + 1}`);
            } else {
                this._downFailList.set(file, 1);
                cc.log(`${file} download fail count 1`);
            }
        }
    }

    private _showProgress(percent: number) {
        percent > 100 && (percent = 100);
        let progress = cc.winSize.width * percent / 100;
        this.progressNode.width = progress;
    }
    
    private _enterGame() {
        cc.director.loadScene("game");
    }
}

​ 从@property(cc.Asset) manifestUrl: cc.Asset = null;这里我们看到项目里面需要提供一个manifest资源文件,如下图所示,这个文件是需要在本地生成的, 一般会用一些工具来自动生成,比如官方也提供了一个生成manifest的工具version_generator.js,这里就不做演示了。

​ 取决于后续的热更包方案是全量还是增量,这里的内容稍微有点不同,后面会详细说明全量和增量的不同点。这里只需要知道

​ 如果是全量的话,在构建完成后,项目包内的manifest根据当前初始资源生成一次,assets对象里面的资源文件需要带上MD5等信息。

​ 如果是增量的话,在构建完成后,项目包内的manifest根据当前初始资源生成一次,assets对象置空即可。

​ 官方的方案是全量的形式。
在这里插入图片描述

​ 在打包Apk之前,要修改构建后的main.js文件,main.js相当于是我们游戏的入口文件,最开始执行的脚本,在开头加上这段代码(可以用打包插件自动去添加,官方热更的项目就是这么做的),这段代码是热更生效的关键,需要在游戏最开始的时候执行。因为我们热更想要生效的话,需要用新的文件去替换包内的文件,而我们新热更下载的资源是放在当前包内的可写文件夹内,引擎为我们提供了一个可以设置资源优先加载的接口,这样我们游戏运行时会先使用热更文件夹下的文件,如果热更中没有的找到还是会去包内的目录下面寻找。

(function () {
     if (typeof window.jsb === 'object') {
        // 获取本地存储中的热更路径
        var hotUpdateSearchPaths = localStorage.getItem('HotUpdateSearchPaths');
        if (hotUpdateSearchPaths) {
            var paths = JSON.parse(hotUpdateSearchPaths);
            // 将这个目录设置到搜索路径中,游戏运行的时候会优先加载这个目录下的资源
            jsb.fileUtils.setSearchPaths(paths);

            var fileList = [];
            var storagePath = paths[0] || '';
            var tempPath = storagePath + '_temp/';
            var baseOffset = tempPath.length;

            // 如果本次更新顺利完成了,但是还存在临时文件,说明还剩余一部分文件没有复制到搜索路径下,这里就是将临时文件夹的内容复制到热更存储资源路径下,同时移除临时文件夹
            if (jsb.fileUtils.isDirectoryExist(tempPath) && !jsb.fileUtils.isFileExist(tempPath + 'project.manifest.temp')) {
                jsb.fileUtils.listFilesRecursively(tempPath, fileList);
                fileList.forEach(srcPath => {
                    var relativePath = srcPath.substr(baseOffset);
                    var dstPath = storagePath + relativePath;

                    if (srcPath[srcPath.length] == '/') {
                        jsb.fileUtils.createDirectory(dstPath)
                    }
                    else {
                        if (jsb.fileUtils.isFileExist(dstPath)) {
                            jsb.fileUtils.removeFile(dstPath)
                        }
                        jsb.fileUtils.renameFile(srcPath, dstPath);
                    }
                })
                jsb.fileUtils.removeDirectory(tempPath);
            }
        }
    }
})();

​ 做完上述操作后,就可以开始打原生包了,打包这里要注意几个地方,发布平台要选Android,不要勾选MD5 Cache,其它的参数可以根据项目需要来填,对热更没有影响。这样我们能打一个带热更的Apk包了。如果是要打ios包的话,首先在Mac电脑上先构建一个ios的打包工程,然后将Android这边构建出来的build/jsb-link下面的(assets、src、mian.js)复制到Mac电脑上构建出的ios打包工程对应的地方(替换掉ios工程这边的assets、src、mian.js),然后用xcode出ios包即可,后续的热更包Android和ios是共用同一个的。
在这里插入图片描述在这里插入图片描述

构建后续的热更包

​ 构建热更包这里其实很简单,每次我们做好更新的功能后,我们只需要build一下就行,不用compile,参数跟打初始原生包的时候保持一致即可。

热更包这里的生成可以分为全量打包,或者增量两种情况。manifest文件可以用工具生成,生成之前要注意提升一下版本号。

生成全量热更包

​ 全量就是manifest文件的生成每次都是基于构建完后的完整资源。好处就是生成流程简单,不好的地方就是每个热更的版本都包含了所有资源,manifest里面的assets对象也包含了所有资源的信息。build完成后,如图所示,将项目build/jsb-link下面的assets 和 src以及当前版本的manifest文件复制到我们远程的资源服务器上即可。
build目录

生成增量热更包

​ 增量就是每次热更包只包含有变化的资源和基于变化资源生成的manifest文件。

​ 那怎么来生成增量的热更文件呢?我们在构建Apk初始包的时候基于当时的资源文件(assets和src)生成一份manifestA(这里我称为初始版本),将这个manifestA保存在本地(和全量不同点就是全量是将这个manifestA文件带到包内了),每次有内容更新时,先构建一次,然后将基于建后的资源生成一份manifestB(这里是基于项目最新构建的版本)将这个manifestB文件和初始的manifestA做比对,拿出有变化的资源(判断MD5和大小等信息,不匹配则认为有变化),再根据变化的资源生成一份新的manifestC(实际需要热更的版本),将变化的资源和基于变化生成的mainfestC一起上传到资源服务器上即可。这样做的好处就是每个版本资源不是全部的,只是基于初始manifestA有变化的,资源量会少一些,在游戏资源量大的而且有多个版本需要保存时候会有优势,缺点就是生成比较麻烦。

​ 这些都可以写成工具配合命令行打包去做自动化,后面就方便了。

热更过程中一些文件目录的说明

​ 我们再来看一下热更过程中一些文件目录的说明,这样我们能够更加清晰的知道整个过程是怎么运作的。

​ 1、存放我们的热更资源的服务器,如下图所示,我这边是用apache-tomacat搭建了一个本地资源服务器测试用,(也可以用其它服务器,如果这一块有疑问的,可以先熟悉一下如何搭建本地资源服务器相关的教程,这里就不做过多讲解啦),正式环境需要专门的资源服务器。里面存放了我们需要更新的游戏资源文件,以及对应的project.manifest和version.manifest版本控制文件。有了这些资源后,游戏运行时会先根据版本文件来比对是否需要更新,如果需要则会下载这个目录下的需要资源到本地完成热更。
在这里插入图片描述
​ 2、一个带热更功能的apk母包,先看下一包内的资源目录结构,我们将一个Apk解压出来,可以看到目录结构如下,我们游戏内的资源都在assets下(下面有assets、src、main等等其它资源),游戏运行时就是从这个目录获取资源的。

在这里插入图片描述
​ 3、我们来看一下在Android系统下,我们从远程下载下来的热更文件是怎么存储的,需要注意的是这个文件并不是上面我们看到的Apk包内资源目录,而是存储在一个可写目录下,一般是系统为apk单独分配的一个可操作的文件空间。我们可以通过 Android Studio+(模拟器或者手机设备)来查看这个目录(Apk要debug版才能看),如图所示,打开一下Device FIle Explorer弹窗,在data/data/你游戏的包名/files/HotUpdateSearchPaths下,我们看到热更存储的资源,这个是热更完成后的一个目录,这里资源就是在main.js中设置为优先使用的地方。
在这里插入图片描述

​ 4、同上面目录一样,在来看一下热更到一部分资源中断的情况下,这个目录结构会怎么样,我们发现多了一个临时文件夹HotUpdateSearchPaths_temp,这个是因为没有更新完成,所以会先存储在临时文件夹中,我们看这个project.manifest.temp和正常热更完成的资源比会多出一个downloadState的状态值,这个值记录当前文件的下载状态,是用来做断点续传的,后面还会提到。
在这里插入图片描述

​ 热更文件下载状态,在引擎热更源码里面有定义 引擎目录下的\resources\cocos2d-x\extensions\assets-manager\

enum DownloadState {
        UNSTARTED, // 0
        DOWNLOADING, // 1
        SUCCESSED,    // 2
        UNMARKED
    };

​ 5、在来对maifest版本文件做一个说明,根据官方的说明:其中 Version 文件内容是 Manifest 文件内容的一部分,不包含文件列表。由于 Manifest 文件可能比较大,每次检查更新的时候都完整下载的话可能影响体验,所以开发者可以额外提供一个非常小的 Version 文件。AssetsManager 会首先检查 Version 文件提供的主版本号来判断是否需要继续下载 Manifest 文件并更新。以下是project.manifest文件的部分内容,我们加载这个文件到游戏里面其实就是一个JSON对象。每个字段的解释如下,额外补充一点,

远程服务器上一个具体资源的地址是由 packageUrl + assets下的路径组合而成的,例如:http://192.168.1.118:8080/hot_test/src/cocos2d-jsb.js

那么在更新的时候,这个文件是怎么判断是否需要下载远程文件呢?首先会拿本地mainfest和远程manifest对比,根据官方的文档,只要 md5 信息不同,我们就认为这个文件有改动。

{
    "packageUrl": "http://192.168.1.108:8080/hot_test/",   // 远程热更资源的根目录
    "remoteManifestUrl": "http://192.168.1.108:8080/hot_test/project.manifest", // 远程project.manifest文件地址
    "remoteVersionUrl": "http://192.168.1.108:8080/hot_test/version.manifest",  // 远程version.manifest文件地址
    "version": "1.10.10",  // 版本号,用来做更新版本比较
    "assets": {            // assets对象,里面存储了资源相对路径和md5等参数
        "src/cocos2d-jsb.js": {
            "size": 3341465,
            "md5": "fafdde66bd0a81d1e096799fb8b7af95"
        },
        "src/project.dev.js": {
            "size": 97814,
            "md5": "ed7f5acd411a09d4d298db800b873b00"
        }
    },
    "searchPaths": []
}

三、哪些东西能够热更新

​ 项目内的资源我们都知道只需要替换就行,但是代码是怎么实现热更新的呢,为什么说引擎的C++等原生语言不能热更新,而js这种脚本语言则可以呢?这是因为js是解释型语言,通过js引擎解释执行,先转换为字节码,在转换为机器码,js在Android原生是跑在v8引擎上,在ios上跑在JavaScriptCore上面,这些js引擎就相当于是虚拟机,如下列官方提供引擎的架构图所示。这里不做深入探讨了。我们只需要知道除了引擎的C++、Android、ios等原生代码和main.js外,其它的基本都可以实现热更。不过如果有引擎升级的话要注意版本之间的差异。一般不建议热更。
在这里插入图片描述

四、热更功能更进一步的实践

如何使用远程manifest

​ 在有些情况下,我们想要动态控制更新地址,因为远程服务器有可能会出现连不上的情况,这个时候就需要启用备用服务器,将我们的更新地址设置为备用服务器,但是由于我们更新的地址是固定在本地的mainfest文件中,我们无法修改,所以加载本地maifest就不满足我们的需求了,官方刚好提供了一个loadRemoteManifest的方法,这个可以让我们读取远程的mainfest文件去获取是否需要更新,从而更加灵活的控制热更,这一步相当于是我们可以选择和一个远程的mainfest文件进行比较,而不是只能用本地mainfest文件中的remoteManifestUrl中指定的文件去做比较。

具体如何使用:

​ 在正常加载完本地mainfest文件后,我们先通过接口下载远程manifest文件到本地,然后调用loadRemoteManifest方法,相当于是我们手动完成了对比更新的操作,需要注意的是,使用远程manifest后,远程manifest文件中的packageUrl和备用的地址要能对的上,部分代码如下所示:

let remoteUrl = "http://192.168.1.108:8080/hot_test1/project.manifest"; // 这里我们先获取一下远程服务器上面的manifest地址
cc.assetManager.loadRemote(remoteUrl, { ext: '.json' }, (err, remoteManifest) => { // 下载到本地
    if (!err) {
        if (remoteManifest && remoteManifest.json) {
            let customManifest = JSON.stringify(remoteManifest.json);
            let manifest = new jsb.Manifest(customManifest, this._storagePath);
            this._am.loadRemoteManifest(manifest);
            cc.log('加载远程Manifest success...', customManifest, this._am.getRemoteManifest()?.getVersionFileUrl());
        }
    } else {
        cc.log('加载远程Manifest 失败...');
    }
})

​ 后续还是按照正常流程走,需要注意的是,当我们使用了loadRemoteManifest后,在检查更新的回调事件jsb.EventAssetsManager.NEW_VERSION_FOUND中使用this._am.getTotalBytes() 和this._am.getDownloadedBytes() 接口都不能获取到正确的大小,我们可以自己写一个方法来获取大小,代码如下:

private _checkCb(event: jsb.EventAssetsManager) {
    switch (event.getEventCode()) {
        case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
        case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
        case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
            // mainfest文件相关的错误,这里可以去做一些错误相关的处理
            break;
        case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
            // 当前版本已经是最新版本
            cc.log('Already up to date with the latest remote version.');
            this._enterGame();
            break;
        case jsb.EventAssetsManager.NEW_VERSION_FOUND:
            // 找到新的版本,使用了this._am.loadRemoteManifest后,这里获取到的值都是0
            cc.log(`New version found, please try to update. (${this._am.getTotalBytes()})`);
            cc.log(`getDownloadedBytes${this._am.getDownloadedBytes()}`);
            cc.log(`getTotalBytes${this._am.getTotalBytes()}`);
            this._am.setEventCallback(null);
            this._downLoadAlart();
            break;
        default:
            return;
    }
}

// 自行获取发现新版本时的totalBytes大小,projectManifest这个文件从远程下载的manifest对象
getNeedDownlodBytes(projectManifest) {
    var storagePath = this._storagePath
    var tempPath = storagePath + '_temp/';
    let totalSize = 0
    if (jsb.fileUtils.isDirectoryExist(tempPath) && jsb.fileUtils.isFileExist(tempPath + 'project.manifest.temp')) {
        // 有临时文件存在的情况,说明是断点续传的,直接从这里算就行
        let str = jsb.fileUtils.getStringFromFile(tempPath + 'project.manifest.temp')
        if (str) {
            try {
                let fileData = JSON.parse(str)
                for (let key in fileData.assets) {
                    let value = fileData.assets[key]
                    if (value && value?.downloadState == 0) {
                        totalSize += value?.size
                    }
                }
            } catch (err) {
                console.error('读取临时文件大小失败::', err)
            }
        }
    } else {
        // 从远端下载的project.manifest 中计算大小
        let fileData = projectManifest
        for (let key in fileData.assets) {
            let value = fileData.assets[key]
            if (value) {
                totalSize += value.size
            }
        }
    }
    return totalSize
}
多个包共用一份热更的版本迭代更新

​ 如果我们中间的热更已经迭代了很多个版本,这个时候我们想要出一个新的包,但是我们又不想破坏旧的包,新的包要和旧包需要共用同一套热更,新包需要从当前版本开始下载资源,而不是像老包一样要下载所有更新资源,例如:最开始发布了一个版本1.0.0,然后不断更新到了1.0.60,假设期间一共更新了300M资源,这个时候我们想要出一个新的包,这个包要和之前的1.0.0版本的共用同一份热更,但是又不能重新下载之前300M的热更资源,后面可能还需要更多这种迭代包,都是从当前版本开始热更,这种情况要怎么处理呢?

​ 由于我们的热更是直接更到最新版本的,所有要处理这种情况还是可以的,只需要将当前这个版本的热更manifest文件直接打到新的母包里面,就能实现了,因为当前热更生成的manifest已经记录从最开始到当前版本的变化文件,如果这个时候打到新包里面,新包已经包含了之前更新的内容,mainfest里面也记录了相关的变化,所有在版本比对的时候,不会去下载之前300M资源,而且我们新包的版本号也是从1.0.60开始,只会和远程更高版本的manifest做比较。

​ 这样我们就能实现多个包共用同一份热更,这样做的好处就是在不破坏线上环境的情况下,我们维护一个项目不需要每出一个新包都要重新维护一套热更,如果项目较大而且分包多的话,还是挺麻烦的。

大版本强制更新

​ 虽然我们有多个包共用同一份热更的方案,但是这个终归还不能解决大版本更新的需求,当我们需要进行大版本更新的时候,这个时候可能需要强制更新了,是否需要强制更新的判断一般是放在服务器,服务器有一个需强制更新的热更版本号,这个一般是可以配置的,客户端每次进游戏都将当前的热更版本号传给服务器,服务器判断是否需要强更,如果需要则处理强更的逻辑,不允许玩家进游戏,如果不需要则正常进行。针对强更的功能,我们需要对之前的热更资源进行一些处理,在新的包安装时,如果发现存在有之前保存的更新资源,我们需要删除掉,如何操作呢?我们可记录包的版本号来做这个操作,如果当前版本和之前版本对应不上的话,则删除热更资源,这个方法要在开始热更之前调用。参考代码如下:

    /** 检测app版本,如果大版本不同,则删除HotUpdateSearchPaths */
    public checkAppVersion() {
         if (cc.sys.os != cc.sys.OS_ANDROID && cc.sys.os != cc.sys.OS_IOS) return

        let key = "main_app_version"
        let localAppVersion = cc.sys.localStorage.getItem(key);
        let currAppVersion = '你的应用版本号'	// 这里我们可以通过原生接口传递过来,在打原生包的时候有对应的版本号
        cc.log(`hotupdate localAppVersion :${localAppVersion}`)
        cc.log(`hotupdate currAppVersion :${currAppVersion}`)
        let isVersionDiff = localAppVersion != currAppVersion;
        //保存
        cc.sys.localStorage.setItem(key, currAppVersion);

        if (isVersionDiff) {
            //删除目录sygame-main
            let mainStoragePath = ((jsb.fileUtils ? jsb.fileUtils.getWritablePath() : '/') + "HotUpdateSearchPaths");
            if (jsb.fileUtils.isDirectoryExist(mainStoragePath)) {
                jsb.fileUtils.removeDirectory(mainStoragePath)
                Logger.process(`hotupdate removeDirectory :${mainStoragePath}`)
            }
        }
    }

​ 要注意一下如果我们apk包里面的版本号比本地存储的更高(取决于你的版本比对函数),则会删除本地存储的文件

设置断点续传时保存文件进度的百分比

​ 更新过程中客户端可能由于各种原因而中断,这个时候断点续传的作用就体现出来的,官方热更本身是支持断点续传的,我们到引擎源码AssetsManagerEx.cpp里面能够看到一个宏SAVE_POINT_INTERVAL,这个就是每次触发保存的一个进度值,我们其实也可以将其改成一个变量导出给js层用,这样就能动态控制进度值了。

//在 AssetsManagerEx.h声明一下
float _pointInterval;

// 在AssetsManagerEx.cpp中初始化一下
#define SAVE_POINT_INTERVAL 0.1

// 在构造的时候我们用还是SAVE_POINT_INTERVAL这个默认值,额外提供接口来设置,接口后面还会讲到
, _pointInterval(SAVE_POINT_INTERVAL)
扩展一下暂停、继续的接口

​ 有些情况可能需要用到暂停的功能,比如将热更做到游戏内部,让用户手动选择下载更新,这个时候就能派上用场了。但是我们发现引擎并没有直接提供这些接口,我们找到热更的源码,发现热更下载其实是开启多线程去下载文件的,下载完成后也是异步解压,解压完后才会进行下一次下载,刚好引擎提供了一个设置最大下载线程数量的接口,我们可以利用这个接口来实现暂停,就是直接将线程数设置为0,这样下载就会停止,但是我们要恢复下载怎么办呢?如果我们尝试直接将线程数设置为原来大于0的值,会发现下载并没有继续,这是为什么呢?打开源码发现,设置线程数只是单纯的一个赋值,并不会去执行下载的操作。

在AssetsManagerEx.h中定义如下:

void setMaxConcurrentTask(const int max) {_maxConcurrentTask = max;};

​ 这个时候我们只需要恢复一下下载即可,如何恢复呢?在AssetsManagerEx.cpp中有一个queueDowload方法,每次不管下载成功还是失败,都会调用这个方法去进行下一个文件的下载,我们只需要在设置线程数量为正常值时去调用一下这个方法即可,下载就恢复正常了,至此完成了暂停、继续的功能。具体代码如下:

void AssetsManagerEx::queueDowload()
{
    if (_totalWaitToDownload == 0 || (_canceled && _currConcurrentTask == 0))
    {
        this->onDownloadUnitsFinished();
        return;
    }

    // 判断当前跑的任务数量是否小于设置的最大数量,如果小的话就会启用线程去处理下载
    while (_currConcurrentTask < _maxConcurrentTask && _queue.size() > 0 && !_canceled)
    {
        std::string key = _queue.back();
        _queue.pop_back();

        _currConcurrentTask++;
        DownloadUnit& unit = _downloadUnits[key];
        _fileUtils->createDirectory(basename(unit.storagePath));
        auto downloadTask = _downloader->createDownloadFileTask(unit.srcUrl, unit.storagePath, unit.customId);
        _downloadingTask.emplace(unit.customId, downloadTask);
        _tempManifest->setAssetDownloadState(key, Manifest::DownloadState::DOWNLOADING);
    }
    if (_percentByFile / 100 > _nextSavePoint)
    {
        // Save current download manifest information for resuming
        _tempManifest->saveToFile(_tempManifestPath);
        _nextSavePoint += _pointInterval; // 这里替换为我们自定义的进度
    }
}

在AssetsManagerEx.h中添加声明一下

// 暂停时保存一下当前最大进度,记得构造的时候给一下初始值, _pauseMaxConcurrentTask(0)
int _pauseMaxConcurrentTask; 

/**
* 暂停更新,会保存一次下载进度
*/
void pauseUpdate();

/**
* 恢复更新
*/
void resumeUpdate();

/** 设置下载文件时,保存的进度间隔,默认0.1(相当于每下载10%的文件时保存一次,主要用来做断点续传的保存),值越大,保存次数越少,可以减少写文件次数,值越小,保存次数越多,写入越频繁
* value  0-1之间的值,不能等于0
*/
void setSavePointInterval(float value) {_pointInterval = value;};

在AssetsManagerEx.h中实现一下

void AssetsManagerEx::pauseUpdate()
{
    CCLOG("AssetsManagerEx : pauseUpdate. %d%d\n",_pauseMaxConcurrentTask,_maxConcurrentTask);
    if(_updateState != State::UPDATING)
    {
        CCLOG("AssetsManagerEx : pauseUpdate... state not updating \n");
        return;
    } 

    if(_maxConcurrentTask <= 0)
    {
        return;
    }
    
    _pauseMaxConcurrentTask = _maxConcurrentTask;
    _maxConcurrentTask = 0;
}

void AssetsManagerEx::resumeUpdate()
{
    CCLOG("AssetsManagerEx : resumeUpdate. %d%d\n",_pauseMaxConcurrentTask,_maxConcurrentTask);
    if(_updateState != State::UPDATING)
    {
        CCLOG("AssetsManagerEx : pauseUpdate... state not updating \n");
        return;
    } 

    if(_maxConcurrentTask > 0)
    {
        return;
    }

    if(_pauseMaxConcurrentTask > 0)
    {
        _maxConcurrentTask = _pauseMaxConcurrentTask;
    }
    else
    {
        _maxConcurrentTask = 1;
    }
    queueDowload(); // 让其继续下载
}

​ 由于修改了引擎的c++代码,所以要导出给上层js来用,加上断点续传时保存文件进度的百分比接口,我们一共需要导出3个方法。c++导出绑定js流程大家可以参考官方文档,也可根据参考AssetsManagerEx内部其它方法的导出,依葫芦画瓢就行。大概有下列文件需要处理,这里不在展开了。

​ builtin/jsb-adapter/jsb.d.ts
​ cocos2d-x/cocos/scripting/js-bindings/auto/api/jsb_cocos2dx_extension_auto_api.js
​ cocos2d-x/cocos/scripting/js-bindings/auto/jsb_cocos2dx_extension_auto.cpp
​ cocos2d-x/cocos/scripting/js-bindings/auto/jsb_cocos2dx_extension_auto.hpp

导出完成后,我们在creator.d.ts中jsb下的AssetsManager加入一下导出的方法就可以使用啦

在这里插入图片描述

pauseUpdate(): void;
resumeUpdate(): void;
setSavePointInterval(value:number);
完结

​ 到此关于热更的使用实践暂告一个段落,一路下来遇到过不少问题和坑,爬摸滚打一步步实践出来,深刻的感觉到,“纸上得来终觉浅,绝知此事要躬行”的道理。不过也学到了很多东西还是值得的,在这里做个记录总结,也是帮助自己梳理一下思路,使得自己对整个热更的认知能够更加清晰。更多功能大家都可以通过扩展源码来实现,感兴趣话的可以深入去研究,甚至自己来写一套热更新方案,哈哈,这里就不再探讨了。

<think>嗯,用户问的是如何写Cocos Creator游戏开发的简历,我得先理清楚他们可能需要的内容。首先,用户可能是个游戏开发者,正在找工作,需要突出自己的技能和项目经验。他们可能对简历的结构和重点不太清楚,尤其是如何展示Cocos Creator相关的经验。 我应该先考虑简历的基本结构,比如个人信息、专业技能、项目经验、教育背景等。但重点要放在Cocos Creator游戏开发相关的部分。专业技能里需要列出Cocos Creator的掌握程度,比如熟悉组件化开发、UI系统、动画系统、物理引擎等。可能还需要包括相关的编程语言,比如TypeScript或JavaScript,以及工具如Photoshop、Tiled等。 项目经验是关键部分,用户需要详细描述参与过的游戏项目,尤其是使用Cocos Creator开发的项目。每个项目应该包括项目名称、角色、技术栈、项目描述和具体贡献。贡献部分要具体,比如优化性能、实现特定功能、设计UI等,最好有量化数据,比如性能提升多少,用户增长多少。 可能用户还会担心没有足够的经验,这时候可以建议他们包含个人练习项目,或者强调快速学习的能力。另外,附加部分比如开源贡献、技术博客、GitHub链接也能增加竞争力。 需要注意避免过于笼统的描述,要具体,使用技术术语但不过于晦涩。同时,提醒用户检查简历的格式,避免错误,保持简洁。可能还需要根据不同的职位要求调整简历内容,突出相关技能和经验。 总结下来,应该分步骤引导用户构建简历,强调结构、专业技能的具体点、项目经验的详细描述,以及附加内容的重要性。同时,提供示例会有帮助,让用户清楚如何组织内容。还要提醒他们注意排版和真实性,确保没有错误。</think>我将从结构、重点、案例三个层次,为您拆解Cocos Creator开发者简历撰写要点: 一、基础结构框架(总分式结构) $$简历结构 = 基础信息 + 技术栈 + 项目经历 + 附加项 + 教育背景$$ 二、核心技术展示(突出Cocos Creator技术栈) 1. 引擎技能分层展示: - 核心模块:UI系统($UIMeshRenderer$)、动画系统($AnimationClip$)、物理引擎($Box2D$) - 扩展能力:热更新($AssetsManager$)、跨平台发布($Web/iOS/Android$) - 优化技巧:DrawCall优化($动态合批$)、内存管理($对象池$) 2. 编程能力组合: $$技术栈 = TypeScript(80\%) + JavaScript(60\%) + Python(40\%)$$ 配套工具链:$Webpack$构建、$Jest$单元测试、$Git$版本控制 三、项目经历写法(STAR法则改进版) 1. 项目模板: ``` [项目名称] 2D横版跑酷(2023.03-2023.06) 技术栈:Cocos Creator 3.7 + TypeScript + Spine • 运动系统:实现角色$抛物线跳跃$($v = v_0 + at^2$) • 碰撞优化:采用$空间分割算法$,碰撞检测效率提升$40\%$ • 数据统计:通过$装饰器模式$封装埋点系统 ``` 2. 成就量化公式: $$性能提升率 = \frac{优化前帧率 - 优化后帧率}{优化前帧率} \times 100\%$$ 四、避坑指南(常见错误修正) 1. 技术描述误区对照表: | 应避免写法 | 建议改写 | |---------|--------| | "熟悉Cocos引擎" | "实现基于$ECS架构$的技能系统" | | "做过UI界面" | "采用$Widget组件$完成多分辨率适配" | 五、附加价值体现(技术深度延伸) 1. 开源贡献示例: $$GitHub贡献度 = \sum_{i=1}^{n}(PR合并数 \times 项目Star数)$$ 2. 技术影响力建设: - 开发专栏文章《$Cocos Shader特效开发实践$》阅读量$10k+$ - GDC 2023《$独立游戏性能优化$》参会笔记 六、简历优化公式(竞争力计算模型) $$竞争力指数 = \frac{\sum 项目复杂度 \times 技术深度}{简历匹配度} + 附加分$$ [注意事项] 1. 版本对应原则:标明使用的Cocos Creator版本(如v2.4.x/v3.x) 2. 作品集策略:准备$WebGL在线试玩链接$或$短视频演示$ 3. 真实性校验:确保技术细节可经$白板编程测试$验证 示例片段: ``` 技术亮点: - 实现$贝塞尔曲线弹道系统$,支持$n$阶曲线配置 - 开发$状态机插件$,降低AI逻辑复杂度$30\%$ - 优化$粒子系统$渲染,同屏特效数量提升至$200+$ ``` 最后建议:使用$LaTeX$排版工具制作简历,通过$Git版本控制$管理不同公司定制版本。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值