Cocos游戲開發(fā)|使用zip壓縮減少web請(qǐng)求,加速資源加載
點(diǎn)擊上方碼不了一點(diǎn)+關(guān)注和★ 星標(biāo)
1 引言
Cocos Creator 3.8 有提供 zip 格式的 bundle,但不支持 web 平臺(tái)。今天就給大家分享一下如何使用 Zip 加速 Cocos Creator 在 Web 平臺(tái)的資源加載。
前段時(shí)間使用 Cocos Creator 3.8 做一個(gè)云展廳項(xiàng)目,要求在 Web 平臺(tái)上線(微信 H5&瀏覽器)。
這個(gè)云展廳項(xiàng)目使用 gltf 模型,gltf 模型中有拆分很多 Mesh 和材質(zhì)。
而在 Cocos 中 gltf 會(huì)被拆分解析為 Cocos 資產(chǎn),發(fā)布 Web 后加載一個(gè)這種大 gltf 就可能有幾百個(gè) request,明明網(wǎng)速飛起,但是加載還是很慢,因?yàn)榇藭r(shí)項(xiàng)目中加載速度的瓶頸已經(jīng)不是網(wǎng)速,而是 request 的數(shù)量太多了。
2 產(chǎn)生的原因&如何解決
為什么有這么多 request
- 新建一個(gè)項(xiàng)目,將下圖這個(gè)gltf(編輯注:想要這個(gè)資源的朋友請(qǐng)查看 碼不了一點(diǎn) 公眾號(hào))直接放在
resources文件夾下,方便在demo進(jìn)行預(yù)加載,這個(gè)gltf中共28材質(zhì),32個(gè)Mesh和一些骨骼&貼圖

- 創(chuàng)建Start場(chǎng)景用于預(yù)加載資源,和Game場(chǎng)景用于展示模型

- 創(chuàng)建Start腳本,對(duì)資源做一個(gè)簡(jiǎn)單的預(yù)加載,加載完成后進(jìn)入Game場(chǎng)景
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");
}
/**
* 預(yù)加載資源
*/
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();
}
);
});
}
}
- 運(yùn)行游戲,在本地web環(huán)境查看Network,request數(shù)量為379,和這個(gè)gltf相關(guān)的就有216個(gè),打包發(fā)布至Web環(huán)境,選擇合并所有Json,加載該gltf總共用了35次request。
本地 Web 預(yù)覽
打包合并后
- 明明只有一個(gè)gltf卻用了35次request來加載。
- 原因在于Cocos將gltf資源轉(zhuǎn)換成了Cocos資產(chǎn),將Mesh,材質(zhì)等拆解了出來,每個(gè)資源除了資源本身外還會(huì)有一個(gè)記錄屬性依賴的Json文件

如何解決
- 將整個(gè)bundle打包,比如打包成zip文件,進(jìn)入游戲先加載需要的bundle的zip文件一次下載并且解壓,之后需要資源直接從解壓完的文件里取。
3 Zip 和 JsZip 的使用
Zip
不必多言,想必大家都知道
JsZip 的使用
不必重復(fù)了,直接上npm平臺(tái)參考文檔吧
jszip[1]https://www.npmjs.com/package/jszip
文檔看起來肯定很抽象,不如直接跟著下面的步驟實(shí)操。
4 探索 Cocos 加載資源的奧秘
- 查看Network,可以發(fā)現(xiàn)Cocos下載資源都會(huì)通過一個(gè)
download-file.ts文件,移動(dòng)鼠標(biāo)到download-file.ts上就可以看到他的調(diào)用棧,其中主要是download-file.ts和downloader.ts也就是資源下載管線的一部分。那么我們直接打開源碼進(jìn)入到這里


- 在代碼中我們可以看到,大部分文件的下載都是通過
downloadFile方法進(jìn)行下載的,這個(gè)方法就是剛才的download-file.ts中的方法,該方法使用XMLHttpRequest下載文件


