Cocos Creator 热更新

文章目录

    • 1、引言
    • 2、准备工作
      • 2.1、工具准备
      • 2.2、知识准备
        • 2.2.1 官方教程
        • 2.2.2 更新流程
    • 3、热更新
      • 3.1 搭建热更服务器
      • 3.2 创建热更目录
      • 3.3 creator客户端搭建
        • 3.3.1 制作UI
        • 3.3.2 添加脚本
        • 3.3.3 生成manifest文件
        • 3.3.4 创建热更新文件
    • 4、Demo演示
    • 5、示例下载
    • 6、结束语


1、引言

  creator用了有一年了,一直没有注意热更新!这段刚好有时间,来看看热更的实现!下面我们一起来看看具体的实现办法!

2、准备工作

2.1、工具准备

  我这里使用的工具如下:

  • Cocos Creator Version 2.3.0,点此根据需要下载;
  • VSCode
  • Google浏览器
  • 打包环境,打包不会的同学请参考Cocos Creator Android打包 apk,这里详细记录了打包流程!热更之前先确保能够正常出包,如果没有包,就谈不上热更新!
  • cocosCreator 热更新插件 《热更新manifest生成工具》,这里列举了详细的插件安装步骤。
  • hfs网络文件服务器 2.3(要用做本地简单服务器的搭建,可自行下载,也可点这里下载)

2.2、知识准备

  我们要实现热更新,必须要有个理论依据,需要知道热更新的原理,和实现步骤!毕竟没有流程我们不知道如何下手!我们就从官方文档开始一步一步的熟悉大致的流程!

2.2.1 官方教程

  下面是官方的文档,并且举例给了Demo:

  • 官方热更新管理器
  • 官方热更新范例教程

2.2.2 更新流程

  • 基本原理
      热更新机制本质上是从服务器下载需要的资源到本地,并且可以执行新的游戏逻辑,让新资源可以被游戏所使用。这意味着两个最为核心的目标:下载新资源,覆盖使用新逻辑和资源。

  从官方文档中我们大概知道了,热更新的原理:

  游戏资源来说,也可以在资源服务器上保存一份完整的资源,客户端更新时与服务端进行比对,下载有差异的文件并替换缓存。无差异的部分继续使用包内版本或是缓存文件。这样我们更新游戏需要的就是:
  服务端保存最新版本的完整资源(开发者可以随时更新服务器)
客户端发送请求和服务端版本进行比对获得差异列表
从服务端下载所有新版本中有改动的资源文件
用新资源覆盖旧缓存以及应用包内的文件

  这就是整个热更新流程的设计思路,当然里面还有非常多具体的细节,后面会结合实际流程进行梳理。这里需要特别指出的是:
  Cocos 默认的热更新机制并不是基于补丁包更新的机制,传统的热更新经常对多个版本之间分别生成补丁包,按顺序下载补丁包并更新到最新版本。Cocos 的热更新机制通过直接比较最新版本和本地版本的差异来生成差异列表并更新。 这样即可天然支持跨版本更新,比如本地版本为 A,远程版本是 C,则直接更新 A 和 C 之间的差异,并不需要生成 A 到 B 和 B 到 C 的更新包,依次更新。所以,在这种设计思路下,新版本的文件以离散的方式保存在服务端,更新时以文件为单位下载。

  • 热更新基本流程

  基于这样的项目结构,本篇教程中的热更新思路很简单:

1、基于原生打包目录中的 assets 和 src 目录生成本地 Manifest 文件。
2、创建一个热更新组件来负责热更新逻辑。
3、游戏发布后,若需要更新版本,则生成一套远程版本资源,包含 assets 目录、src 目录和 Manifest 文件,将远程版本部署到服务端。
4、当热更新组件检测到服务端 Manifest 版本不一致时,就会开始热更新

  这里也将按照这个思路来实现一个热更新Demo!

3、热更新

  这里将分为以下几个部分介绍!

3.1 搭建热更服务器

3.2 创建热更目录

  在硬盘中创建一个目录,用来热更新的目录。我这里是D:\hotUpdate_client
Cocos Creator 热更新_第1张图片
  通过上面的地址下载后是一个压缩文件,解压到磁盘中,启动hfs.exe,我这里是hfs_490056.exe!点击箭头根部的按钮,设置端口号为8080!将刚刚的目录拖入hfs左侧空白就会自动生成,此时点击左边列表中的hotUpdate_client文件夹就会看到形如红框中的地址!这个地址就是将来热更的地址!Cocos Creator 热更新_第2张图片
  这样就简单的生成了资源更新服务器了。

3.3 creator客户端搭建

