Cocos游戏开发|使用zip压缩减少web请求,加速资源加载

COCOS

共 27967字,需浏览 56分钟

 · 2024-04-11

点击上方码不了一点+关注和★ 星标


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和一些骨骼&贴图

8e58f41523cf192f0495a1ac11e4d795.webp

  • 创建Start场景用于预加载资源,和Game场景用于展示模型

b791c9629f7c0876827a87e2c6c9422f.webp

  • 创建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 (errorconsole.error(error);
                    resolve();
                }
            
);
        }
);
    }
}
  • 运行游戏,在本地web环境查看Network,request数量为379,和这个gltf相关的就有216个,打包发布至Web环境,选择合并所有Json,加载该gltf总共用了35次request。
0948242f272b9920c65754de230979a0.webp本地 Web 预览 b39fd78cc27a2e246a8244f113f5a29f.webp打包合并后
  • 明明只有一个gltf却用了35次request来加载。
  • 原因在于Cocos将gltf资源转换成了Cocos资产,将Mesh,材质等拆解了出来,每个资源除了资源本身外还会有一个记录属性依赖的Json文件

0c4f2ba83dfe7477b962e416e0a33a82.webp

如何解决

  • 将整个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.tsdownloader.ts也就是资源下载管线的一部分。那么我们直接打开源码进入到这里

3b20e9c18c501788837ff994a7b0ebbb.webp


9f5d7d5acd9e9c38ddefd87c4fc2cd16.webp

  • 在代码中我们可以看到,大部分文件的下载都是通过downloadFile方法进行下载的,这个方法就是刚才的download-file.ts中的方法,该方法使用XMLHttpRequest下载文件

86674007d87964aea255e295f4440a10.webp


a9936a3e81628a447bcf0f6a6e3c6d1a.webp


  • 既然我们已经知道在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(thisarguments);
}

// 拦截send
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = async function (data{
  return oldSend.apply(thisarguments);
}
  • 添加解析zip的代码,将zip中的代码解析到完整的路径上
      
      /* ... */;

const ZipCache = new Map<stringany>();

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(thisarguments);
        }

        // 拦截send
        const oldSend = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.send = async function (data{
            return oldSend.apply(thisarguments);
        }
    }
  • 在拦截的open和send中取消网络请求,直接定向到我们缓存在zip资源,由于我们不能直接修改xhr的response,因为他是只读属性,所以我们要借助Object.getOwnPropertyDescriptorObject.defineProperty,话不多说,直接看代码把
  • 在测试过程中发现Cocos可能会请求多次同一个json,且可能修改解析后的对象,所以我暂时给json类型的资源加了一个id,可以让他每次都重新获取zip中的内容并解析
      
      /* ... */

const ZipCache = new Map<stringany>();
const ResCache = new Map<stringany>();
let jsonId = 0;  // 兼容json

export default class ZipLoader {

    /* ... */

    constructor() {
        this.init();
    }

    /* ... */

    init() {

        const accessor = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'response');
        Object.defineProperty(XMLHttpRequest.prototype, 'response', {
            getfunction () {
                if (this.zipCacheUrl) {
                    return ResCache.get(this.zipCacheUrl);
                }
                return accessor.get.call(this);
            },
            setfunction (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(thisarguments);
        }

        // 拦截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(thisarguments);
        }
    }
}
  • 打包项目手动压缩bundle文件夹上传进行测试,可以看到我们下载了三个zip资源,大量的json和bin文件夹都没有下载,和我们测试gltf相关的文件,仅有两张贴图而已,而且能正常进入Game场景,说明我们刚才写的代码是有效的,加载该gltf文件的request次数从35次降到了3次

c16a7e48fcb2842f8aef6fa493e72b1c.webp


0097b5f0731dafb9a3911386aac31176.webp

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


浏览 6
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报