- 既然我們已經(jīng)知道在Cocos中,大部分資源的下載都依賴于
XMLHttpRequest,那么我們可以想辦法攔截它,重定向到我們解包的zip包就可以避免發(fā)起它真實(shí)的網(wǎng)絡(luò)請(qǐng)求從而消耗大量時(shí)間了。
5 如何加載自己的 Zip 包
裝載自己的 zip
- 淺寫一個(gè)
ZipLoader并作為單例使用 - 偷個(gè)懶,這里直接使用 Cocos 內(nèi)置 API 加載遠(yuǎn)程文件吧,注意這個(gè) API 已經(jīng)棄用,未來可能刪除
- 我們直接不管容錯(cuò),把 demo 跑通再說
- 并使用
Promise配合外部async/await來簡(jiǎn)化控制流。
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;
}
/**
* 下載單個(gè)zip文件為buffer
* 為什么這里帶上后綴名后面會(huì)講到,是為了方面自動(dòng)化
* @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中添加代碼 - 注意以下幾點(diǎn)
- 作者這里有自動(dòng)化壓縮上傳插件,會(huì)自動(dòng)修改server字段,server就是項(xiàng)目發(fā)布的根目錄帶協(xié)議和域名,例如
https://xxx.com/cc_project/version/ - 作者這里會(huì)將需要zip加載的包注入到window上
- 注入的js類似
window["zipBundle"] = ["internal", "main", "resources"]; - 作者這里所有bundle全都在遠(yuǎn)程所以只加載remote中的文件就行了且zip文件和bundle文件夾在同一目錄下
/* ... */
@ccclass('Start')
export class Start extends Component {
/* ... */
async start() {
// 作者這里有自動(dòng)化壓縮上傳插件,會(huì)自動(dòng)修改server字段
// 并且會(huì)將需要zip加載的包注入到window上
// 注入的js類似與下面這行
// window["zipBundle"] = ["internal", "main", "resources"];
const remoteUrl = settings.querySettings(Settings.Category.ASSETS, "server");
const zipBundle = window["zipBundle"] || [];
// 作者這里所有bundle全都是遠(yuǎn)程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 的文檔沒有很好的批量實(shí)現(xiàn)這個(gè)需求的方式,又因?yàn)?Cocos 引擎更新比較頻繁,我個(gè)人又喜歡多用新引擎新功能,所以我選擇不自定義引擎,直接采用攔截 Cocos 加載的方法實(shí)現(xiàn)將加載資源替換到自己的zip包。
通過閱讀源碼我們已經(jīng)知道除了圖片資源,其他資源都是通過 XMLHttpRequest 來加載的,那么很簡(jiǎn)單,我們直接攔截 XMLHttpRequest 就行了。
那么你問我怎么才能攔截一個(gè)瀏覽器 Native 對(duì)象,這可是 Js,Js 無所不能!
攔截open和send
- 不必多說,按下面這種方法就可攔截一個(gè)
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名,文件名拼起來,直接存在一個(gè)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中取消網(wǎng)絡(luò)請(qǐng)求,直接定向到我們緩存在zip資源,由于我們不能直接修改xhr的response,因?yàn)樗侵蛔x屬性,所以我們要借助
Object.getOwnPropertyDescriptor和Object.defineProperty,話不多說,直接看代碼把 - 在測(cè)試過程中發(fā)現(xiàn)Cocos可能會(huì)請(qǐng)求多次同一個(gè)json,且可能修改解析后的對(duì)象,所以我暫時(shí)給json類型的資源加了一個(gè)id,可以讓他每次都重新獲取zip中的內(nèi)容并解析
/* ... */
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) {
// 有這個(gè)資源就記錄下來
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設(shè)置的responseType給zip解析
const res = await cache.async(this.responseType);
ResCache.set(this.zipCacheUrl, res);
}
}
// 解析完了直接調(diào)用onload,并且不再發(fā)起真實(shí)的網(wǎng)絡(luò)請(qǐng)求
this.onload();
return;
}
return oldSend.apply(this, arguments);
}
}
}
- 打包項(xiàng)目手動(dòng)壓縮bundle文件夾上傳進(jìn)行測(cè)試,可以看到我們下載了三個(gè)zip資源,大量的json和bin文件夾都沒有下載,和我們測(cè)試gltf相關(guān)的文件,僅有兩張貼圖而已,而且能正常進(jìn)入Game場(chǎng)景,說明我們剛才寫的代碼是有效的,加載該gltf文件的request次數(shù)從35次降到了3次


6 發(fā)布自動(dòng)化
- 編寫 Cocos 插件打包自動(dòng)壓縮 bundle 為 zip
- 這個(gè)就比較簡(jiǎn)單了,新建一個(gè)構(gòu)建插件
- 編寫一個(gè)zip.ts,文件內(nèi)容如下
import * as fs from "fs";
import JSZIP from "jszip";
//讀取目錄及文件
function readDir(zip, nowPath) {
const files = fs.readdirSync(nowPath);
files.forEach(function (fileName, index) {//遍歷檢測(cè)目錄中的文件
console.log(fileName, index);//打印當(dāng)前讀取的文件名
const fillPath = nowPath + "/" + fileName;
const file = fs.statSync(fillPath);//獲取一個(gè)文件的屬性
if (file.isDirectory()) {//如果是目錄的話,繼續(xù)查詢
const dirlist = zip.folder(fileName);//壓縮對(duì)象中生成該目錄
readDir(dirlist, fillPath);//重新檢索目錄文件
} else {
// 排除圖片文件,下面會(huì)講到
if (fileName.endsWith(".png") || fileName.endsWith(".jpg")) {
return;
}
zip.file(fileName, fs.readFileSync(fillPath));//壓縮目錄添加文件
}
});
}
//開始?jí)嚎s文件
export function zipDir(name, dir, dist) {
return new Promise<void>((resolve, reject) => {
const zip = new JSZIP();
readDir(zip, dir);
zip.generateAsync({//設(shè)置壓縮格式,開始打包
type: "nodebuffer",//nodejs用
compression: "DEFLATE",//壓縮算法
compressionOptions: {//壓縮級(jí)別
level: 9
}
}).then(function (content) {
fs.writeFileSync(`${dist}/${name}.zip`, content, "utf-8");
resolve();
});
});
}
- 在hooks中
onAfterBuild中編寫壓縮腳本的內(nèi)容,壓縮腳本的內(nèi)容在其他操作(如壓縮圖片,混淆代碼,修改js等)都做完之后,且在上傳資源前,且只針對(duì)web模板大致內(nèi)容如下
export const onAfterBuild: BuildHook.onAfterBuild = async function (options: ITaskOptions, result: IBuildResult) {
// 非需要的模板不進(jìn)行這個(gè)操作
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 做一個(gè)簡(jiǎn)單的優(yōu)化
通過前面閱讀源碼和 Network 中看到,Cocos 加載圖片的方式不是通過 XMLHttpRequest,而是通過創(chuàng)建 Image 對(duì)象的方式。
此片文章的內(nèi)容暫時(shí)不研究如何將加載圖片也替換到使用自己的 Zip,因?yàn)槲易约阂策€沒做。
所以我選擇直接在打包 zip 的時(shí)候過濾 png/jpg 文件來降低 zip 包的大小,僅在 zip 中打包需要的文件即可。
8 關(guān)注我
歡迎大家關(guān)注我的公眾號(hào),只搞實(shí)用的。
參考資料 [1]jszip: https://www.npmjs.com/package/jszip