任务目标:通过热更新来实现一个切换游戏场景的功能!

3.3.1 制作UI

先创建一个新项目,添加如下控件:Cocos Creator 热更新_第3张图片
其中三个按钮check绑定checkUpdate,update绑定hotUpdate,changeScene用来切换场景,这里可以先绑定上,然后隐藏这个按钮!这里绑定事件需要后面添加了脚本后再绑定,保存场景为test,我们通过更新来显示changeScene按钮,并且点击按钮来切换场景!新的场景这里先不制作,等出了包之后再更改做!

3.3.2 添加脚本

首先需要再项目的根目录添加version_generator.js脚本( 官方代码仓库也有),放置如下位置:
Cocos Creator 热更新_第4张图片
这里把代码贴在下面:

/**
 * 此模块用于热更新工程清单文件的生成
 */

var fs = require('fs');
var path = require('path');
var crypto = require('crypto');

var manifest = {
    //服务器上资源文件存放路径(src,res的路径)http://192.168.1.133:8080/hotUpdate_client/
    packageUrl: 'http://192.168.1.133:8080/hotUpdate_client/',
    //服务器上project.manifest路径
    remoteManifestUrl: 'http://192.168.1.133:8080/hotUpdate_client/project.manifest',
    //服务器上version.manifest路径
    remoteVersionUrl: 'http://192.168.1.133:8080/hotUpdate_client/remote-assets/version.manifest',
    version: '1.0.0',
    assets: {},
    searchPaths: []
};

//生成的manifest文件存放目录
var dest = 'assets/';
//项目构建后资源的目录
var src = 'build/jsb-default/';

/**
 * node version_generator.js -v 1.0.0 -u http://your-server-address/tutorial-hot-update/remote-assets/ -s native/package/ -d assets/
 */
// Parse arguments
var i = 2;
while ( i < process.argv.length) {
    var arg = process.argv[i];

    switch (arg) {
    case '--url' :
    case '-u' :
        var url = process.argv[i+1];
        manifest.packageUrl = url;
        manifest.remoteManifestUrl = url + 'project.manifest';
        manifest.remoteVersionUrl = url + 'version.manifest';
        i += 2;
        break;
    case '--version' :
    case '-v' :
        manifest.version = process.argv[i+1];
        i += 2;
        break;
    case '--src' :
    case '-s' :
        src = process.argv[i+1];
        i += 2;
        break;
    case '--dest' :
    case '-d' :
        dest = process.argv[i+1];
        i += 2;
        break;
    default :
        i++;
        break;
    }
}


function readDir (dir, obj) {
    var stat = fs.statSync(dir);
    if (!stat.isDirectory()) {
        return;
    }
    var subpaths = fs.readdirSync(dir), subpath, size, md5, compressed, relative;
    for (var i = 0; i < subpaths.length; ++i) {
        if (subpaths[i][0] === '.') {
            continue;
        }
        subpath = path.join(dir, subpaths[i]);
        stat = fs.statSync(subpath);
        if (stat.isDirectory()) {
            readDir(subpath, obj);
        }
        else if (stat.isFile()) {
            // Size in Bytes
            size = stat['size'];
            md5 = crypto.createHash('md5').update(fs.readFileSync(subpath, 'binary')).digest('hex');
            compressed = path.extname(subpath).toLowerCase() === '.zip';

            relative = path.relative(src, subpath);
            relative = relative.replace(/\\/g, '/');
            relative = encodeURI(relative);
            obj[relative] = {
                'size' : size,
                'md5' : md5
            };
            if (compressed) {
                obj[relative].compressed = true;
            }
        }
    }
}

var mkdirSync = function (path) {
    try {
        fs.mkdirSync(path);
    } catch(e) {
        if ( e.code != 'EEXIST' ) throw e;
    }
}

// Iterate res and src folder
readDir(path.join(src, 'src'), manifest.assets);
readDir(path.join(src, 'res'), manifest.assets);

var destManifest = path.join(dest, 'project.manifest');
var destVersion = path.join(dest, 'version.manifest');

mkdirSync(dest);

fs.writeFile(destManifest, JSON.stringify(manifest), (err) => {
  if (err) throw err;
  console.log('Manifest successfully generated');
});

delete manifest.assets;
delete manifest.searchPaths;
fs.writeFile(destVersion, JSON.stringify(manifest), (err) => {
  if (err) throw err;
  console.log('Version successfully generated');
});

注意:以下几个地方,你可能需要根据自己的需求修改,后面我们会再介绍!
Cocos Creator 热更新_第5张图片

