简介:面向Cocos Creator开发者的ZIP文件处理示例包,聚焦JavaScript环境下压缩包的读取、解压与创建,适合需要在游戏中实现资源增量更新、扩展内容下载或存档系统的开发者。压缩包共25个文件,以JavaScript脚本、C++/HPP原生扩展代码、JSON配置及场景文件与资源元数据为主,整体体积仅43KB,并附有演示工程与项目配置文件,便于直接对照学习。已有1145人学习下载,在Cocos Creator开发者群体中具有较高的参考与复用价值。代码完整演示了JSZip库的引入过程以及loadAsync、readAsText、generateAsync等核心接口的实际用法,完整覆盖ZIP文件路径适配、平台兼容判断、编辑器资源导入排错等关键处理逻辑,并针对资源增量更新、DLC内容下载和存档打包等典型场景给出了实现参考。可帮助开发者快速将ZIP压缩包管理能力集成到自己的项目中,大幅减少重复开发成本。
1. 让 Cocos Creator 处理 zip:资源动态化绕不开的第一道坎
做游戏的人迟早会碰上一个需求:把一批图片、音效、甚至整个关卡配置打包成 zip,运行时从服务器拉下来解压再用。Cocos Creator 项目里最常见的做法是发版时把资源打进 apk/ipa,但一旦涉及活动奖励更新、新关卡投放、或者让玩家下载扩展包,zip 就成了绕不开的格式。原因很直接:zip 有压缩率、有目录结构、有 CRC 校验,比散装文件省流量、好管理,而且在 Creator 的 Web 和原生双端环境下都有现成方案可落地。
但 zip 文件处理不是一个“解压完就能用”的简单事。真要落到 Cocos Creator 工程里,你会遇到三座山:第一,解压后资源怎么进引擎管线,直接 load 路径会撞上 Asset Bundle 的管理规则;第二,内存和文件系统的边界,Web 端有 IndexedDB 限制,原生端又有不同沙盒路径;第三,zip 包本身的坑——伪加密、zip64、中文文件名编码,任何一个都能让你的资源静默加载失败。这篇文章按“选型 → 最小实现 → 动态加载链路 → 踩坑 → 内存技巧”的顺序,把整套方案讲透。
2. JSZip 为什么是首选:库选型与最小解压链路
2.1 三个候选方案,为什么只有 JSZip 能进 Creator 工程
Cocos Creator 工程里处理 zip,理论上有三条路。第一条是调原生平台的解压接口,Android 上写 Java 调 ZipFile,iOS 上调 SSZipArchive,这条路性能最好,但每次都要过 JSB 桥接,Android 和 iOS 双端各写一遍,后续升级 Creator 版本还可能撞上 JSB 接口变更,维护成本高。第二条是用 Creator 自带的 assetManager 直接加载远程压缩包,但 Creator 原生支持的是 Asset Bundle,zip 不在官方管线内,强行加载会报 “Unable to load asset” 之类的问题。
第三条就是 JSZip 纯 TypeScript 方案。JSZip 是一个纯前端实现的 zip 解析库,不依赖原生代码,Creator 2.x 和 3.x 都能直接作为插件引入。它的解压过程发生在 JS 层,内存换兼容,对于游戏内非高频解压场景完全够用。核心 API 就三个:loadAsync 读文件、file 取具体条目、generateAsync 压缩输出。对于一个几十 MB 的 zip 包,解压时间在几百毫秒到两三秒之间,不会卡死主线程到不可接受。
我一般把 JSZip 放在 assets/scripts 外的 plugin 目录,不走 Creator 的构建编译,这样升级引擎版本时不会因为插件被重新编译而出问题。引入后在脚本里import JSZip from 'jszip'就能用,老项目也支持require方式。
2.2 最小可跑代码:加载 zip 并读出配置表
写一个最基础的调用,把 zip 里的一个 JSON 配置读出来。这段逻辑适用于所有后续方案——先读文件、再取条目、然后处理,是整套 zip 处理的固定三步。
import JSZip from 'jszip'; import { sys, path } from 'cc'; export async function readZipConfig(zipPath: string, entryName: string): Promise<any> { // 用 native 文件系统读取 zip 的二进制内容 const fullPath = path.join(sys.userDataPath, zipPath); const arrayBuffer = await readFileAsArrayBuffer(fullPath); // JSZip 加载二进制数据,等待内部结构解析完成 const zip = await JSZip.loadAsync(arrayBuffer); // 取指定目录下的指定文件,必须写完整路径 const target = zip.file('config/level_1.json'); if (!target) { throw new Error(`zip 内不存在条目: ${entryName}`); } // 按文本方式读取内容并转成 JSON const text = await target.async('string'); return JSON.parse(text); } function readFileAsArrayBuffer(filePath: string): Promise<ArrayBuffer> { return new Promise((resolve, reject) => { // Creator 原生环境用 filesystem 读取,Web 环境可用 XMLHttpRequest 或 fetch if (sys.platform === sys.Platform.WEB) { fetch(filePath) .then(res => res.arrayBuffer()) .then(resolve) .catch(reject); } else { // 原生平台用 jsb.fileUtils 的二进制读取接口 const data = jsb.fileUtils.getDataFromFile(filePath); // getDataFromFile 返回 Uint8Array,需要拷进 ArrayBuffer resolve(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); } }); }这段代码有几个关键参数要说明。第一,JSZip.loadAsync接收 ArrayBuffer、Uint8Array 或 Blob,不要传 string 路径,JSZip 只认二进制流。第二,zip.file()必须写完整条目路径,zip 里的目录结构是写在条目名里的,file('config/level_1.json')才能命中,只写文件名会返回 null。第三,target.async('string')有三个模式可选:'string'用于文本文件,'base64'用于需要转成图片数据的场景,'uint8array'用于二进制文件。这里没法用JSON.stringify省一步的读法,JSZip 的条目对象必须经过 async 方法才能取到内容。
2.3 加密与伪加密:loadAsync 什么时候会翻车
zip 文件处理里最容易被忽略的是加密状态。JSZip 支持传统的 ZipCrypto 加密,不支持 AES-256 加密。如果压缩时用了 WinRAR 的 AES 加密或 7-Zip 的加密头,JSZip 在loadAsync阶段就会直接抛错,错误信息通常是 “Unsupported zip encryption method”。这种坑在团队协作时特别容易出现——美术或策划同事随手用 360 压缩的“加密压缩”选项,发的包到你这边直接打不开。
伪加密则是另一回事。伪加密指的是 zip 文件把加密标志位设为 1,但数据区实际没加密。这种文件很多解压工具能打开,但 JSZip 会严格按标志位走,照样抛错。遇到过几次后,我在加载前会先读 zip 的前四个字节和通用标志位,做个预处理判断:
// 读 zip 文件头,判断是否带加密标志 const view = new DataView(arrayBuffer); // zip 头部固定为 PK 开头,第 6-7 字节是通用位标志 const signature = String.fromCharCode(view.getUint8(0), view.getUint8(1)); const flag = view.getUint16(6, true); if (signature !== 'PK') { throw new Error('不是合法的 zip 文件,缺少 PK 头'); } if ((flag & 0x1) === 0x1) { // 第 0 位为 1 表示加密,第 3 位为 1 时表示是伪加密的“提示性”标志 console.warn('zip 带加密标志,JSZip 可能无法解析,建议重新压制非加密包'); }这里参数说明一下:zip 的 Central Directory 和 Local File Header 都以PK\x03\x04开头,读取前四个字节就够判断合法性;通用位标志的第 0 位代表加密,第 3 位代表数据描述符。这个预处理能在 loadAsync 报错前就把问题暴露出来,省得每次在控制台里翻半天堆栈。与其花时间绕开 JSZip 的加密限制,更稳妥的约定是:所有进游戏管线的 zip 一律不加密,因为客户端里的 zip 加密本身就是形式上的——密钥就在包体里,真要防破解不靠这个。如果你确实需要对传输过程加密,走 HTTPS 就够了,层内别再套加密 zip。
3. 解压到磁盘还是驻留内存:两条资源注入路径的取舍
3.1 直接内存解压到纹理,最直观但最容易把内存打爆
解压 zip 拿到二进制资源后,第一反应是直接拿像素数据创建纹理。这种方案在代码上是最短的——用ImageAsset接收 ArrayBuffer,然后塞给Texture2D。但我劝你在开写之前先掂量一下资源量级。一张 2048x2048 的 RGBA 纹理,解压后在内存里占 16MB,如果你一个 zip 包里有 30 张这种纹理,光纹理本身就要 480MB,这还没算中间过程的临时 buffer 和 GC 压力。
Cocos Creator 3.x 的Texture2D创建接口适合小图、零星资源;对于整包资源,我更建议把解压后的文件先落盘,再走 Asset Bundle 或远程加载。为什么?因为落盘之后,引擎的贴图压缩格式(ASTC/ETC2)可以直接作用在文件上,运行时按需加载,而不是一次性把整包纹理全部搬进内存。内存路径适合的典型场景是:zip 里只有一张启动图、一个语言包 JSON、或者少量粒子配置文件。
如果你确实要走内存路径,有一个关键点必须注意,ImageAsset的reset接口负责接管底层像素数据,但ArrayBuffer的生命周期一定要自己控制好。JSZip 解出来的 buffer 在创建完纹理后立即置为 null,给 GC 让路:
import { ImageAsset, Texture2D } from 'cc'; export async function createTextureFromZipBuffer(zip: JSZip, entryName: string): Promise<Texture2D> { const file = zip.file(entryName); if (!file) { throw new Error(`zip 条目不存在: ${entryName}`); } // 读取为 Uint8Array,PNG/JPG 数据可以直接被 ImageAsset 解析 const uint8 = await file.async('uint8array'); // 拷贝一份 ArrayBuffer 用于创建 ImageAsset,避免 Uint8Array 的 view 偏移问题 const buffer = uint8.buffer.slice(uint8.byteOffset, uint8.byteOffset + uint8.byteLength); const imageAsset = new ImageAsset(); // 关键:创建图片资源时同步传入二进制数据,不触发额外的解码线程 imageAsset.reset(buffer); const texture = new Texture2D(); texture.image = imageAsset; // 释放中间 buffer 引用,尽早触发 GC uint8.fill(0); return texture; }参数说明三个点。第一,uint8.buffer.slice必须做,因为 JSZip 返回的 Uint8Array 可能带 byteOffset,直接传给reset可能读到非零偏移的数据导致花屏或解码失败。第二,imageAsset.reset(buffer)不是异步的,它同步标记了底层数据源,真正解码发生在 GPU 上传时。第三,uint8.fill(0)是把原 buffer 清零,这样做不是因为必须,而是明确告诉 V8 这段内存可以被回收。Web 端如果这种内存游戏常见,建议配合createImageBitmap做异步解码,能够降低首帧卡顿时间。
3.2 磁盘落盘 + Asset Bundle 动态注册,可维护性的最优解
游戏资源量大时,磁盘落盘是更理性的选择。先把 zip 解压到沙盒目录,然后通过 Creator 的assetManager.loadBundle加载这个目录。这个方案最大的好处是资源可以被引擎统一管理,内存占用和生命周期交给引擎的 Asset Manager 系统,不用自己写一堆手动释放代码。
落盘的关键是目录结构。Creator 的 Asset Bundle 要求资源目录内必须有一个bundle配置文件(config.json),这个文件可以在编辑器里创建 Bundle 时自动生成,也可以自己手工写。如果你是把 zip 当作扩展包从服务器下载,建议服务端直接打一个已经带bundle配置的 zip 包,客户端解压后推给 assetManager 就能用。
解压落盘的代码写法如下:
import { jsb, sys, path } from 'cc'; export async function unzipToDisk(zip: JSZip, targetDir: string): Promise<void> { // 目标目录统一放沙盒的 userDataPath 下 const baseDir = path.join(sys.userDataPath, targetDir); // 遍历 zip 内所有条目 const entries = Object.keys(zip.files); for (const entryName of entries) { const entry = zip.files[entryName]; // 跳过目录条目,zip 里目录以 / 结尾 if (entry.dir) { continue; } // 拼接完整输出路径,注意替换 zip 内的路径分隔符 const outPath = path.join(baseDir, entryName.split('/').join(path.sep)); // 确保父目录存在 const parentDir = path.dirname(outPath); if (!jsb.fileUtils.isDirectoryExist(parentDir)) { jsb.fileUtils.createDirectory(parentDir); } // 按二进制读取条目内容 const content = await entry.async('uint8array'); // 写入文件系统 jsb.fileUtils.writeDataToFile(content, outPath); } }这段代码有几个必须处理的坑。第一,path.join(baseDir, entryName)在 Windows 原生环境会出现/和\混用的问题,先做split('/').join(path.sep)能规避。第二,zip 里的空目录条目(entry.dir为 true)不会自动创建,你需要在跳过前先检查父链上是否有目录,没有就补建。第三,writeDataToFile接收的是 Uint8Array,而async('uint8array')返回的正合适。
另外一个容易被忽略的点是路径穿越攻击。zip 条目名里如果包含../,解压时可能把文件写到沙盒外,在原生平台这就是严重安全隐患。我一般在解压前做一次检查:
// 安全校验:拦截包含 .. 的条目路径 for (const entryName of Object.keys(zip.files)) { const normalized = entryName.replace(/\\/g, '/'); if (normalized.includes('..')) { console.error(`非法条目路径,已跳过: ${entryName}`); delete zip.files[entryName]; } }这个校验必须在解压循环之前做,因为 Zip Slip 攻击利用的就是解压时对路径的信任。虽然游戏内 zip 包来自自己的服务器,但谁知道哪个第三方渠道会往中间塞东西呢。
3.3 Web 平台落盘:内存到 IndexedDB 的迁移策略
Web 端和原生端的落盘策略完全不同。Web 没有文件系统,你只有两个选择:IndexedDB 或内存缓存。IndexedDB 存储量大(通常 50MB 到数百 MB 不等),但读取是异步且没有 Memoization 机制,第一次读卡顿明显。
在 Web 端我一般这么做:zip 从服务器下载后不立刻解压,先把 zip 整个存进 IndexedDB,然后每次需要某个资源时,从 IndexedDB 读 zip 条目再解压。这样做的好处是磁盘占用小,坏处是每次读取都要先加载 zip 文件头,生成 entries 索引要花时间。更优的做法是:进入游戏时把 zip 的 entries 信息序列化存起来,运行时按条目懒解压。懒解压是内存和速度的折中,因为 JSZip 支持直接在压缩数据上定位条目,不用解压全包。
Web 端用idb-keyval这类轻量封装库管理 IndexedDB 比较顺手:
import { get, set } from 'idb-keyval'; export async function cacheZipToIDB(zipName: string, arrayBuffer: ArrayBuffer): Promise<void> { // 直接存 ArrayBuffer 到 IndexedDB await set(`zip_${zipName}`, arrayBuffer); } export async function loadZipFromIDB(zipName: string): Promise<JSZip> { const buffer = await get(`zip_${zipName}`); if (!buffer) { throw new Error(`IndexedDB 中未找到 zip: ${zipName}`); } return JSZip.loadAsync(buffer); }要提醒的是,IndexedDB 存储的 ArrayBuffer 在 iOS Safari 上有 2GB 单文件限制的传闻,实际在旧版浏览器上 100MB 以上就可能不稳定。所以 Web 端单个 zip 包我一般限制在 50MB 以内,超出就分片。安卓端用自定义 WebView 时可以通过开发者选项调大存储限额,但 iOS 端没有公开 API 可以动这个配置。
4. zip 解压后的资源隔离:Asset Bundle 与子域结构怎么规划
4.1 目录前缀即资源命名空间,别把 zip 里的文件全倒进根目录
解压一个 zip 包里的 200 个文件到同一个目录,后续加载时必然会碰到重命名或意外的同名覆盖。zip 包在服务器上的目录结构设计,直接决定了解压后代码里要怎么引用资源。
一个 zip 包对应一个顶层目录是最基本的约定。比如皮肤包skin_01.zip内部结构是skin_01/icon.png、skin_01/effect/...,而不是icon.png、effect/...。这样做好处有三个:第一,每个包的资源在沙盒中有独立的命名空间,多个 zip 包之间不会互相覆盖;第二,Asset Bundle 加载时可以精确指向子目录,loadBundle('skin_01')从目录路径直接生成;第三,排查问题时用文件管理器逐层看目录就能定位。
我见过不少项目把 zip 里的资源直接解压到userDataPath/assets下,导致多个包之间有同名config.json,后解压的覆盖先到的,游戏里出现“部分玩家显示新皮肤,部分玩家显示旧皮肤”的奇葩问题——这其实就是资源覆盖造成的。在规划 zip 包目录时就想清楚顶层目录名,比在代码里兜底更省事。
4.2 用子域隔离而非全局注入:bundle 内资源自动冲突规避
Asset Bundle 加载的本质是让引擎把某个目录当做一个独立的加载单元。多个 bundle 之间存在同名资源时,Creator 的引用机制会按 bundle 作用域区分,不会混淆。这就是资源隔离的底层保障。
加载 zip 解压目录的 bundle 时,代码长这样:
import { assetManager } from 'cc'; export function loadBundleFromZipDir(bundleName: string, dirPath: string): Promise<AssetManager.Bundle> { return new Promise((resolve, reject) => { assetManager.loadBundle(bundleName, (err, bundle) => { if (err) { reject(new Error(`bundle 加载失败: ${bundleName}, ${dirPath}`)); return; } resolve(bundle); }); }); }这里面最容易踩的是bundleName和dirPath的关系。assetManager.loadBundle的bundleName默认是 bundle 的配置名,不是你解压到的沙盒路径。如果你的 zip 包内的bundle配置是手工写的,需要确认name字段和loadBundle的第一个参数一致。如果是服务器打 zip 时已经包含了编辑器自动生成的配置,两者天然对齐。
还有一类常见问题是:zip 解压后 bundle 配置是存在的,但因为目录层级不对,assetManager在扫描时找不到 config.json。加载失败的错误信息往往含糊,只提示 “Bundle not found”。排查时我优先检查config.json是否存在、是否在目标目录的根下,而不是层层找代码问题。
4.3 跨 bundle 引用:zip 资源之间互相引用的顺序坑
一个 zip 包内含依赖关系很常见:A bundle 里的贴图被 B bundle 的材质引用。在原生平台上,磁盘文件是分散着放的,加载顺序不受影响;但 Web 端的 IndexedDB 懒加载模式下,必须先加载被依赖的 bundle,再加载引用它的 bundle,否则会报错 “Missing referenced asset”。
解决分两步。第一步,在 zip 包内的配置清单里显式声明依赖顺序,服务器生成 zip 时把这个清单生成好:
{ "bundleName": "bundle_b", "dependencies": ["bundle_a"] }第二步,客户端加载 B 之前先检查 dependencies 并递归加载前置 bundle:
export async function loadBundleWithDependencies(bundleName: string, dependencies: string[]): Promise<void> { for (const dep of dependencies) { const depOk = await loadBundleFromZipDir(dep, `${dep}`); if (!depOk) { console.error(`前置 bundle 加载失败: ${dep}`); } } await loadBundleFromZipDir(bundleName, `${bundleName}`); }跨 bundle 引用另一个注意点是:Cocos Creator 的cc.SpriteFrame在加载时会去查自身的 URL 并解析依赖,一旦被依赖的 bundle 没有先加载,解析结果就是一个空引用。这类问题在编辑器和真机上的表现不一致——编辑器资源齐全,真机按序加载,所以测不出来,一上安卓包就花屏。养成先加载依赖再加载主包的顺序,可以少踩很多坑。
5. 避坑手册:zip 文件处理里的 5 个高频翻车现场
5.1 中文文件名乱码:zip 里的 UTF-8 标志位到底有没有生效
现象:zip 里是皮肤/主图.png,解压到磁盘后变成一串乱码,加载时无论怎么拼路径都找不到文件。
原因:zip 规范里,文件名编码默认是本地编码(Windows 上是 GBK),只有通用标志位第 11 位(0x800)置 1 时,文件名才按 UTF-8 解析。JSZip 在读取时如果检测到 UTF-8 标志位,会正常解码;但如果压缩时使用的工具没有正确写入这个标志位,JSZip 就会按 UTF-8 硬解,GBK 字节流直接变成乱码。
解决:两个方向。一是压缩端统一,打包时明确让 7-Zip 或 WinRAR 勾选 UTF-8 文件名选项,这条能让 90% 的乱码问题消失。二是读端兜底,如果文件名乱码,尝试用 TextDecoder 手动按 GBK 解码:
// 手动把条目名按 GBK 解码,作为 UTF-8 失败的兜底 function decodeGBK(bytes: Uint8Array): string { // 浏览器和原生环境都支持 TextDecoder 的 gbk 编码 const decoder = new TextDecoder('gbk'); return decoder.decode(bytes); } // 使用示例:entryName 乱码时,找 zip.files 的 key 做对应 const rawKeys = Object.keys(zip.files); const gbkName = decodeGBK(new TextEncoder().encode(rawKeys[0]));这里要说明的是,TextDecoder('gbk')在部分老版本 V8 上可能未启用,运行时需要try/catch。我在实际项目中一般用方案一彻底解决,方案二只作为线上版本排查工具。
5.2 loadAsync 报错 “Corrupted zip”:zip64 格式与大文件尾记录
现象:游戏内下载的 zip 包在 Windows 上能正常打开,真机上JSZip.loadAsync却报Corrupted zip。
原因:zip 文件超过 4GB 或者条目数超过 65535 时会启用 zip64 扩展。JSZip 对 zip64 的支持是有条件的,它在解析 End of Central Directory(EOCD)时如果遇到 zip64 定位器,会尝试读取 zip64 EOCD 记录。但如果打包工具写入的 zip64 记录不规范,或在 Web 端二进制流被截断,JSZip 就定位不到有效的中央目录。
解决:第一个选择是压缩时主动限制,zip 包控制在 2GB 以内、条目数控制在 5000 以内,彻底避开 zip64。第二个选择是拿到报错时先验证 zip 尾部 22 字节的 EOCD 签名,如果你能确认尾部数据被截断,用 retry 逻辑重新下载。Cocos Creator 热更新框架里有一个常见坑:下载中断时临时文件没有清理,下次下载时用了断点续传,续传后的文件尾部不是合法的 EOCD 记录,解压直接失败。解决方案是每次解压前先检查尾部签名:
// 验证 zip 尾部是否有 EOCD 记录(PK\x05\x06) function checkZipEOCD(arrayBuffer: ArrayBuffer): boolean { const view = new DataView(arrayBuffer); const length = view.byteLength; if (length < 22) return false; const signature = view.getUint32(length - 22, true); return signature === 0x06054b50; }这在下载失败重试的场景中特别值得前置判断。EOCD 记录的长度不是固定的,但正常 zip 的注释长度一般不超过 64KB,从尾部往上找PK\x05\x06这个特征串比固定位置读更可靠。
5.3 花屏与紫块:解压缓冲区的 byteOffset 没对齐
现象:zip 里的 PNG 图片解压后创建纹理,显示出来是一半紫一半正常,或者整体花掉。
原因:JSZip 从压缩流中解出的Uint8Array不是从 ArrayBuffer 的 0 偏移开始的,可能带了几字节的偏移。你直接拿uint8.buffer传给reset,ImageAsset 读到的是从 0 开始的整个 buffer,其中包含了解压流的头部脏数据,纹理数据错位几字节,解码出来的颜色就全乱了。
解决:按 3.1 节的写法,先slice对齐再传:
const cleanBuffer = uint8.buffer.slice(uint8.byteOffset, uint8.byteOffset + uint8.byteLength);这个坑在所有基于 JSZip 的方案里都存在。伴随的现象是创建纹理不报错,加 log 也能看到 buffer 长度正确,但颜色就是错乱的。记得把byteOffset是否为 0 的判定写进调试日志。
5.4 解压速度慢到像卡死:遇到固实压缩的 zip
现象:100MB 的 zip 包在 PC 上秒解,在低端安卓机上要卡 10 秒以上,甚至 ANR。
原因:压缩时选了“固实压缩”(Solid Compression),所有文件被当作一个连续数据流压缩。解压任意一个文件都要从流起点顺序解压到目标位置,单个文件的随机访问效率极低。JSZip 对这种格式没有特殊优化,只能从头开始解。
解决:服务器打包服务统一用标准 zip 格式,关闭固实压缩。在 7-Zip 里不要勾选“固实压缩”选项,WinRAR 里压缩方式选“标准”,不要选“最好”。这条约定必须在打包工具链上写死,因为人工打包很容易忘记勾选。另外,如果你需要频繁从 zip 里随机取单张图,先跑一次全量解压落盘,别在运行时反复调async解单条。
5.5 热更安全:zip 包校验与解压路径的沙盒控制
现象:热更下载的 zip 包被第三方篡改,解压出恶意脚本文件,游戏内执行导致数据异常。
原因:下载链路没做完整性校验,zip 解压又没限制输出路径,文件可能落到沙盒外或被改名成.js后被误加载。
解决:下载完成后、解压前,增加两层防护。第一层是校验 zip 的 CRC,JSZip 在loadAsync之后逐个文件async时会校验 CRC,但我们不能等那么久——先zip.forEach遍历所有 entry 并把 CRC32 值和服务器下发的清单做比对,不一致直接销毁。第二层是解压路径白名单,在 4.2 节基础上更严格,目标路径必须是userDataPath下的assets/或bundles/前缀:
const allowedPrefix = path.join(sys.userDataPath, 'bundles'); if (outPath.indexOf(allowedPrefix) !== 0) { throw new Error(`非法解压路径: ${outPath}`); }这类安全兜底对纯本地游戏也许显得多余,但只要你的游戏有联机对战、排行榜或者任何服务端逻辑,zip 包被篡改就是真实存在的风险。校验逻辑就几行代码,别省。
6. 内存峰值控制技巧:把 zip 处理从“卡一下”变成“无感”
6.1 逐条处理而不是全量解压:让 JSZip 的流式读取接管压力
最差的 zip 做法是Promise.all并发解压全部条目。100 个文件同时解压,内存峰值是 100 份峰值之和,低端机直接崩。正确做法是按需逐条async,处理完一条释放一条。
// 正确:一条一条处理 for (const entryName of targetEntries) { const entry = zip.files[entryName]; const data = await entry.async('uint8array'); // 交给业务处理,处理完立即置空 const result = handleSingleResource(entryName, data); data.fill(0); } // 错误:全量并发,内存直接打爆 // const results = await Promise.all(targetEntries.map(entry => entry.async('uint8array')));JSZip 的generateAsync也有内存峰值选项,这在客户端生成 zip 后准备上传时很关键。上传几百 MB 的存档包时,如果不控制,内存先爆掉。generateAsync会接受一个streamFiles参数,设成 true 会逐条目生成流、边生成边释放。
6.2 分批处理与分帧调度:别让主线程一次跑满
Cocos Creator 的脚本跑在主线程上,JSZip 解压计算密集且没有官方 worker 支持。如果解压 30MB 包需要 1 秒,这一秒里游戏帧率会降到个位数。分帧调度的思路是把解压任务按条目拆成多个小任务,每帧只做一部分:
// 伪代码示意:分片处理解压结果 const CHUNK_SIZE = 5; const entries = Object.keys(zip.files); let index = 0; function processNextChunk() { const end = Math.min(index + CHUNK_SIZE, entries.length); for (; index < end; index++) { // 解压单条并处理 handleSingleEntry(entries[index]); } if (index < entries.length) { // 下一帧继续,不阻塞主线程太久 requestAnimationFrame(processNextChunk); } } processNextChunk();这个方案适合解压后的资源不需要“全部就绪”才能开始的场景。如果必须全部解压完才继续,那就接受启动时的一次性卡顿,但尽量把解压放在 Loading 场景而不是战斗场景。Loading 场景卡一下用户感知弱,战斗场景卡一下就是掉帧判定。
6.3 性能基线:用一条日志把解压链路打透
线上出了问题最怕的是“黑匣子”——不知道该看哪条日志。我习惯在每个 zip 包处理时输出统一的性能标记:
console.time(`zip_download_${bundleName}`); console.time(`zip_load_${bundleName}`); console.time(`zip_unzip_${bundleName}`); // 下载流程 await downloadZip(url, savePath); console.timeEnd(`zip_download_${bundleName}`); // 加载与解压 const arrayBuffer = await readFileAsArrayBuffer(savePath); const zip = await JSZip.loadAsync(arrayBuffer); console.timeEnd(`zip_load_${bundleName}`); // 逐条解压 await unzipToDisk(zip, targetDir); console.timeEnd(`zip_unzip_${bundleName}`);这些日志能直接回答三个问题:加载慢是出在网络下载还是本地解压;本地解压慢是出现在loadAsync的索引构建还是逐条解压;以及两者占比是否合理。我见过一个项目,zip 只有 20MB但 loadAsync 耗时 3 秒,查下来是包内有 2 万个碎文件导致的索引进时间,后来把资源合并成大图就解决了。性能数字能告诉你优化方向,别靠感觉改代码。
6.4 升级视角:什么时候该把 JSZip 换成原生解压
JSZip 方案有天花板。如果你的游戏要做大世界连续加载、几十 MB 级贴图流式加载,JSZip 的纯 JS 解压速度大概只有原生解的 1/4 到 1/5,这时候就该考虑原生解压插件了。Android 端写一个原生模块,把 zip 解压到指定目录,通过 JSB 回调给 TypeScript 层通知;iOS 端同样用 SSZipArchive 封装。核心的驱动信号是:解压耗时超过游戏内可接受的加载时间,且 CPU 峰值影响帧率。
原生解的接入成本主要在工程配置和双端同步,但解压速度的提升立竿见影。有时上一张 1024 贴图,JSZip 要 30 毫秒,原生解只需 5 毫秒。另一个信号是 Web 端占比低——如果你的游戏以原生为主、Web 只是调试辅助,直接上原生解压插件,把 JSZip 作为 Web 端的兼容兜底实现,双端共用一套接口,这种高低搭配可以兼顾开发效率和运行性能。
这几年做下来,我的习惯是每个 zip 包的下载与解压日志都留底,版本迭代时对比耗时曲线。资源格式、打包工具、引擎版本任何一个变化都可能让解压耗时出现 30% 以上的波动,没有基线数据就只能靠猜。先跑通最小闭环,再逐步叠加安全校验、性能优化和懒加载策略,这条路比较稳妥,希望帮到你。
本文还有配套的精品资源,点击获取