Cocos游戏开发|使用zip压缩减少web请求,加速资源加载
共 27967字,需浏览 56分钟
·
2024-04-11 06:48
点击上方码不了一点+关注和★ 星标
1 引言
Cocos Creator 3.8 有提供 zip 格式的 bundle,但不支持 web 平台。今天就给大家分享一下如何使用 Zip 加速 Cocos Creator 在 Web 平台的资源加载。
前段时间使用 Cocos Creator 3.8 做一个云展厅项目,要求在 Web 平台上线(微信 H5&浏览器)。
这个云展厅项目使用 gltf 模型,gltf 模型中有拆分很多 Mesh 和材质。
而在 Cocos 中 gltf 会被拆分解析为 Cocos 资产,发布 Web 后加载一个这种大 gltf 就可能有几百个 request,明明网速飞起,但是加载还是很慢,因为此时项目中加载速度的瓶颈已经不是网速,而是 request 的数量太多了。
2 产生的原因&如何解决
为什么有这么多 request
- 新建一个项目,将下图这个gltf(编辑注:想要这个资源的朋友请查看 码不了一点 公众号)直接放在
resources
文件夹下,方便在demo进行预加载,这个gltf中共28材质,32个Mesh和一些骨骼&贴图
- 创建Start场景用于预加载资源,和Game场景用于展示模型
- 创建Start脚本,对资源做一个简单的预加载,加载完成后进入Game场景
import {_decorator, Component, director, Label, settings, ProgressBar, resources, assetManager, Settings} from 'cc';
const {ccclass, property} = _decorator;
@ccclass('Start')
export class Start extends Component {
@property(ProgressBar)
progressBar: ProgressBar;
@property(Label)
barLab: Label = null;
async start() {
// 直接加载resources根目录
await this.preload([
{
path: "/",
type: "dir",
},
]);
director.loadScene("Game");
}
/**
* 预加载资源
*/
preload = (pkg) => {
return new Promise<void>((resolve, reject) => {
const pathArr = [];
pkg.forEach((asset) => {
if (typeof asset == "string") {
return pathArr.push(asset);
}
switch (asset.type) {
case "dir":
resources.getDirWithPath(asset.path).forEach((v) => pathArr.push(v.path));
break;
default:
pathArr.push(asset.path);
}
});
resources.load(
pathArr,
(finish: number, total: number, _) => {
const pro = finish / total;
if (pro < this.progressBar.progress) {
return;
}
this.progressBar.progress = pro;
this.barLab.string = `正在加载中 ${(pro * 100).toFixed(0)}%`;
},
async (error) => {
if (error) console.error(error);
resolve();
}
);
});
}
}
- 运行游戏,在本地web环境查看Network,request数量为379,和这个gltf相关的就有216个,打包发布至Web环境,选择合并所有Json,加载该gltf总共用了35次request。
- 明明只有一个gltf却用了35次request来加载。
- 原因在于Cocos将gltf资源转换成了Cocos资产,将Mesh,材质等拆解了出来,每个资源除了资源本身外还会有一个记录属性依赖的Json文件
如何解决
- 将整个bundle打包,比如打包成zip文件,进入游戏先加载需要的bundle的zip文件一次下载并且解压,之后需要资源直接从解压完的文件里取。
3 Zip 和 JsZip 的使用
Zip
不必多言,想必大家都知道
JsZip 的使用
不必重复了,直接上npm平台参考文档吧
jszip[1]https://www.npmjs.com/package/jszip
文档看起来肯定很抽象,不如直接跟着下面的步骤实操。
4 探索 Cocos 加载资源的奥秘
- 查看Network,可以发现Cocos下载资源都会通过一个
download-file.ts
文件,移动鼠标到download-file.ts
上就可以看到他的调用栈,其中主要是download-file.ts
和downloader.ts
也就是资源下载管线的一部分。那么我们直接打开源码进入到这里
- 在代码中我们可以看到,大部分文件的下载都是通过
downloadFile
方法进行下载的,这个方法就是刚才的download-file.ts
中的方法,该方法使用XMLHttpRequest
下载文件
- 既然我们已经知道在Cocos中,大部分资源的下载都依赖于
XMLHttpRequest
,那么我们可以想办法拦截它,重定向到我们解包的zip包就可以避免发起它真实的网络请求从而消耗大量时间了。
5 如何加载自己的 Zip 包
装载自己的 zip
- 浅写一个
ZipLoader
并作为单例使用 - 偷个懒,这里直接使用 Cocos 内置 API 加载远程文件吧,注意这个 API 已经弃用,未来可能删除
- 我们直接不管容错,把 demo 跑通再说
- 并使用
Promise
配合外部async/await
来简化控制流。
import {assetManager} from "cc";
import JSZIP from "jszip";
export default class ZipLoader {
static _ins: ZipLoader;
static get ins() {
if (!this._ins) {
this._ins = new ZipLoader();
}
return this._ins;
}
/**
* 下载单个zip文件为buffer
* 为什么这里带上后缀名后面会讲到,是为了方面自动化
* @param path 文件路径
* @returns zip的buffer
*/
downloadZip(path: string) {
return new Promise((resolve) => {
assetManager.downloader.downloadFile(
path + '.zip',
{xhrResponseType: "arraybuffer"},
null,
(err, data) => {
resolve(data);
}
);
});
}
/**
* 解析加载Zip文件
* @param path 文件路径
*/
async loadZip(path: string) {
// 这里没用npm包的形式而是采用umd形式的js包
const jsZip = window["JSZip"]();
// 下载
const zipBuffer = await this.downloadZip(path);
// 解压
const zipFile = await jsZip.loadAsync(zipBuffer);
}
}
- 在之前的
Start.ts
中添加代码 - 注意以下几点
- 作者这里有自动化压缩上传插件,会自动修改server字段,server就是项目发布的根目录带协议和域名,例如
https://xxx.com/cc_project/version/
- 作者这里会将需要zip加载的包注入到window上
- 注入的js类似
window["zipBundle"] = ["internal", "main", "resources"];
- 作者这里所有bundle全都在远程所以只加载remote中的文件就行了且zip文件和bundle文件夹在同一目录下
/* ... */
@ccclass('Start')
export class Start extends Component {
/* ... */
async start() {
// 作者这里有自动化压缩上传插件,会自动修改server字段
// 并且会将需要zip加载的包注入到window上
// 注入的js类似与下面这行
// window["zipBundle"] = ["internal", "main", "resources"];
const remoteUrl = settings.querySettings(Settings.Category.ASSETS, "server");
const zipBundle = window["zipBundle"] || [];
// 作者这里所有bundle全都是远程bundle所以只加载remote中的文件就行了
// 且zip文件和bundle文件夹在同一目录下
const loadZipPs = zipBundle.map((name: string) => {
return ZipLoader.ins.loadZip(`${remoteUrl}remote/${name}`);
});
// 先等zip加载完
await Promise.all(loadZipPs);
// 直接加载resources根目录
await this.preload([
{
path: "/",
type: "dir",
},
]);
director.loadScene("Game");
}
/* ... */
}
不自定义引擎,拦截 Cocos 加载
查阅了 Cocos 的文档没有很好的批量实现这个需求的方式,又因为 Cocos 引擎更新比较频繁,我个人又喜欢多用新引擎新功能,所以我选择不自定义引擎,直接采用拦截 Cocos 加载的方法实现将加载资源替换到自己的zip包。
通过阅读源码我们已经知道除了图片资源,其他资源都是通过 XMLHttpRequest
来加载的,那么很简单,我们直接拦截 XMLHttpRequest
就行了。
那么你问我怎么才能拦截一个浏览器 Native
对象,这可是 Js,Js 无所不能!
拦截open和send
- 不必多说,按下面这种方法就可拦截一个
XMLHttpRequest
来做一些操作
// 拦截open
const oldOpen = XMLHttpRequest.prototype.open;
// @ts-ignore
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
return oldOpen.apply(this, arguments);
}
// 拦截send
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = async function (data) {
return oldSend.apply(this, arguments);
}
- 添加解析zip的代码,将zip中的代码解析到完整的路径上
/* ... */;
const ZipCache = new Map<string, any>();
export default class ZipLoader {
/* ... */
constructor() {
this.init();
}
/* ... */
/**
* 解析加载Zip文件
* @param path 文件路径
*/
async loadZip(path: string) {
const jsZip = JSZIP();
const zipBuffer = await this.downloadZip(path);
const zipFile = await jsZip.loadAsync(zipBuffer);
// 解析zip文件,将路径,bundle名,文件名拼起来,直接存在一个map里吧
zipFile.forEach((v, t) => {
if (t.dir) return;
ZipCache.set(path + "/" + v, t);
});
}
init() {
// 拦截open
const oldOpen = XMLHttpRequest.prototype.open;
// @ts-ignore
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
return oldOpen.apply(this, arguments);
}
// 拦截send
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = async function (data) {
return oldSend.apply(this, arguments);
}
}
- 在拦截的open和send中取消网络请求,直接定向到我们缓存在zip资源,由于我们不能直接修改xhr的response,因为他是只读属性,所以我们要借助
Object.getOwnPropertyDescriptor
和Object.defineProperty
,话不多说,直接看代码把 - 在测试过程中发现Cocos可能会请求多次同一个json,且可能修改解析后的对象,所以我暂时给json类型的资源加了一个id,可以让他每次都重新获取zip中的内容并解析
/* ... */
const ZipCache = new Map<string, any>();
const ResCache = new Map<string, any>();
let jsonId = 0; // 兼容json
export default class ZipLoader {
/* ... */
constructor() {
this.init();
}
/* ... */
init() {
const accessor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'response');
Object.defineProperty(XMLHttpRequest.prototype, 'response', {
get: function () {
if (this.zipCacheUrl) {
return ResCache.get(this.zipCacheUrl);
}
return accessor.get.call(this);
},
set: function (str) {
// console.log('set responseText: %s', str);
// return accessor.set.call(this, str);
},
configurable: true
});
// 拦截open
const oldOpen = XMLHttpRequest.prototype.open;
// @ts-ignore
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
// 有这个资源就记录下来
if (ZipCache.has(url as string)) {
this.zipCacheUrl = url;
}
return oldOpen.apply(this, arguments);
}
// 拦截send
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = async function (data) {
if (this.zipCacheUrl) {
// 有缓存就不解析了
if (!ResCache.has(this.zipCacheUrl)) {
const cache = ZipCache.get(this.zipCacheUrl);
if (this.responseType === "json") {
// 兼容json
this.zipCacheUrl += jsonId++;
const text = await cache.async("text");
ResCache.set(this.zipCacheUrl, JSON.parse(text));
} else {
// 直接拿cocos设置的responseType给zip解析
const res = await cache.async(this.responseType);
ResCache.set(this.zipCacheUrl, res);
}
}
// 解析完了直接调用onload,并且不再发起真实的网络请求
this.onload();
return;
}
return oldSend.apply(this, arguments);
}
}
}
- 打包项目手动压缩bundle文件夹上传进行测试,可以看到我们下载了三个zip资源,大量的json和bin文件夹都没有下载,和我们测试gltf相关的文件,仅有两张贴图而已,而且能正常进入Game场景,说明我们刚才写的代码是有效的,加载该gltf文件的request次数从35次降到了3次
6 发布自动化
- 编写 Cocos 插件打包自动压缩 bundle 为 zip
- 这个就比较简单了,新建一个构建插件
- 编写一个zip.ts,文件内容如下
import * as fs from "fs";
import JSZIP from "jszip";
//读取目录及文件
function readDir(zip, nowPath) {
const files = fs.readdirSync(nowPath);
files.forEach(function (fileName, index) {//遍历检测目录中的文件
console.log(fileName, index);//打印当前读取的文件名
const fillPath = nowPath + "/" + fileName;
const file = fs.statSync(fillPath);//获取一个文件的属性
if (file.isDirectory()) {//如果是目录的话,继续查询
const dirlist = zip.folder(fileName);//压缩对象中生成该目录
readDir(dirlist, fillPath);//重新检索目录文件
} else {
// 排除图片文件,下面会讲到
if (fileName.endsWith(".png") || fileName.endsWith(".jpg")) {
return;
}
zip.file(fileName, fs.readFileSync(fillPath));//压缩目录添加文件
}
});
}
//开始压缩文件
export function zipDir(name, dir, dist) {
return new Promise<void>((resolve, reject) => {
const zip = new JSZIP();
readDir(zip, dir);
zip.generateAsync({//设置压缩格式,开始打包
type: "nodebuffer",//nodejs用
compression: "DEFLATE",//压缩算法
compressionOptions: {//压缩级别
level: 9
}
}).then(function (content) {
fs.writeFileSync(`${dist}/${name}.zip`, content, "utf-8");
resolve();
});
});
}
- 在hooks中
onAfterBuild
中编写压缩脚本的内容,压缩脚本的内容在其他操作(如压缩图片,混淆代码,修改js等)都做完之后,且在上传资源前,且只针对web模板大致内容如下
export const onAfterBuild: BuildHook.onAfterBuild = async function (options: ITaskOptions, result: IBuildResult) {
// 非需要的模板不进行这个操作
if (options.platform !== "web-mobile") return;
// 修改脚本,混淆代码,压缩资源等
/ ... /
if (fs.existsSync(result.dest + "/remote")) {
await Promise.all(
fs.readdirSync(result.dest + "/remote")
.map((dirName) => {
return zipDir(dirName, result.dest + "/remote/" + dirName, result.dest + "/remote");
})
)
}
/ ... /
// 上传
};
7 做一个简单的优化
通过前面阅读源码和 Network
中看到,Cocos 加载图片的方式不是通过 XMLHttpRequest
,而是通过创建 Image
对象的方式。
此片文章的内容暂时不研究如何将加载图片也替换到使用自己的 Zip,因为我自己也还没做。
所以我选择直接在打包 zip 的时候过滤 png/jpg 文件来降低 zip 包的大小,仅在 zip 中打包需要的文件即可。
8 关注我
欢迎大家关注我的公众号,只搞实用的。
参考资料 [1]jszip: https://www.npmjs.com/package/jszip