packageUrl:服务器存放资源文件(src res)的路径
remoteManifestUrl:服务器存放资源清单文件(project.manifest)的路径
remoteVersionUrl:服务器存放version.manifest的路径
dest:要生成的manifest文件存放路径
src:项目构建后的资源目录

然后在asset目录添加热更新组件,并挂载热更新脚本HotUpdate.js
如图所示:
Cocos Creator 热更新_第6张图片
下面是详细内容:

//HotUpdate.js
/**
 * 负责热更新逻辑的组件
 */
cc.Class({
    extends: cc.Component,
 
    properties: {
        manifestUrl: cc.RawAsset,  //本地project.manifest资源清单文件
        _updating:false,
        _canRetry:false,
        _storagePath:'',
        label: {
            default: null,
            type: cc.Label
        },

        fileLabel:cc.Label,
        fileProgress:cc.ProgressBar,
        byteLabel:cc.Label,
        byteProgress:cc.ProgressBar
    },

    checkCb: function (event) {
        var self = this;
        cc.log('Code: ' + event.getEventCode());
        switch (event.getEventCode()) {
            case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
                self.label.string = '本地文件丢失';
                cc.log("No local manifest file found, hot update skipped.");
                break;
            case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
            case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
                cc.log("Fail to download manifest file, hot update skipped.");
                self.label.string = '下载远程mainfest文件错误';
                break;
            case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
                cc.log("Already up to date with the latest remote version.");
                self.label.string = '已经是最新版本';
                break;
            case jsb.EventAssetsManager.NEW_VERSION_FOUND:
                cc.log('New version found, please try to update.');
                self.label.string = '有新版本发现,请点击更新';

                this.fileProgress.progress = 0;
                this.byteProgress.progress = 0;
                
                //this.hotUpdate();  暂时去掉自动更新
                break;
            default:
                return;
        }
 
        this._am.setEventCallback(null);
        //this._checkListener = null;
        this._updating = false;
    },
 
    updateCb: function (event) {
        var self = this;
        var needRestart = false;
        var failed = false;
        switch (event.getEventCode()) {
            case jsb.EventAssetsManager.ERROR_NO_LOCAL_MANIFEST:
                cc.log('No local manifest file found, hot update skipped...');
                self.label.string = '本地版本文件丢失,无法更新';
                failed = true;
                break;
            case jsb.EventAssetsManager.UPDATE_PROGRESSION:
                cc.log(event.getPercent());
                cc.log(event.getPercentByFile());
                cc.log(event.getDownloadedFiles() + ' / ' + event.getTotalFiles());
                cc.log(event.getDownloadedBytes() + ' / ' + event.getTotalBytes());
 
                this.byteProgress.progress = event.getPercent();
                this.fileProgress.progress = event.getPercentByFile();

                this.fileLabel.string = event.getDownloadedFiles() + ' / ' + event.getTotalFiles();
                this.byteLabel.string = event.getDownloadedBytes() + ' / ' + event.getTotalBytes();

                var msg = event.getMessage();
                if (msg) {
                    cc.log('Updated file: ' + msg);
                    this.label.string = 'Updated file: ' + msg;  
                }
                break;
            case jsb.EventAssetsManager.ERROR_DOWNLOAD_MANIFEST:
            case jsb.EventAssetsManager.ERROR_PARSE_MANIFEST:
                cc.log('Fail to download manifest file, hot update skipped.');
                self.label.string = '下载远程版本文件失败';
                failed = true;
                break;
            case jsb.EventAssetsManager.ALREADY_UP_TO_DATE:
                cc.log('Already up to date with the latest remote version.');
                self.label.string = '当前为最新版本';
                failed = true;
                break;
            case jsb.EventAssetsManager.UPDATE_FINISHED:
                cc.log('Update finished. ' + event.getMessage());
                self.label.string = '更新完成. ' + event.getMessage();
                needRestart = true;
                break;
            case jsb.EventAssetsManager.UPDATE_FAILED:
                cc.log('Update failed. ' + event.getMessage());
                self.label.string = '更新失败. ' + event.getMessage();
                this._updating = false;
                this._canRetry = true;
                break;
            case jsb.EventAssetsManager.ERROR_UPDATING:
                cc.log('Asset update error: ' + event.getAssetId() + ', ' + event.getMessage());
                self.label.string = '资源更新错误: ' + event.getAssetId() + ', ' + event.getMessage();
                break;
            case jsb.EventAssetsManager.ERROR_DECOMPRESS:
                cc.log(event.getMessage());
                self.label.string = event.getMessage();
                break;
            default:
                break;
        }
 
        if (failed) {
            //cc.eventManager.removeListener(this._updateListener);
            this._am.setEventCallback(null);
            //this._updateListener = null;
            this._updating = false;
        }
 
        if (needRestart) {
            //cc.eventManager.removeListener(this._updateListener);
            this._am.setEventCallback(null);
            //this._updateListener = null;
            // Prepend the manifest's search path
            var searchPaths = jsb.fileUtils.getSearchPaths();
            var newPaths = this._am.getLocalManifest().getSearchPaths();
            cc.log(JSON.stringify(newPaths));
            Array.prototype.unshift(searchPaths, newPaths);
            // This value will be retrieved and appended to the default search path during game startup,
            // please refer to samples/js-tests/main.js for detailed usage.
            // !!! Re-add the search paths in main.js is very important, otherwise, new scripts won't take effect.
            cc.sys.localStorage.setItem('HotUpdateSearchPaths', JSON.stringify(searchPaths));
            jsb.fileUtils.setSearchPaths(searchPaths);
 
            cc.audioEngine.stopAll();
            cc.game.restart();
        }
    },
 
 
    retry: function () {
        if (!this._updating && this._canRetry) {
            this._canRetry = false;
 
            cc.log('Retry failed Assets...');
            this._am.downloadFailedAssets();
        }
    },
 
/*     checkForUpdate: function () {
        cc.log("start checking...");
        if (this._updating) {
            cc.log('Checking or updating ...');
            return;
        }
        if (this._am.getState() === jsb.AssetsManager.State.UNINITED) {
            this._am.loadLocalManifest(this.manifestUrl);
            cc.log(this.manifestUrl);
        }
        if (!this._am.getLocalManifest() || !this._am.getLocalManifest().isLoaded()) {
            cc.log('Failed to load local manifest ...');
            return;
        }
        this._checkListener = new jsb.EventListenerAssetsManager(this._am, this.checkCb.bind(this));
        this._checkListener.setEventCallback(this.checkCb.bind(this));
        //cc.eventManager.addListener(this._checkListener, 1);
        
        this._am.checkUpdate();
        this._updating = true;
    }, */
 
    checkForUpdate:function(){
/*         if (this._updating) {
            cc.log('Checking or updating ...');
            return;
        } */
 
        //cc.log("加载更新配置文件");
 
        //this._am.loadLocalManifest(this.manifestUrl);
        //cc.log(this.manifestUrl);
 
        //this.tipLabel.string = '检查更新';
        //cc.log("start checking...");
 
        //var state = this._am.getState()
 
        //if (state=== jsb.AssetsManager.State.UNINITED) {
 
        // Resolve md5 url
 
        console.log('检查更新')
 
        this._am.setEventCallback(this.checkCb.bind(this));
 
        this._failCount = 0;
 
        this._am.checkUpdate();
 
        this._updating = true;
 
        // }
    },
 
    hotUpdate: function () {
        if (this._am && !this._updating) {
            //this._updateListener = new jsb.EventListenerAssetsManager(this._am, this.updateCb.bind(this));
            this._am.setEventCallback(this.updateCb.bind(this));
            //cc.eventManager.addListener(this._updateListener, 1);
 
            this._am.loadLocalManifest(this.manifestUrl);
 
            this._failCount = 0;
            this._am.update();
            this._updating = true;
        }
    },
 
    show: function () {
        // if (this.updateUI.active === false) {
        //     this.updateUI.active = true;
        // }
    },
 
    changesence:function(){
        cc.log("改变场景");
        cc.director.loadScene("helloworld");
    },
 
    // use this for initialization
    onLoad: function () {
        var self = this;
        // Hot update is only available in Native build
        console.log("onloadUpdate");
        if (!cc.sys.isNative) {
            return;
        }
 
        this._storagePath = ((jsb.fileUtils ? jsb.fileUtils.getWritablePath() : '/') + 'remote-asset');
        cc.log('Storage path for remote asset : ' + this._storagePath);
 
        // Setup your own version compare handler, versionA and B is versions in string
        // if the return value greater than 0, versionA is greater than B,
        // if the return value equals 0, versionA equals to B,
        // if the return value smaller than 0, versionA is smaller than B.
        this.versionCompareHandle = function (versionA, versionB) {
            cc.log("JS Custom Version Compare: version A is " + versionA + ', version B is ' + versionB);
            self.label.string = "Compare: version A is " + versionA + ', version B is ' + versionB;
            var vA = versionA.split('.');
            var vB = versionB.split('.');
            for (var i = 0; i < vA.length; ++i) {
                var a = parseInt(vA[i]);
                var b = parseInt(vB[i] || 0);
                if (a === b) {
                    continue;
                }
                else {
                    return a - b;
                }
            }
            if (vB.length > vA.length) {
                return -1;
            }
            else {
                return 0;
            }
        };
 
        // Init with empty manifest url for testing custom manifest
        this._am = new jsb.AssetsManager('', this._storagePath, this.versionCompareHandle);
        // Setup the verification callback, but we don't have md5 check function yet, so only print some message
        // Return true if the verification passed, otherwise return false
        this._am.setVerifyCallback(function (path, asset) {
            // When asset is compressed, we don't need to check its md5, because zip file have been deleted.
            var compressed = asset.compressed;
            // Retrieve the correct md5 value.
            var expectedMD5 = asset.md5;
            // asset.path is relative path and path is absolute.
            var relativePath = asset.path;
            // The size of asset file, but this value could be absent.
            var size = asset.size;
            if (compressed) {
                cc.log("Verification passed : " + relativePath);
                return true;
            }
            else {
                cc.log("Verification passed : " + relativePath + ' (' + expectedMD5 + ')');
                return true;
            }
        }.bind(this));
 
        cc.log("Hot update is ready, please check or directly update.");
 
        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);
            cc.log("Max concurrent tasks count have been limited to 2");
        }
 
        this._am.loadLocalManifest(this.manifestUrl);
        
        cc.log(this.manifestUrl);
 
        //检查更新
        this.checkUpdate()
    },
 
    checkUpdate:function() {
        console.log('检查更新')
 
        this._am.setEventCallback(this.checkCb.bind(this));
 
        this._failCount = 0;
 
        this._am.checkUpdate();
 
        this._updating = true;
    },
 
    onDestroy: function () {
        if (this._updateListener) {
            //cc.eventManager.removeListener(this._updateListener);
            this._am.setEventCallback(null);
            //this._updateListener = null;
        }
        //if (this._am && !cc.sys.ENABLE_GC_FOR_NATIVE_OBJECTS) {
        //    this._am.release();
        //}
    }
});

