在去年 Cocos Creator 2.4 发布的时候,Cocos 老朋友 B 站 up 主 Nowpaper 为我们做了一次版本更新的盘点,对其中 Asset Manager 和 Bundle 部分专门做了重点盘点,并用一个小例子展示能力,讲解了一波应用场景。
视频非常受欢迎,也收到了许多小伙伴更多关于实际使用问题的咨询。Nowpaper 发现很多问题是由于使用方法不对造成的,所以他抽时间专门撰写本篇文章,详细讲解 Bundle 的使用方法,并通过三个实际应用展示。欢迎阅读!此外,Nowpaper 曾做客我们 Cocos 人物志接受专访,感兴趣的同学可以点击阅读噢。
本文将会按照下面的结构:
- 例子展示
- 手撸工程
2.1 同项目 Bundle,动态加载
2.2 跨项目 Bundle,大厅+子游戏
2.3 跨项目 Bundle,代码互调
- 踩坑总结
本文有视频版本,目前还未剪辑完成,敬请期待发布完成。工程项目已发布至 GitHub 地址在文章后面 ╮( ̄▽  ̄)╭
例子
我将完成结果先放在前面,帮助各位可以快速找到自己所需要的部分。
第一个例子,同项目 Bundle,动态加载
![](http://pic2.zhimg.com/v2-650c4be23f6adc0c5a0f8c6ef11661e9_r.jpg)
直接加载内部的 Bundle 包,它是项目本身的某个目录,只不过被设置为 Bundle,里面包含了动画、脚本、场景、资源等。我们可以看到,项目运行起来的时候,被设置为 Bundle 的部分,不会被加载,只有主动加载的时候才会载入,通过 API 也可以读取内部的素材,就像我们现在做的这样,读取了一个 Prefab 创建成 Node,还有就是读取一张图片显示出来。
第二个例子,跨项目 Bundle,大厅+子游戏
![](http://pic4.zhimg.com/v2-a614b6fc8e2bd9f5012e161f5feeb8af_r.jpg)
加载另外一个项目的 Bundle 包,它没有和主项目在一起,而是另外一个 Cocos Creator 项目的一部分,我把生成的素材放在一个资源服务器上,让主项目去载入它,然后运行,通过远程载入创建出来,它是实现动态加载的核心,一会儿后面咱们详细讲解。
第三个例子,跨项目 Bundle,代码互相调方案
![](http://pic2.zhimg.com/v2-2e1c4c9d9b962a5463f9e4b3c8c3343d_r.jpg)
不同 Bundle 内资源或代码的互相调用测试,这个例子主项目为子游戏 Bundle 提供调用接口,我们可以看到除了能调用主项目,也可以通过一些方法被调用,那么怎么做到呢?且听后面分解。
手撸详解
看完例子,那么我们把上面的全部手撸一遍,注意本视频中的所有代码为 TypeScript,使用的是 Cocos Creator 2.4.5。
一、同项目 Bundle,动态加载
首先我们先创建一个 Cocos Creator 项目,这个项目必须使用的 v2.4.0 以上的版本创建,这样才能有 Bundle 的特性,项目名称叫做 BundleLobby。
![](http://pic3.zhimg.com/v2-c2330fd89ab14ec8bf95f39184f59ef6_r.jpg)
打开项目,建立一些目录和场景,随便来一个目录就叫:aaa 吧。这个名字越随便越好,以后就会设置成为 Bundle,在它下面建立一些基本的项目目录,比如 res、src,用来存放你的资源和代码。创建一个主场景叫做 Main,搭建一下基本的 UI 功能,这里做了两个按钮:一个用来读取,一个用来跳转,还有一个进度条,用来表示 Bundle 的读取成功与否。
![](http://pic3.zhimg.com/v2-8041a503b76d933144c2b70b1656e8f2_r.jpg)
现在为 aaa 的目录里增加一些素材,创建一个 aaa 的场景,简单摆放一下,为了方便展示它的复杂性,所以我加入了一个 Spine 动画,并且把 spineboy 制作成了一个 Prefab。
![](http://pic4.zhimg.com/v2-d436f8f87d7809819111b6ad31e3730b_r.jpg)
先实现一下基本的场景跳转,也就是没有把它配置成为 Bundle 的情况下,它只是整个项目的一部分,写一些代码让 Main 和 aaa 两个场景互相跳转,需要完成两个组件代码,我这里为 aaa 场景和 Main 场景各自加入了一个组件 Script,并且为各自的场景添加了场景跳转代码:
onClickSceneTo(){
cc.director.loadScene('aaa');
}
![](http://pic3.zhimg.com/v2-25fcb206ef9d6d2470c4869a93f1ce36_r.jpg)
这种跳转是最普通的情况,当把 aaa 这个目录给配置成为 Bundle 之后,就不一样了。
![](http://pic2.zhimg.com/v2-1d794ffe74d1ae186b196107f24d60a9_r.jpg)
此时跳转到 aaa 的按钮已经不管用了,我们看调试信息里已经给出了报错信息,它找不到名为 aaa 的场景资源。
![](http://pic4.zhimg.com/v2-8ed6b78f062467855ec2c44b95d02b9b_r.jpg)
这是因为 Bundle 资源不会在启动的时候加载,而是需要用 assetManager 的 loadBundle,所以我们为读取按钮添加一个 Click 事件,并且实现如下代码:
onClickLoad(){ cc.assetManager.loadBundle('aaa',(err,bundle)=>{ if(!err){ this.progressBar.progress = 1; } });}运行项目后,先点击读取,读取成功之后再点击跳转 aaa,就会跳转到对应的场景当中,不会报错。既然基本的场景已经实现,那么能否更进一步,从 Bundle 包里面读取资源呢?我们需要对 Main 场景进行改造和调整一下,并且组件脚本中的对应处理也需要调整,在这里,我使用了两个空 Node 来显示读取出的 Prefab 和图片,它们分别叫 target1 和 target2,场景结构大概是这个样子:
![](http://pic2.zhimg.com/v2-e83506a91476de3c12e3387447dda4b1_r.jpg)
MainScript.ts 的代码如下:
const {ccclass, property} = cc._decorator;@ccclassexport default class MainScript1 extends cc.Component { @property(cc.ProgressBar) progressBar:cc.ProgressBar = null; @property(cc.Node) target1:cc.Node = null; @property(cc.Node) target2:cc.Node = null; private _bundle:cc.AssetManager.Bundle; start () { this.progressBar.progress = 0; this.target1.active = this.target2.active = false; } onClickLoad(){ cc.assetManager.loadBundle('aaa',(err,bundle)=>{ if(!err){ this._bundle = bundle; this.progressBar.progress = 1; this.target1.active = this.target2.active = true; } }); } onClickSceneTo(){ cc.director.loadScene('aaa'); } onClickLoadPrefab(s:cc.Event.EventTouch){ this._bundle.load('res/spineboy',cc.Prefab,(err,asset:cc.Prefab)=>{ if(!err){ this.target1.addChild(cc.instantiate(asset)); s.currentTarget.active = false; } }); } onClickLoadSpriteFrame(s:cc.Event.EventTouch){ this._bundle.load('res/button',cc.Texture2D,(err,tex:cc.Texture2D)=>{ if(!err){ s.currentTarget.active = false; const node = new cc.Node(); node.addComponent(cc.Sprite).spriteFrame = new cc.SpriteFrame(tex); this.target2.addChild(node); } }); }}最终结果:
![](http://pic4.zhimg.com/v2-98eaae5e9152920e081467fa0eddbbb7_r.jpg)
在第一个例子中,通过把一个目录设置成为 Bundle,实现动态加载资源内容的能力,这也是 2.4 版本最让开发者们兴奋的更新,它能大大地加强游戏的载入体验。而它的强大不止于此,官方文档中明确说明,它可以把其他项目的 Bundle 给载入进来,下面第二个例子我们就来试试这个功能。
二、跨项目 Bundle,大厅+子游戏
现在计划把这个项目当成大厅场景,通过 loadBundle 完成对子游戏的 Bundle 加载,对 BundleLobby 的项目改造一下。为了方便区分和展示,先把 Main 给重命名为 Main1,以及 MainScript 改名为 MainScript1,新建一个场景叫 Main2,然后重新建立一个组件脚本,名字叫 MainScript2,把一些代码从 1 复制到 2 当中。这是为了避免重写,先不用着急改代码,等子项目完成后回来再说,同样的,Main2 场景当中的一些界面元素,直接复用即可,只保留一个 target 的 Node 当容器,为两个按钮指定 Click 事件,作为大厅的项目已经准备好了。
![](http://pic4.zhimg.com/v2-862846aa1d6f0b7c32a51b94da3826cb_r.jpg)
构建子游戏项目
关掉主项目,下一步要完成子项目,新建一个 Games 的 Cocos Creator 2.4 以上版本的项目,然后建立一个目录,名字就叫 Game1 吧,未来还得有 Game2。我将会选择一个相对比较有趣的内容用于展示,内容尽量和大厅工程不一样,在后面的展示中,将不止是场景跳转,还有从内部创建,从而让它更像是一个动态载入进来的小游戏,这个部分我作了一些简单的开发,具体细节略过,它是一个一直在推进的连续场景,看起来不错,也很酷。
![](http://pic3.zhimg.com/v2-0d8def36ba94b0daaec44444ecdc960e_b.gif)
我们把游戏内容的舞台部分制作成为 Prefab,当然了你要注意把逻辑脚本挂载在 Prefab 这个节点上,不然的话,嘿嘿嘿......
![](http://pic3.zhimg.com/v2-f87d161c214fefbbc11ed760115e147a_r.jpg)
现在一个最简单的子游戏创建好了,复杂的内容,咱们放在第三个例子中详细说,现在直接 Build 一下,点击主菜单中的“项目 -> 构建发布”进行构建,目标平台为了方便测试先选择 Web Mobile,稍加等待之后,完成。
![](http://pic3.zhimg.com/v2-c7246cfe336bcfc091909d085289e5be_r.jpg)
构建完成后,进入到项目目录,找到 \build\web-mobile\assets 下面:
![](http://pic3.zhimg.com/v2-814c135815b83a424d23139c4be5de9a_r.jpg)
这里有个 Game1,它就是 Bundle 包了,其他的都不需要,咱们只需要这个部分。
资源服务器
但是,跨项目读取必须通过远程方式,所以你需要用一个小服务器来当资源服务器,在我的项目中提供了一个小网站 Node 项目,来实现对它的远程读取,方便主项目远程加载,这个工程和大厅、子游戏放在了同一个地方,名字叫 RemoteHttpServer 的目录。你也可以用别的方式,取决于您的喜好。把 Build 下面的 asset 下的 Game1 移动或复制到这个资源 server 下,确保能够通过网络能够访问到它。
![](http://pic4.zhimg.com/v2-119a327473e92041c2f63298e1105367_r.jpg)
切换 Cocos Creator 的项目到 BundleLobby 下,要对远程的 Bundle 进行加载了,我们打开代码 MainScript2.ts,使用第一个例子中的同样方法 loadBundle,但是需要改成远程资源 URL。在我的例子中,远程 Bundle 在 127.0.0.1:8080/Game1 当中,所以代码读取需要修改,同时,由于子游戏在 Build 的时候为文件加了 MD5 标记,所以直接打开是不行的,需要借助可选参数的 version 字段来解决这个问题,因此最终的代码如下:
const {ccclass, property} = cc._decorator;
@ccclassexport default class MainScript2 extends cc.Component { @property(cc.ProgressBar) progressBar:cc.ProgressBar = null; @property(cc.Node) target1:cc.Node = null;
private _bundle:cc.AssetManager.Bundle; start () { this.progressBar.progress = 0; this.target1.active = false; } onClickLoad(){ const options = { version:"08f26", onFileProgress:(n,t)=>{ this.progressBar.progress = n / t; } } cc.assetManager.loadBundle('http://127.0.0.1:8080/Game1', options, (err,bundle)=>{ if(!err){ this._bundle = bundle; this.target1.active = true; } }); } onClickSceneTo(e:cc.Event.EventTouch){ e.currentTarget.active = false; this._bundle.load("prefab/Game1Stage",cc.Prefab,(err,asset:cc.Prefab)=>{ if(!err){ this.target1.addChild(cc.instantiate(asset)); } }); }}
![](http://pic2.zhimg.com/v2-074a808a7b044e643c37061bf9a54711_r.jpg)
最终结果如下:
![](http://pic4.zhimg.com/v2-ed5bba351ea5a22763110f3d5b144abb_r.jpg)
到目前为之,第二个例子已经结束了,虽然已经完成了远程包体的载入流程,但是真正实现一个大厅加子游戏,或者动态功能模块的话,似乎差了一些什么。这种项目需求是要求子包和大厅之间的代码调用,或者互相通讯,下面我们开始尝试用第三个例子来解决这个问题。
三、跨项目 Bundle,代码互调
在这之前,我们可能需要了解和梳理 Bundle 的机制,在官方文档中描述 Asset Bundle 的构造提到,内容分为代码和资源两个部分,资源的入口是 config.json,代码入口为 index.js。按照我的测试结果来看,Bundle 在下载成功后,会立即将 index.js 中的代码加入到主包中,打开这个文件看看就能猜到个大概。因此,我们只需要设计大厅接口,在子游戏中实现同样的接口,最后不把它们 Build 到 Bundle 即可。设计思路大致为主包大厅和 Bundle 子游戏内创建的控制组件,并开发通用接口,互相之间通过这种方法调用,为了开发的便捷性,可以为子游戏中创建虚拟的接口类,实现独立开发的能力。在大厅项目中,我们新建一个场景 Main3 和 MainScript3 组件脚本,并且按照之前 Main2 样子搭建,有一些部分还得需要结合子游戏修改,先放在这里,现在用 VS Code 在 src 目录中,实现一个接口文件,就叫 IMainController 吧,我这里就简单实现一个输出文本接口:
export interface IMainController { outString(str: string): void;}在实际项目中,接口可能要比这个复杂的多,主要看你的项目需求,现在我们再建立一个 MainController 的组件脚本。为了区分,我加上了 Script 为后缀,实现基础的组件类代码,并且实现 IMainController 的接口,回到 Main3 的场景中,为 Canvas 挂上刚刚的组件,在场景中创建并且指定一个 cc.Label 作为输出组件,现在主场景已经准备好了。
import { IMainController } from "./IMainController";
const {ccclass, property} = cc._decorator;
@ccclassexport default class MainControllerScript extends cc.Component implements IMainController { @property(cc.Label) outLabel:cc.Label = null; outString(str: string): void { this.outLabel.string = str; } }下一步开发子游戏,关闭大厅项目,打开子游戏项目,为了避免和之前的重复,新建一个 Game2 文件夹,放进去了一个龙骨制作的小熊,现在我将实现点击一下舞台区域,就变化一次动作,并且将动作名字输出给大厅。布局好基本的场景元素,创建 Game2Logic 的组件脚本,先实现点击舞台变化动作的功能,这些代码并不复杂,所以就暂时略过,参考后面的完整代码。下一步就是实现前面的 MainController 接口,由于子游戏会运行在大厅环境中,并且可能会有很多游戏使用,所以它可以作为公共代码存在,完全没有必要将它也输出,现在我们建立一下相关的代码文件。新建 common 目录:
![](http://pic3.zhimg.com/v2-0b4b77586bc3eaac0e7096303ef0ac16_r.jpg)
新建 IMainController.d.ts 文件,对照大厅实现接口代码,借助一下 Window 的公共接口声明来达到调试类应用的目的,在这里我又弄了一个调试用的 DebugMainController。IMainController.d.ts
declare interface IMainController { outString(str: string): void;}declare interface Window{ debugMainCtrl:IMainController;}DebugMainController.ts
class DebugMainController implements IMainController{ outString(str: string): void { console.warn('METHod is debug,str is ' + str); }}if(!window.debugMainCtrl){ window.debugMainCtrl = new DebugMainController();}这个做法是为了当不在大厅的时候,本地调试的功能可以来测试真实的反馈。现在我们到 Game2Logic 的组件脚本中,先把名字给提取出来作为变量,然后我们通过获取当前场景的根节点进行组件遍历查找,getComponentInChildren 获得大厅的组件脚本。还记得大厅组件已经接口实现了吧,如果找到就用它,如果没有找到就使用调试类,然后作输出。
MainScript3.ts
const {ccclass, property} = cc._decorator;@ccclassexport default class Game2Logic extends cc.Component { @property(dragonBones.ArmatureDisplay) actor:dragonBones.ArmatureDisplay = null; start () { this.node.on(cc.Node.EventType.TOUCH_END,this.onTouchEnd,this); this.node.on("ActorAnimationPlay",this.onActorAnimationPlay,this); } onDestroy(){ this.node.off(cc.Node.EventType.TOUCH_END,this.onTouchEnd,this); this.node.off("ActorAnimationPlay",this.onActorAnimationPlay,this); } private onActorAnimationPlay(aniname:string){ this.actor.playAnimation(aniname,-1); } private index = 0; private onTouchEnd(){ const arr = this.actor.getAnimationNames("ubbie"); const aniName = arr[this.index % arr.length]; this.node.emit("ActorAnimationPlay",aniName); this.index += 1; let mainCtrl:IMainController = cc.director.getScene().getComponentInChildren('MainControllerScript'); if(!mainCtrl){ mainCtrl = window.debugMainCtrl; } mainCtrl.outString(aniName); }}现在调试一下看看效果,可以看到它确实调用的是本地调试类的方法。
![](http://pic2.zhimg.com/v2-a8eccde1989e90098e37022acadaa909_r.jpg)
在上面的代码中,加入了一个动画播放的事件监听,事件名为 ActorAnimationPlay,这个监听主要是用来从大厅项目向子游戏通讯用的,具体细节后面详述。下一步设置子游戏包,详细步骤参考例子二,并且将舞台做成一个 Prefab,然后再 Build 一下,在 Build 目录下 asset 中复制或者移动 Game2 到 HttpServer 项目中,刷新一下页面看到有了 Game2 即可,记录一下名字中间的版本编号,更新到对应的代码中。运行一下基本上可以能够得到如例子二一样的结果,只不过目前只是单向的,即子游戏向大厅调用,反过来也是一样,主场景也能调用子游戏代码,用事件是一个很好的办法,因此我上面加入了 ActorAnimationPlay 这个事件名的监听,用这个事件来实现控制子游戏的小熊动画,具体代码请参看后续代码。不明白的,可以看代码以及官方文档当中有关事件的部分,子游戏也可以用事件的方式来处理向大厅通讯。但是按照我的经验来看,写接口调用的方式会更加严谨,也比较容易排查错误,有时候甚至还得用上 Promise 异步,如果真的是需要用上事件,也最好封装一下。因此,最终 MainScript3.ts 的代码如下:
const {ccclass, property} = cc._decorator;
@ccclassexport default class MainScript3 extends cc.Component { @property(cc.ProgressBar) progressBar:cc.ProgressBar = null; @property(cc.Node) target1:cc.Node = null;
private _bundle:cc.AssetManager.Bundle; start () { this.progressBar.progress = 0; this.target1.active = false; } onClickLoad(){ const options = { version:"78969", onFileProgress:(n,t)=>{ this.progressBar.progress = n / t; } } cc.assetManager.loadBundle('http://127.0.0.1:8080/Game2', options, (err,bundle)=>{ if(!err){ this._bundle = bundle; this.target1.active = true; } }); } onClickSceneTo(e:cc.Event.EventTouch){ // cc.director.loadScene('Game1'); e.currentTarget.active = false; this._bundle.load("prefab/Game2Stage",cc.Prefab,(err,asset:cc.Prefab)=>{ if(!err){ this.target1.addChild(this._gameStage = cc.instantiate(asset)); } }); } private _gameStage:cc.Node; onClickActonWalk(){ this._gameStage.emit("ActorAnimationPlay","walk"); } onClickActonStand(){ this._gameStage.emit("ActorAnimationPlay","stand"); }}Main3 的场景结构大致为这样的:
![](http://pic3.zhimg.com/v2-50af7aeaaef26d503e6ab5a55cdfaeca_r.jpg)
可能有一些细节需要再作修正,不过我感觉已经很详细了,项目源码和视频已经准备好了,可以进一步了解。
![](http://pic3.zhimg.com/v2-2522cdedcfd142a1df486b7ef3220e6e_r.jpg)
注意事项
- 第一是各个 Bundle 中的代码中不要有一样的类名,或者全局变量名,这样的代码会在读取 Bundle 后直接报重名错误。
- 第二是 Bundle 包代码尽量不要互相引用。如果你的业务需求必须这样做,应该用设置载入优先级解决。但只能解决在同一个项目中的 Bundle 读取,跨项目使用还是得自己控制先后顺序。建议可以把通用代码整合成一个包,在开始的时候读下来。
- 第三是跨 Bundle 的资源尽量互相保持独立,对象管理只是一方面,关键是有一些不可预期的奇怪错误,往往会从缓存和释放的地方出问题。
总结
Bundle 的方式是一个好东西,游戏行业总是想办法尽可能缩短用户进入游戏以及游戏加载内容的时长,从而降低因等待造成的流失成本。Cocos Creator 的 Bundle 包,不仅可以应用到大厅和子游戏模式,还比较适用于推进式关卡、人物角色形象包、教育用的图书绘本等等,相信有了上面的例子,你对 Bundle 的使用一定有更进一步的理解。文章内容就到这里了,这个手撸 Bundle 还有视频版本,配有语音讲解,项目工程已经放在 GitHub 当中。项目工程地址:https://github.com/Nowpaper/CreatorBundleTest
<hr/>以上就是本期教程啦,感谢 Nowpaper 的倾情分享,他的讲解视频在爆肝剪辑中,如果大家对他的文章或视频感兴趣,欢迎前往他的 B 站首页了解更多噢~
![](http://pic1.zhimg.com/v2-87cd05e35cd9b5806ba7baff3f3c4f14_r.jpg) |