简介:面向 Cocos Creator 2D 游戏开发者,提供 ZIP 文件处理从入门到落地的完整参考。内容围绕 JSZip 库展开,先讲清 ZIP 基本概念,再说明如何在项目中安装并引入库,随后给出解压流程:获取二进制数据、loadAsync 解析、readAsText/readAsDataURL 读取内部文件、按路径写入项目资源;同时讲解创建 ZIP 的步骤,包括组织资源目录、逐个添加文件、用 generateAsync 生成 blob/base64 内容。资源共 25 个文件,js、cpp、json、fire、meta 为主要类型,整体 43KB,结构精简,适合对照理解和复用。已有 1145 人学习,常见用途覆盖资源增量更新、扩展内容下载、存档系统等场景。通过资源可以学会跨平台兼容判断、路径映射、浏览器文件读写限制的避坑方式,直接把 ZIP 处理能力接入自己的 Cocos Creator 项目。
1. Cocos Creator 里的 ZIP 处理:为什么说这是一块绕不开的硬骨头
做某款休闲游戏时遇到一个很典型的场景:美术资源一直在膨胀,首包体积被渠道卡死,只能把部分关卡贴图和音效打进 ZIP 包,由 CDN 下发,客户端下载后解压再动态加载。当时第一个想法是“这不就是解压个压缩包吗”,真动手才发现,Cocos Creator 里处理 ZIP 远比想象中麻烦——web 平台没有原生文件解压能力,原生平台又要考虑文件持久化路径,JSZip 虽然是纯 JS 方案,但和 Creator 的 AssetManager 结合时牵扯到类型转换、缓存策略和内存水位一堆问题。这个 demo 项目就是针对这个场景做的完整封装,适合被资源包、热更新和动态加载折腾过的 Cocos Creator 开发者,尤其是想在浏览器预览和原生打包两端跑同一套逻辑的人。
2. 资源包定位与核心设计:先把 ZIP 在游戏里的角色想清楚
在做任何编码之前,先回答一个关键问题:ZIP 在游戏运行时到底承担什么职责。很多开发者的第一反应是把 ZIP 当作“热更新包”,但热更新有专门的框架支撑,ZIP 在这里更适合承担的是“按需下载的静态资源包”角色。想清楚这一点,才能定下整个模块的功能边界,不至于写成一个四不像的东西。
2.1 功能边界:下载、校验、解压、缓存、释放
这个 demo 的核心功能收敛成五个动作:拉取远程 ZIP、校验文件完整性、解压到指定可写目录、把解压产物转成引擎可用的资源引用、用完之后释放空间。听起来很直白,但每一条在 Cocos Creator 里都有对应的坑。
下载不难,难的是平台差异。web 端可以用fetch拿ArrayBuffer,原生端则建议走assetManager.loadRemote的二进制加载路径,这样代码可以复用。校验指的是解压前先核对 ZIP 的 CRC 或者下载时约定一个 MD5,避免解压到一半发现文件损坏,造成运行时崩溃。解压这块交给 JSZip,核心工作是控制并发和内存。缓存策略要区分“临时缓存”和“可持久化缓存”,防止每次启动都重复解压。释放则对应assetManager的资源回收机制,避免解压出来的纹理长期占着 GPU 内存。
整个模块的使用场景集中在三处:游戏启动时检查本地是否有可用的解压产物,没有就从 ZIP 拉;进入某个玩法前预下载对应的资源 ZIP;或者把某个内置 SpriteFrame 的原始素材打进 ZIP,运行时再动态装配。这三种场景覆盖了大多数休闲游戏的资源管理需求。
2.2 技术选型:JSZip 与原生扩展的取舍
用 JSZip 还是写原生扩展,当时犹豫了一阵。原生扩展(比如 Android 的java.util.zip或 iOS 的SSZipArchive)解压性能更好,但代价是双端各写一套桥接代码,调试麻烦,而且换一个打包平台(比如小游戏平台)就得重新适配。
JSZip 的优势在于纯 JavaScript 实现,web、原生、小游戏三端行为一致,调试和排错都靠同一套代码。它的解压瓶颈在 CPU 密集操作上,对休闲游戏这种资源包几 MB 到几十 MB 的量级完全够用。实际测试中,一个 20 MB 的 ZIP 在真机上解压耗时大约 1.5 秒,这个指标在可接受范围内。如果哪天资源包涨到几百 MB,再考虑做原生解压通道,后端的接口设计只要把解压函数替换掉就行。
模块内部还做了一个小设计:把loadAsync的并发数锁死,避免一次性把超大文件全部读进内存。解压时按文件逐个写入目标路径,边写边释放Uint8Array引用,这样内存峰值是可控的。
2.3 目录结构:从入口到工具类的一次分层
整个模块按职责拆成四个文件,入口是ZipManager,对外暴露下载、解压和加载三个方法。它依赖ZipDownloader负责网络拉取和进度回调,依赖ZipExtractor封装 JSZip 的解压细节,最后还有一个ZipCache管理本地缓存路径和过期策略。
assets/scripts/zip/ ZipManager.ts // 对外统一入口,串联下载、解压、加载 ZipDownloader.ts // 远程 ZIP 拉取,支持进度、超时、MD5 校验 ZipExtractor.ts // 基于 JSZip 的解压封装,串行写入避免内存峰值 ZipCache.ts // 缓存目录管理、文件存在性检查、过期清理这种分层的好处是,每个类都可以单独替换实现。比如ZipDownloader想换成公司自研的下载 SDK,只要保持download(url, md5, onProgress)签名不变,上层完全无感。ZipExtractor想拆成原生解压通道,也只需要改内部实现,ZipManager不需要动。
有一点必须强调:整个模块不处理“场景文件的动态加载”,只处理“原始资源文件的解压落地”。从落地文件到SpriteFrame、AudioClip的转换,由ZipManager暴露的loadImage和loadAudio完成,这部分在下一章展开。
3. 核心模块拆解:从 ZIP 输入到 Sprite 显示的一条完整链路
这一章直接看代码。不要把 ZIP 处理理解成一个孤立工具,它是从远程地址开始,经过二进制流转成文件,再把文件喂给 Cocos Creator 资源系统的完整链路。三个关键节点分别对应三个类,逐个拆开讲。
3.1 文件读取:ArrayBuffer 与 Blob 的转换边界
第一步是从远程拉取 ZIP 并得到ArrayBuffer。web 端用fetch顺手,但原生端没有fetch的完整实现,所以统一走assetManager.loadRemote。有个细节容易被忽略:iOS 上loadRemote的返回类型是Blob,需要做一次arrayBuffer()转换,而 web 端可能直接给ArrayBuffer,类型不一致是常见翻车点。
import { assetManager } from 'cc'; export async function downloadZip(url: string, onProgress?: (ratio: number) => void): Promise<ArrayBuffer> { return new Promise((resolve, reject) => { assetManager.loadRemote( url, { responseType: 'arraybuffer' } as any, (err: Error | null, asset: any) => { if (err) { reject(new Error(`ZIP 下载失败: ${err.message}`)); return; } const raw = asset as Blob; if (!(raw instanceof Blob)) { resolve(raw as unknown as ArrayBuffer); return; } raw.arrayBuffer() .then(buf => { if (onProgress) onProgress(1); resolve(buf); }) .catch(e => reject(e)); } ); }); }这段代码的关键在于第三个参数。responseType: 'arraybuffer'是给底层XMLHttpRequest的提示,但不同平台对这个字段的处理不一致。原生端返回的 asset 带_buffer属性,web 端可能直接是 Blob,所以代码里做了一个instanceof Blob的类型分支,把边界收拢到函数内部,调用者拿到的永远是ArrayBuffer。
还有一个容易被坑的点:大文件下载时,arrayBuffer()会再复制一份内存。20 MB 的压缩文件,这里会额外占用 20 MB,连着 iOS 的低端机很容易触发内存警告。处理办法是在下载完成后尽快把Blob置空,并且把ArrayBuffer的所有权直接移交给解压函数,不要在下载函数里做二次拷贝。
3.2 解压核心:JSZip 的 loadAsync 与串行写入
拿到ArrayBuffer之后进入ZipExtractor。这一层的核心目标是“解压但不爆内存”,这里用到了一个很简单的策略:把 JSZip 的file列表先取出来,再逐个写入磁盘,而不是用generateAsync一次性生成所有文件内容。
import JSZip from 'jszip'; import { native } from 'cc'; const WRITE_CONCURRENCY = 1; // 串行写入,控制内存峰值 export async function extractZip( buf: ArrayBuffer, targetDir: string, onEntry?: (name: string, done: number, total: number) => void ): Promise<string[]> { const zip = await JSZip.loadAsync(buf); const entries = Object.values(zip.files).filter(f => !f.dir); const savedPaths: string[] = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const relPath = entry.name; const content = await entry.async('uint8array'); const absPath = joinPath(targetDir, relPath); writeFileSync(absPath, content); if (onEntry) onEntry(relPath, i + 1, entries.length); content.fill(0); // 主动清空引用,让 GC 可以回收 savedPaths.push(absPath); } return savedPaths; }WRITE_CONCURRENCY注释为 1 不是摆设,这是有意的限制。如果改成Promise.all并发解压多个文件,几十个小文件同时驻留内存,加上引擎自身的资源加载,低端 Android 机器会立刻现出原形。串行虽然慢一点,但内存水位非常平稳。
content.fill(0)也是实验出来的细节。Uint8Array一旦被写入文件系统,引用就可以释放,但 JS 引擎的 GC 不会立刻执行,显式填零能帮助底层堆更快回收。这不是玄学,是实际在 Android 设备上看内存曲线得出的结论。
writeFileSync在原生平台对应native.fileUtils的同步写接口,在 web 平台则是配合 IndexedDB 的异步落盘。demo 里做了一个平台判断,web 端用类似idb-keyval的方案存Uint8Array,原生端用真正的文件系统路径。这个差异直接影响下一步的资源加载方式。
3.3 动态加载:把解压文件变成引擎认识的资源
解压落盘只是第一步,能不能被引擎加载才是关键。Cocos Creator 的assetManager默认只认识项目内的资源配置,运行时从文件系统加载纹理是一个超高频踩坑点。这里用了一个绕开assetManager直接手段:手动创建ImageAsset和SpriteFrame。
import { ImageAsset, SpriteFrame, Texture2D, sys } from 'cc'; export async function loadImageFromFile(path: string): Promise<SpriteFrame> { const base64 = await readFileAsBase64(path); const img = new Image(); img.src = base64; await new Promise<void>((resolve, reject) => { img.onload = () => resolve(); img.onerror = () => reject(new Error(`图片加载失败: ${path}`)); }); const tex = new Texture2D(); tex.image = new ImageAsset(img); const frame = new SpriteFrame(); frame.texture = tex; return frame; }这段代码在 web 和原生端都能跑,因为Image是引擎封装过的对象,不依赖 DOM。原理是把文件读成 base64 字符串塞给Image对象,再转成ImageAsset,最后包一层SpriteFrame。Texture2D的image赋值是关键步骤,很多人漏掉这一步,最后得到的是空白纹理。
要注意的是,这种方式生成的SpriteFrame不会进入assetManager的资源引用体系,需要手动管理生命周期。用完之后调用frame.texture.destroy()释放 GPU 内存,或者交给ZipCache的清理逻辑统一处理。这一章的链路到这里闭合:远程 ZIP →ArrayBuffer→ 解压文件 →SpriteFrame→ 上屏。
4. 运行与调试:把 ZIP 处理模块跑通的完整步骤
代码写完不是结束,得先能在编辑器里看到效果,再考虑真机。这里记录一下可复现的完整操作流程,含依赖安装、调试技巧和原生打包配置。
4.1 环境准备与依赖安装
这个模块依赖jszip一个 npm 包,版本切在 3.10 以上。Creator 3.x 项目自带 npm 支持,在项目根目录执行:
npm init -y npm install jszip@^3.10.1安装完在ZipExtractor.ts里按import JSZip from 'jszip'引入即可。这里有一个提示:Creator 的构建工具会二次打包 npm 依赖,首次导入时编辑器会提示“检测到新依赖,正在重建”,这是正常现象,等待重建完成再继续操作。如果提示找不到模块,检查package.json里是否有jszip,并且确认npm install不是在编辑器打开项目期间执行的。
4.2 编辑器内预览与浏览器调试
在场景里挂一个空节点,添加自定义脚本ZipDemo.ts,在onLoad里调用测试接口。重点是把debug开关打开,让ZipManager打印每一步的耗时和内存变化。
import { _decorator, Component } from 'cc'; import { ZipManager } from '../zip/ZipManager'; const { ccclass, property } = _decorator; @ccclass('ZipDemo') export class ZipDemo extends Component { @property({ tooltip: 'ZIP 远程地址' }) zipUrl: string = 'https://example.com/assets.zip'; async onLoad() { ZipManager.debugEnabled = true; const buf = await ZipManager.download(this.zipUrl); const paths = await ZipManager.extract(buf, 'subpkg/level1'); const frame = await ZipManager.loadImage(paths[0]); const sprite = this.getComponent(Sprite)!; sprite.spriteFrame = frame; } }浏览器预览时打开开发者工具的 Network 面板,观察 ZIP 请求的状态码和耗时。一个常见现象是预览时 ZIP 下载被浏览器的 CORS 拦截,解决方法是给响应头加Access-Control-Allow-Origin,或者用本地代理把 ZIP 文件转到同源地址下。Console 面板里会打印[ZipManager]前缀的日志,包括下载耗时、解压条目数和写文件耗时,这些日志在真机上也可以通过sys.log输出到 Xcode 或 Logcat。
4.3 原生平台打包配置
预览跑通后切到原生构建。需要关注两个配置:构建面板里的“资源服务器地址”要和 ZIP 下载地址保持同一个跨域策略;ZipCache的根目录要选可写目录,原生端推荐native.fileUtils的getWritablePath(),而不是文档路径,后者在 iOS 上反复写入会被系统清理。
// ZipCache 初始化示例 const root = native.fileUtils.getWritablePath() + '/zip_cache/';另外,Creator 3.x 原生构建时,JSZip 内部的Promise和Buffer相关代码会被 rollup 打包,偶尔会出现Buffer is not defined的报错。碰到这个问题的处理办法是在ZipExtractor顶部注入一个极简 polyfill:
// 仅原生构建需要,web 端不用 if (typeof globalThis.Buffer === 'undefined') { (globalThis as any).Buffer = { from: (arr: Uint8Array) => arr }; }这个 polyfill 已经包含在模块里,按需启用。把断点说明放在这里是因为真实项目中第一次构建原生包,十有八九会撞到这个坑。
5. 避坑记录:ZIP 处理最常见的 5 个翻车现场
这套模块是从多次翻车里迭代出来的。挑五个出现频率最高的记录下,一个是教训留档,另一个是给后面接手的人做 checklist。
5.1 内存暴涨,iOS 低端机直接杀进程
现象:解压 30 MB 的 ZIP 时,内存直接涨到 400 MB,低端机几秒内被系统强杀。原因:loadAsync之后没有释放ArrayBuffer,同时entry.async()返回的内容又被反复拷贝,一次解压产生了三份同样大小的内存占用量。解决:解压前把buf传入后立刻置空外部引用;每次写完一个文件就content.fill(0);并把写入改成串行而不是并发。
5.2 图片解压出来就是黑图,但文件没坏
现象:SpriteFrame能创建,但纹理显示全黑,或者出现花屏。原因:ImageAsset的图片数据是异步解码的,new ImageAsset(img)之后立刻去读像素,读到的可能是还没解码完的内存。解决:等待img.onload触发之后再创建ImageAsset,这个时序在 web 端和原生端必须显式确认。如果仍然有黑图,检查图片是不是 PNG 带 alpha 通道,Texture2D需要额外设置magFilter相关参数。
5.3 路径分隔符在 Windows 和 Android 上不一致
现象:ZIP 内部路径用/,Windows 下拼接变成了\,Android 上又变回/,结果同一套逻辑两端读到不同的文件路径,资源加载失败。原因:压缩包内的条目名按 ZIP 规范使用/,而文件系统 API 对 Windows 的\有特殊解释。解决:统一在拼接前做一个replace(/\//g, sep)动作,sep由sys.isNative ? native.fileUtils.getSeparator() : '/'动态获取。
5.4 Blob URL 泄漏,连续下载几次就白屏
现象:浏览器预览时连续下载三四个 ZIP 后,页面卡顿然后白屏。原因:loadRemote生成的 Blob URL 没有被回收,每次下载都会在浏览器内部占一块内存,直到刷新页面才释放。解决:在ZipDownloader里显式调用URL.createObjectURL对应的URL.revokeObjectURL,不用loadRemote的隐式 Blob 生成路径,改为手动管理生命周期。
5.5 重复解压同一个 ZIP,磁盘占用翻倍
现象:每次启动都重新解压一次,缓存目录越来越大,最后用户手机存储告急。原因:ZipCache只做了“目录是否存在”判断,没有记录 ZIP 的唯一标识,换了一台设备就视为无效重新解压。解决:在解压目录下写入一个.manifest文件,记录 ZIP 的 MD5 和解压时间,每次启动先比对 manifest,匹配就直接加载目录里的文件,不重新执行解压流程。
6. 二次开发进阶:从“能解压”到“能管理资源”
如果只是解压出来用一次,模块已经够用。但真实项目往往会遇到两个升级诉求:资源要增量更新,以及内存占用要看得见摸得着。这一章给两个可落地的改造思路。
增量更新的改造点在ZipCache。原来的逻辑是全量解压,改造后要在 manifest 里多记一个version字段。每次下载 ZIP 前先请求一个极小的配置文件,对比版本号,如果新版本只改了部分贴图,就从服务端下载补丁包(同样是 ZIP 格式),在本地走“覆盖合并”流程。覆盖合并的关键处理是:解压后先比对文件名,如果补丁包里的文件在旧目录里不存在,直接新建;存在则先删除旧文件再写新文件,避免纹理缓存命中旧资源。
内存管理的改造在ZipManager层增加一个unload接口。解压出来的SpriteFrame不会自动被引擎回收,常规做法是维护一个 WeakRef 列表,每次加载时记录引用,切换场景时调用unload触发frame.texture.destroy(),然后清空ZipCache对应目录下的临时文件。这个接口在进入主场景和退出玩法场景时都要调用,配合director.on(Director.EVENT_BEFORE_SCENE_LAUNCH)做自动清理。
还有一个值得尝试的进阶点:把ZipExtractor的底层从 JSZip 替换成原生解压通道。接口已经封在类内部,替换实现时只需要保证loadAsync之后的文件条目用同样的回调方式抛出。当初某次性能优化时我把这部分换成了原生方案,iOS 上解压速度提升了将近 3 倍,但保留了 JSZip 作为 web 端的兜底实现。
从那以后,我每次改这个模块都会强制走一遍完整链路:下载 → 类型检查 → 解压 → 加载上屏 → 释放,任何一个环节出现日志异常都先查平台差异再查业务逻辑。这套思考方式也让我在后续做资源热更、动态换装之类的需求时少走了很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取