ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cocos Creator ZIP解压实战:JSZip选型、跨平台落盘与避坑指南

Cocos Creator ZIP解压实战:JSZip选型、跨平台落盘与避坑指南 简介面向Cocos Creator开发者的ZIP文件处理示例资源聚焦于JavaScript环境下结合JSZip库完成压缩包的读取、解压与创建操作适合需要实现游戏资源增量更新、扩展内容下载或本地存档系统的开发者。压缩包共25个文件大小仅43KB文件类型以js脚本、json配置、cpp/hpp原生绑定代码以及meta、fire等Cocos Creator工程文件为主覆盖ZIP处理的实现逻辑与项目配置。目前已有1145人学习下载。资源虽小但结构清晰从ZIP基础概念、JSZip引入方式到loadAsync解压、generateAsync生成均有对应代码示例并针对Web端Blob限制、文件路径匹配、跨平台兼容等常见问题给出处理思路。对于想在Cocos Creator中快速上手ZIP功能、减少踩坑的开发者来说这份小巧的示例包具有较高参考价值。1. Cocos Creator 里的 ZIP 文件处理引擎没给现成解压器你得自己扛做 Cocos Creator 项目的人迟早会撞上 ZIP 文件处理。运营要换一波活动图你不想为十几张图发一次包业务系统导出的货运单据、点菜数据、关卡配置全部以 zip 形式塞过来甚至你自己从网上拖回来的源码包和资源包解不开就只能干瞪眼。反直觉的结论是Creator 从 2.x 到 3.x 都没有内置 zip 解压能力构建打包 apk 时也不会帮你处理运行时 ZIP编辑器对 zip 的支持仅限于“导入资源包”那一下。这意味着所有解压逻辑你都要自己选库、自己跨平台排雷。这篇内容就把选型、最小可用链路、动态加载和踩过的坑一次说清适合正在做资源包更新、外部数据导入和微信小游戏兼容的团队参考。2. 解压库选型与集成为什么我最后选了 JSZip以及两条接入路径2.1 动手前先回答一个问题你要在哪里处理 ZIP同样是 ZIP场景不同方案完全不同。如果是编辑器内导入资源Creator 的资源管理器本身能处理标准资源包 zip你不需要写代码但绝大多数人搜到这里是因为运行时需要解压——游戏启动后从远程拉一个资源包或者读本地沙盒里的业务数据。这时代码跑在浏览器、微信小游戏、Android/iOS 的 JS 引擎里没有系统 unzip 命令可调必须引入纯 JS 的解压库。还有一种情况是离线工具链处理在构建机或 CI 里把资源打成 zip、给 zip 加包头、做校验。这种走 Node 或 Python 都行跟游戏运行时无关但我在第 6 章会提到一个技巧它需要打包端和游戏端配合所以选型时要把打包脚本的语言也一起定下来。我一般建议游戏端用 JSZip打包脚本用 Node两头都是 JS 生态符号和逻辑能保持一致少踩一种语言差异的坑。2.2 JSZip 和 zip.js、原生解压、自写解析的取舍我在项目里比较过四条路结论可以直接抄常规项目无脑选 JSZip。它的体积在纯 JS 解压库里面算克制——gzip 后大约 30KB支持解压也支持压缩API 是 Promise 风格和 Creator 3.x 的 TypeScript 环境配合很顺。zip.js 的流式能力更强浏览器端还有 Worker 加持但体积更大原生平台和小游戏环境的适配要自己验证除非你的包大到必须流式解压否则不值得换。系统原生解压是性能上限最高的方案Android 写 Java/Kotlin 插件、iOS 写 Objective-C/SwiftJS 层通过 bridge 调用。问题是维护成本翻倍换一个人就要重新摸一遍原生侧逻辑而且 zip 里的中文文件名、加密、分卷这些边界在两端可能表现不一致。只有压缩包常年在 500MB 以上、且团队有原生开发资源时我才建议走这条路。至于自写 ZIP 解析几百行能读出最简单的包但 ZIP 协议里的数据描述符、Zip64、加密头、目录偏移任意一个坑都能让你排查三天属于典型的“看着简单落地翻车”不碰。下面是我当时的选型记录参数都标在表里方案平台覆盖体积流式解压维护成本结论JSZip浏览器/小游戏/原生 JS约 30KB gzip不支持低默认首选zip.js浏览器最佳原生需自测较大支持中Web 为主可选系统原生插件Android/iOS不计支持高超大包才考虑自写解析器全平台但易碎极小难极高不推荐最终选型理由就一句话大多数手游的运营资源包在 10MB 到 100MB 之间JSZip 的内存模型完全扛得住为这个体量去养一套原生解压代码不划算。2.3 把 JSZip 装进 Cocos Creatornpm 和插件脚本两条路Creator 3.x 的项目根目录本来就有 package.json直接按 npm 依赖装npm install jszip --save npm install -D types/jszip --save-dev装完后在 TypeScript 里引入即可import JSZip from jszip;Creator 构建时会把 npm 依赖一起打包进 bundle不需要额外配置。要注意的是 types/jszip 只是类型声明如果项目里禁用了 npm 的自动类型查找会在编辑器里报找不到模块的类型但运行时不受影响。如果是 2.x 项目或者 3.x 项目不想引入 npm 构建链路我一般把node_modules/jszip/dist/jszip.min.js拷贝到assets/scripts/vendor/下然后在脚本里直接require(jszip)。更省事的做法是在编辑器里把它挂成插件脚本JSZip 会作为全局变量先于游戏脚本加载缺点是全局命名空间被占一个团队里有人不知道这个变量从哪来排查时会懵一下。两条路我都留过坑npm 方式在构建时如果没开“启用 npm”或版本不兼容会静默失败插件脚本方式在微信小游戏的首包扫描里要确认 jszip.min.js 被正确包含否则线上才报“JSZip is not defined”。3. 跑通最小解压链路拉二进制、解析条目、跨平台落盘3.1 资源放哪远程 URL、resources 目录还是本地沙盒ZIP 文件放在不同位置读取方式完全不同。放在 resources 里随包发布问题是最初就把 zip 打进了安装包那“用 zip 避免整包更新”的意义就没了。放在远程 CDN是最常见的运营资源包场景游戏启动后按版本号下载。放在本地沙盒一般是之前下载过想复用或者外部通过文件分享导入的 zip。我的判断标准很简单zip 只要不是随包发布的一律先下到应用沙盒再解压不要让解压逻辑同时处理“网络流”和“本地文件”两种输入统一成 ArrayBuffer 一种形态后续好维护。3.2 用 XMLHttpRequest 拉二进制别让 responseType 坑你拿 URL 拉 zip 时最容易翻车的就是 responseType。有人用 fetch 直接在微信小游戏环境跑不通有人用 XMLHttpRequest 忘了设二进制模式拿回来的 response 变成字符串一传给 JSZip 就出乱码或爆栈。我习惯封装一个下载函数function downloadBinary(url: string): PromiseArrayBuffer { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(GET, url, true); xhr.responseType arraybuffer; // 关键必须是二进制默认 text 会毁掉 zip xhr.timeout 15000; // 弱网环境下不设超时会一直挂着 xhr.onload () { if (xhr.status 200 xhr.status 300) { resolve(xhr.response as ArrayBuffer); } else { reject(new Error(HTTP ${xhr.status}: ${url})); } }; xhr.onerror () reject(new Error(network error)); xhr.ontimeout () reject(new Error(timeout)); xhr.send(); }); }逻辑说明responseType arraybuffer保证返回的是二进制 ArrayBuffer后续交给 JSZip 的loadAsync才能正确解析timeout设 15 秒避免弱网下用户看到一个永远转不完的菊花。这里的参数按你的业务调整如果 zip 在 CDN 上有预检分片可以缩短到 10 秒如果游戏内网络环境较差拉长到 20 秒。补一句请求失败后不要立刻重试先退避 2 秒再试这属于下载层的常规操作。3.3 解压并逐文件落盘兼容 2.x 与 3.x 的写文件封装拿到 ArrayBuffer 后解压本身不复杂麻烦的是写入。核心函数是这样的async function unzipBuffer(buffer: ArrayBuffer, saveDir: string) { const zip await JSZip.loadAsync(buffer); // 解析 zip 目录此刻不解压文件内容 const names Object.keys(zip.files).filter((n) !zip.files[n].dir); for (const name of names) { if (name.includes(..) || name.startsWith(/)) { // 防路径穿越 console.warn(skip unsafe entry: ${name}); continue; } const data await zip.files[name].async(uint8array); // 逐个解压避免内存峰值叠加 const fullPath joinPath(saveDir, name); ensureDir(dirnameOf(fullPath)); writeFileToNative(fullPath, data); } }逻辑说明loadAsync只解析中央目录不会把文件内容一次性都解出来async(uint8array)按条目取数据for 循环串行执行能让上一个文件的引用在循环结束后被回收内存峰值可控。过滤..和绝对路径是必须的业务 zip 里塞一个../../evil的条目解压时就能写到沙盒外面这是安全审计时一定会盯的点。写文件接口在不同引擎版本差异很大我项目里用的兼容封装长这样function writeFileToNative(fullPath: string, data: Uint8Array) { const jsbAny (globalThis as any).jsb; if (jsbAny jsbAny.fileUtils) { // 2.x / 3.x 早期jsb.fileUtils jsbAny.fileUtils.writeDataToFile(data, fullPath); } else { // 3.x 较新版本native.fs native.fs.writeFileSync(fullPath, data); } }这段多说一句引擎 2.x 到 3.x 的过渡期文件 API 换过一次名字如果你的编辑器控制台出现writeDataToFile is not a function或native.fs.writeFileSync is not a function说明版本分支走错了去看一眼jsb.fileUtils是否存在即可。目录创建我也放在ensureDir里封装先jsb.fileUtils.isDirectoryExist判断不存在就createDirectory原生平台这一布漏掉写文件时直接报目录不存在。3.4 微信小游戏平台的落盘差异小游戏没有jsb.fileUtils也没有 Node 的fs必须走微信自己的文件系统接口而且可写目录只有一个wx.env.USER_DATA_PATH。我推荐的写法是function writeFileOnWechat(relativePath: string, data: Uint8Array) { const fs wx.getFileSystemManager(); const baseDir wx.env.USER_DATA_PATH; const dir baseDir relativePath.substring(0, relativePath.lastIndexOf(/)); if (!fs.accessSync(dir)) { fs.mkdirSync(dir, true); // 第二个参数 true 表示递归创建 } fs.writeFileSync(baseDir relativePath, data.buffer, binary); // 注意是 data.buffer }参数说明data.buffer而不是data因为小游戏写文件接口接收的是 ArrayBufferUint8Array 是它的视图直接传视图会出类型错误mkdirSync(dir, true)的递归参数不传或传 false多级目录直接失败所有路径必须挂在USER_DATA_PATH下写别的地方大概率没权限。这里的习惯是把writeFile、ensureDir、joinPath全部收进一个文件系统适配层业务代码不要出现任何wx或jsb分支以后接抖音小游戏、快手小游戏只改适配层就够了。4. 把解压结果喂给 Creator文本、图片、音频与事件通知4.1 文本与 JSON读完直接业务消费注意 BOM配置文件和数据文件是 zip 里最常见的角色。读取时直接拿 JSZip 转字符串const content await zip.files[config.json].async(string); const config JSON.parse(content.replace(/^\uFEFF/, )); // 去 BOM逻辑说明async(string)会按 UTF-8 解码这是 JSZip 默认行为。replace(/^\uFEFF/, )是给 Excel、记事本等工具导出的 JSON 准备的那些文件头部经常带一个 BOM 字符JSON.parse直接抛错这在业务数据 zip 里出现频率不低。顺便提醒如果业务系统导出的 zip 不是 UTF-8 编码这里读出来就是乱码处理方案在第 5 章避坑里专门讲。4.2 图片原生平台用 assetManager.loadNativeWeb 用 Image ImageAsset解压出来的图片要变成 SpriteFrame有两套路径。Web 端和小游戏端我直接用 Blob 中转import { ImageAsset, SpriteFrame, Texture2D } from cc; const blob await zip.files[img/bg.png].async(blob); const url URL.createObjectURL(blob); const img new Image(); img.onload () { const imageAsset new ImageAsset(img); const texture new Texture2D(); texture.image imageAsset; const sf new SpriteFrame(); sf.texture texture; sprite.spriteFrame sf; URL.revokeObjectURL(url); // 用完释放 Blob URL否则会累积 }; img.src url;原生平台不要走 ImageJS 引擎的原生适配对 file:// 路径支持不稳定我用assetManager.loadNative加载已经落盘的文件const absolutePath fileUtilsPath img/bg.png; assetManager.loadNativeImageAsset({ url: absolutePath, ext: .png }, (err, asset) { if (err) { console.error(err); return; } const texture new Texture2D(); texture.image asset; const sf new SpriteFrame(); sf.texture texture; sprite.spriteFrame sf; });参数说明loadNative的ext必须显式传.png或.jpg它靠扩展名派发到图片加载器url用绝对路径而不是 file:// 前缀。内存管理方面旧 SpriteFrame 的texture.destroy()和imageAsset的引用要记得处理否则运营每换一次活动资源内存涨一截低端机跑两周就白屏。4.3 音频与预制体别在解压层玩魔法音频的处理方式和图片类似解压成文件后用assetManager.loadNative({ url, ext: .mp3 })加载 AudioClip。但预制体千万不要妄想在运行时解压一个 prefab 出来直接用——原因很现实Creator 场景和预制体依赖序列化文件加 UUID 引用一个 prefab 往往牵出材质、图集、动画、组件脚本脱离了 AssetBundle 的资源映射关系你解压出来的只是一堆孤儿文件不是可实例化的对象。正确的架构是zip 里放原始数据或貼图、音频等原始资源Prefab 继续走 AssetBundle 更新流程zip 只在需要动态拼界面、换皮肤、灌数据时发挥作用。把这两件事混在一起是我见过最多人踩的火坑。4.4 用事件把“zip 处理完毕”通知给业务层解压是异步大任务UI 层不该在代码里 await 到底。我习惯在解压完成后发射一个全局事件import { EventTarget } from cc; export const UNZIP_DONE unzip_done; export const zipEventTarget new EventTarget(); // 解压完成后 zipEventTarget.emit(UNZIP_DONE, { names: fileList, elapsed: Date.now() - startTime, });业务层订阅这个事件后再去拉取列表、刷新界面。参数这里有个细节事件带上fileList作为快照而不是让订阅方自己再读一次文件系统——因为另一个 zip 处理任务可能已经改了目录内容。用事件而不是回调是为了避免 UI 脚本持有解压模块的强引用卸载界面时清理订阅也简单。5. 避坑排查could not find eocd、中文乱码与大包闪退的现场记录5.1 导入资源包失败 caused by invalid zip archive: could not find eocd现象编辑器里导入资源包直接弹这个错运行时 JSZip 的loadAsync也会抛一模一样的invalid zip archive: could not find eocd。原因EOCDEnd of Central Directory Record是 ZIP 文件格式协议规定的尾部记录固定以0x06054b50开头解析器靠它定位中央目录。找不到 EOCD基本只有三种情况文件被截断、下载响应没读完就开始解析、或者文件根本是 HTML 错误页伪装成的 zip。网盘里转存的资源包、CDN 上被压缩过的 zip都容易出这种问题。解决解压前先做一个快速体检。检查文件头两个字节是不是PK0x50 0x4B再检查尾部是否带 EOCDfunction hasEocd(buf: ArrayBuffer): boolean { const u8 new Uint8Array(buf); if (u8.length 22) return false; // EOCD 最小长度就是 22 字节 const i u8.length - 22; return u8[i] 0x50 u8[i1] 0x4b u8[i2] 0x05 u8[i3] 0x06; // PK\x05\x06 }逻辑说明这个检查只能拦截“截断包”和“非 zip 包”拦不住“中央目录都完好但某个文件内容损坏”的脏包后者要依赖解压时的 CRC 校验。Zip64 格式的 EOCD 位置略有不同但常规资源包很少触发作为快速自检够用了。5.2 中文文件名解压成乱码现象解压出来的文件名变成灏忓崱.png这类诡异字符资源加载全部扑街。原因ZIP 规范里有个语言编码标志位没有强制压缩时如果打包工具不置位文件名默认按系统本地编码保存。中文世界里这个“本地编码”几乎就是 GBK/GB2312而 JSZip 默认按 UTF-8 解码于是中文名全乱。解决先看zip.files[name].utf8属性它为 false 时说明原包不是 UTF-8。现代浏览器环境可以试const bytes new Uint8Array(Array.from(name).map((c) c.charCodeAt(0) 0xff)); const realName new TextDecoder(gbk).decode(bytes);但这条在原生和小游戏环境不保险TextDecoder(gbk)不是所有平台都带。我的血泪经验是代码兜底不如上游修正。要求打包工具统一用 7-Zip 并勾选 UTF-8或直接用 Info-ZIP 的zip -r归档文件名就是标准的 UTF-8。货运单据导出、餐饮点菜系统这类第三方业务 zip文件名十有八九是 GBK跟对方提“导出时把文件名编码改成 UTF-8”比在客户端做一辈子兼容划算得多。5.3 大压缩包让低端机闪退现象300MB 的资源包在测试机解压时直接杀进程iOS 上先弹内存警告再闪退。原因JSZip.loadAsync要把整个压缩包读成 ArrayBuffer解压时每个条目又产生一份解压后数据内存峰值大概等于“压缩包大小 最大解压文件大小的若干倍”。低端 Android 机型可用内存只有几百 MB再叠上引擎和纹理占用必挂。解决把单包体量控制在 100MB 以内超过就拆包解压时用 for 循环串行读条目不用Promise.all并发解压处理完一个 entry 后立即把引用置空别保存在数组里。还有一条约束不要在解压的同时创建大量 SpriteFrame解压和资源创建分两个阶段做中间隔一帧给内存一个喘息窗口。真到了非流式不可的规模参考第 6 章的分包思路。5.4 微信小游戏里没有你习惯的 fs API现象同一套解压代码在浏览器预览正常构建成微信小游戏后一写文件就报错。原因小游戏环境没有 Node 的fs也没有jsb.fileUtils文件系统只有微信自己的一套接口且可写目录仅限于wx.env.USER_DATA_PATH包内路径只读。解决把“解压 写文件”整体收进适配层平台差异不外泄。小游戏分支用wx.getFileSystemManager()写之前先mkdirSync(dir, true)写入时传data.buffer而不是 Uint8Array。这个适配层我一般放在一个独立 ts 文件里接口只暴露initSaveDir、writeFile、readFile上层代码完全不知道自己在什么平台。代码里出现超过三处wx.或jsb.分支就说明适配层拆得不够干净。5.5 远程包下载一半就校验先看尾字节现象下载过程没报错一解压就 CRC error 或 EOCD 找不到重试一次又好了。原因CDN 在传输中被中间设备掐断或返回的 Content-Length 和实际 body 不一致XHR 的 onload 仍然会触发但数据已经是残缺的。解决下载完成后先比 Content-Length再跑一次第 5.1 节的 EOCD 检查最后让loadAsync顺带做 CRC32 校验——JSZip 解压条目时发现 CRC 不匹配会直接 reject。连续失败三次进入回滚逻辑保留上一版可用资源不覆盖。对于游戏来说宁可让用户看到旧资源也不能让他落在一个半新半旧的状态里这比任何错误提示都致命。6. 进阶分包代替流式、自定义包头与完整性的三次校验6.1 按需解压的真实做法分包而不是流式很多人想找“流式解压 zip”的库我可以直接说结论JSZip 不支持zip.js 的流式也主要在浏览器端可用原生和小游戏上要付出不少适配成本。真正的按需解压不是流式而是拆包。把资源按功能域切片每个包里只有自己那一组贴图和配置客户端按运营位或活动 ID 只下载对应的包。这样单包体积降下来内存问题、下载失败率、解压耗时全部一起缓解。服务端配合一个“包清单”接口客户端拿到清单才知道该拉哪个 zip这个架构比在客户端硬啃流式靠谱得多。6.2 给 ZIP 套自定义头兼容 CDN 与平台过滤我给线上包做过一个保护措施打包时在 zip 前面拼一段 4096 字节的自定义头存魔数、原始长度和 CRC32。好处有两个第一下载完可以先验头再决定要不要把整包交给解压器第二有些安全组件和 CDN 会识别文件魔数做拦截加了自定义头之后文件不再是标准的 zip 头能绕开部分“按魔数过滤”的策略。打包端用 Node 脚本实现const fs require(fs); const zlib require(zlib); function packWithHeader(zipPath, outPath) { const zipBuf fs.readFileSync(zipPath); const header Buffer.alloc(4096); // 固定 4096 字节尾部零填充 header.write(CCZP, 0, ascii); // 魔数4 字节 header.writeUInt32BE(zipBuf.length, 8); // 原始 zip 长度 header.writeUInt32BE(zlib.crc32(zipBuf) 0, 12); // 原始 zip 的 CRC32 fs.writeFileSync(outPath, Buffer.concat([header, zipBuf])); }逻辑说明header.writeUInt32BE(zipBuf.length, 8)把长度放在偏移 8 的位置是自定义的布局读取时也按这个偏移解析CRC32 用 Node 自带的zlib.crc32Node 版本低于 15 时要换第三方库。游戏端读取时先检查魔数再用偏移 8 读长度、偏移 12 验 CRC验过之后把 4096 字节裁掉剩余部分交给JSZip.loadAsync。这个头本身也可以顺便带版本号、包 ID 和加密信息但别把密钥写进包里那只拦君子不拦贼。6.3 把校验写进下载流程三次失败回滚最后形成一个固定习惯下载完成先对长度再查魔数和 EOCD最后解压时让 JSZip 的 CRC32 校验兜底三层校验全过才算成功。任何一步失败都不切换目录尝试三次全部失分后回滚到旧版本。我早年做运营资源包更新时翻过两次车一次是忘了 EOCD 校验CDN 截断的包直接上线玩家全部卡在加载页一次是 GBK 中文名没处理活动图片资源全部 404。这两条后来都写进了打包脚本的自检逻辑里打包时先跑一遍“虚拟解压”发现问题直接在构建期爆出来而不是让玩家在手机上报 bug。希望这些坑能让你少走一段弯路。本文还有配套的精品资源点击获取
返回列表