3.3.3 生成manifest文件

此时我们选择:项目->构建发布->构建:设置如图所示:
Cocos Creator 热更新_第7张图片

注意: 我们选择的模板是 “default” ,发布路径为 “./build” ,发布后的项目资源相对路径为:build/jsb-default

Cocos Creator 热更新_第8张图片
  构建完成后,很多人说需要修改main.js,参照了官方demo,附加搜索路径设置的逻辑,放在main.js的开头,测试发现,构建项目生成的main.js中,已包含判断,不用修改,也可以成功,如图为证:
Cocos Creator 热更新_第9张图片
如下:

if (window.cc && cc.sys.isNative) {
   var hotUpdateSearchPaths = cc.sys.localStorage.getItem('HotUpdateSearchPaths');
    if (hotUpdateSearchPaths) {
        jsb.fileUtils.setSearchPaths(JSON.parse(hotUpdateSearchPaths));
    }
}

  此时,打开项目->热更新工具(没有的参照博文开头的办法自行添加)Cocos Creator 热更新_第10张图片

  • 第一次打包版本号可以就从1.0.0开始!
  • 资源服务器url 对应服务器搭建的目录我这里是(http://192.168.1.133:8080/hotUpdate_client/)
  • build资源路径就是我们在构建的时候生成的build文件模板

然后执行第一步,生成得到打开如图所示的文件夹:
Cocos Creator 热更新_第11张图片
此时打开看到一个带有版本号的压缩文件,包含如下文件:
Cocos Creator 热更新_第12张图片
  此时执行步骤2的导入manifest,就在复制了两个版本文件到asset目录中。到这里还需要在挂在HotUpdate.js的组件上指定manifest,就是上面第二步生成到项目中asset目录中的project.manifest文件。然后构建、编译,打包apk即可,准备好了包,开始准备下一步的热更资源!

3.3.4 创建热更新文件

  这里我们把changescene按钮设为可见,然后创建一个新的场景,添加一个label,如图:
Cocos Creator 热更新_第13张图片
保存场景为helloworld。
  打包资源,项目->构建发布->构建,打开热更组件修改版本号为1.0.2,生成->部署!至此热更资源准备完成。

4、Demo演示

Cocos Creator 热更新_第14张图片

5、示例下载

  为了方便大家,当然如果有不明白的童鞋也可以在这里点此下载Demo示例!

6、结束语


The End
  好了,今天的分享就到这里,如有不足之处,还望大家及时指正,随时欢迎探讨交流!!!


喜欢的朋友们,请收藏、点赞、评论!您的肯定是我写作的不竭动力!

你可能感兴趣的:(CocosCreator,游戏开发,Cocos,Creator)