接了个项目,需求一句话就能说清:Cocos Creator 开发,美术资源一共 500MB,但首包必须控制在 50MB 以内,而且运营要能随时换资源、不重新发版。于是“把资源文件部署到服务器”就成了唯一可行路径。我当时心想,这还不简单?构建完,把产物丢到 Nginx 上,URL 一配,完事。结果这一“完事”,我前后折腾了一个多星期,CORS、路径语义、缓存策略、Bundle 粒度全部翻车一遍。这篇就把整个摸索过程写出来,从构建产物结构讲到服务器配置,再到包体拆分,最后给大家一份可以直接抄的部署清单。如果你也正准备把 Cocos Creator 的资源外置,我的这些坑你大概率都会遇到。
1. 为了把 500MB 资源塞进 50MB 首包,我做了哪些错误预判
1.1 需求其实很简单:首包小、资源能换、别让用户等太久
先说场景。这个项目是一个 3D 互动展示应用,美术资源是外包的,场景模型、角色贴图、动效序列帧加起来差不多 500MB。如果全部打进首包,渠道包基本没法看,用户下载等待时间也会直接劝退。所以当时定下的目标有三个:首包只保留启动必需资源、资源更新不能依赖客户端发版、加载过程要平滑。
这几个目标放到技术层面,其实指向同一个方案:把资源外置到服务器,客户端按需下载。听起来不太难,但我一开始对 Cocos Creator 的资源体系理解得太粗了,以为“构建之后的 assets 目录”就是所有资源的大集合,直接传上去就能远程加载。这个理解后来被证明是最大误区之一——构建产物里的资源形态、索引方式、Bundle 归属,每一项都会影响你能不能实现资源外置。
1.2 构建产物目录给我的第一个下马威
第一次构建完,我打开输出目录,整个人是懵的。build/web-mobile 下面是 assets、src、settings 之类的一堆文件夹,assets 里还嵌套着各种名字毫无规律的目录,每个目录里又散布着 .bin、.json、.png、.webp 等等。我原以为会看到一个和工程 assets 保持对应目录结构的文件树,结果构建产物和源码资产之间根本不是一一对应的关系。
后来才明白,Cocos Creator 构建时会把原始资源做很多处理:图集被打包合并、图片被压缩或重新编码、文本配置被序列化成更紧凑的二进制格式、文件名根据内容重新生成。也就是说,你在编辑器里看到的 scene 文件、prefab 文件,构建之后大部分变成了带哈希的二进制资源,靠 resources 目录和 config 索引文件来组织。
3.x 的构建产物主要分为 assets(资源数据)、src(脚本编译结果)、settings(构建配置)、config(启动配置)几大块;2.x 用户会更熟悉 res/import 和 res/raw 这种分区。无论大版本是哪个,逻辑上都是一回事:构建产物是一个“经过索引压缩的资源包”,不是原始资源目录的复刻。这个认知不建立起来,后面所有排查都会像无头苍蝇。
1.3 为什么说 md5Cache 是后面所有弯路的转折点
构建选项里有一个叫“MD5 Cache”的开关,我第一遍构建的时候没当回事,关着就过去了。后来开始排查远程资源为什么老是加载到旧版本时,才意识到这个开关有多重要。
MD5 Cache 开启之后,构建产物中资源文件的文件名会带上一段内容哈希。内容变了,文件名就变;内容没变,文件名就不会变。这个特性简直是给远程资源加载量身定做的,因为客户端加载资源时用 URL 作为缓存标识,文件名变了就相当于一个新 URL,浏览器和 CDN 都不会给你返回旧缓存。
我关着 MD5 Cache 构建的那段时间,文件名始终是固定的,结果服务器上明明已经覆盖了新资源,客户端死活还是加载旧图。后来我把这个开关打开、重新构建、重新部署,问题立刻消失了。所以如果你想做资源外置,这一条几乎可以直接写进团队开发规范:远程加载场景务必开 MD5 Cache。
2. 远程加载连续翻车:跨域、路径、Bundle 配置一个都没少
2.1 CORS 头不到位,资源请求全被浏览器按在地上摩擦
第一个让我当场裂开的问题,是控制台刷出来一片红色报错:Failed to load resource,后面跟着一句 CORS policy 相关提示。我部署的地址是https://static.example.com/bundles/xxx,而游戏页面跑在https://game.example.com,跨域了。
Cocos Creator 在 Web 端加载远程资源,底层走的是 XHR、Fetch 或者 Image 标签这类浏览器机制。只要资源的响应头里没有明确的跨域许可,浏览器就会拦截。解决方式很标准,在 Nginx 静态资源配置里加上跨域响应头:
add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, X-Requested-With";如果涉及非简单请求,还需要处理 OPTIONS 预检请求。给一个实战中验证可用的完整片段:
server { listen 443 ssl; server_name static.example.com; root /data/static; add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, X-Requested-With"; if ($request_method = OPTIONS) { return 204; } }这里有个容易被忽视的点:如果你只对首页加了add_header,而资源接口是另一个 location,那跨域头可能不会生效,因为 Nginx 的add_header有继承规则。排错时可以先用下面的命令确认响应头到底带没带 CORS 信息:
curl -sI -H "Origin: https://game.example.com" https://static.example.com/bundles/launch/index.json输出里如果能明确看到Access-Control-Allow-Origin,才说明配置真正生效了。
2.2 loadBundle 的 URL 到底指到文件还是目录
CORS 解决后,资源能请求了,但 Cocos 的加载器开始报另一个错误:找不到对应的 Bundle 配置,类似Cannot load bundle或者索引解析失败。这一环节我反复折腾了很久,最后发现是 loadBundle 的 URL 语义理解错了。
我最初传的是“具体文件路径”,比如https://static.example.com/bundles/launch/index.json。但 Cocos Creator 的语义是“目录路径”:你要传一个 Bundle 在服务器上的目录,加载器会自己到这个目录下面找清单文件、索引描述和序列化资源。所以正确写法应该是:
assetManager.loadBundle('https://static.example.com/bundles/launch', { version: '1.0.0' }, (err, bundle) => { if (err) { console.error('加载远程 Bundle 失败', err); return; } bundle.load('prefabs/Player', Prefab, (loadErr, prefab) => { // 拿到资源,继续业务逻辑 }); });这里还要注意一点:URL 结尾不要带多余的查询参数或者#锚点,除非你的版本管理策略需要依赖 query 参数,那样也要保证加载器支持。我当时的教训是:老老实实把整个 Bundle 构建产物目录原封不动传到服务器,客户端 URL 指向该目录,这个链路最不容易出幺蛾子。
2.3 没开“远程包”,构建结果里根本没有远端加载的入口
路径问题排掉后,控制台依然在报同一个类别的问题,但这次更隐蔽:bundle 明明存在于工程内,构建也成功了,服务器上也有对应目录,客户端就是加载不出来。后来我逐个检查 Bundle 属性才发现,工程里的这个 Bundle 压根没有勾选“配置为远程包”。
Asset Bundle 在构建时,只有标记为“远程包”的 Bundle 才会被抽离成独立的、可远程加载的单元。没有开这个开关,资源会被打进默认的内置包,不生成独立的远程 Bundle 加载入口,你拿着 URL 去 load,自然什么都找不到。解决方式是在资源管理器里选中对应 Bundle,在属性面板打开“远程包”开关,然后重新构建。
构建完之后,在输出目录里找到被标记为远程的 Bundle 产物目录,把它整体上传到服务器。这里提醒一点:不同版本的构建产物位置不完全一样,有的在构建目录的固定子目录里,有的直接混在 assets 下,最靠谱的方法是看构建日志,日志里会明确打印远程 Bundle 的输出路径。构建日志别删,排查时会救命。
2.4 HTTPS 页面上加载 HTTP 资源,会直接被浏览器扔掉
这个坑比较隐蔽,因为它在本地调试时不一定出现。我本地起了一个测试页面,结果页面是用http://localhost访问的,而远程资源是https://,看起来一切正常。后来把页面部署到正式环境,用了https://game.example.com,结果远程 Bundle 全部静默失败。
原因很直接:HTTPS 页面加载 HTTP 子资源,属于混合内容,浏览器默认拦截。解决方式也很直接:资源服务器和游戏页面保持同一个协议级别,全部使用 HTTPS。如果实在没有条件,至少保证远程资源 URL 与页面 URL 的协议一致,不要在 HTTPS 页面里写死 HTTP 资源地址。这个问题容易被人忽略在部署阶段,排查却要花掉不少时间,所以提前写进规范里。
3. “改完了还是旧的”背后是缓存策略这道坎
3.1 同一个文件名,服务器换血一百遍,浏览器也只想给你看旧的
跨域、路径、远程包配置都解决后,加载已经通了。我高高兴兴改了美术资源重新上传,结果用户在线上看到的还是旧图。我当时第一反应是 CDN 没刷新,于是去刷了一遍,回来再看,还是旧图。最后开着浏览器 Network 面板一格格看,发现资源请求直接从浏览器缓存里返回了200 (from disk cache),根本连服务器都没到。
这就是缓存策略的锅。浏览器对静态资源有强缓存和协商缓存机制,服务器返回的响应头如果带了Cache-Control、Expires这类标记,浏览器会认为这个资源在有效期内不需要重新请求。哪怕你服务器端已经换成了完全不同的内容,只要 URL 不变,浏览器就认为“这个地址没变,内容应该也没变”。
这个问题在你依赖“同名覆盖”来更新资源时会极其致命。我有一段时间就是走“直接覆盖服务器上的同名文件”路线,结果 CDN 缓存的旧内容、浏览器缓存的旧内容,层层叠叠,想更新一个图片简直像打地鼠。
3.2 正确姿势一:让文件名跟着内容变,md5Cache 的妙用
我之前提到过 md5Cache,现在具体说它怎么解决缓存问题。
开启 MD5 Cache 后,构建产物里的资源文件名不再是固定的texture_001.png这种形式,而是会带上内容哈希,比如texture_001-3a2f9c.png。文件名由内容决定,内容一改,文件名必然改,URL 也跟着变。浏览器看到的是一个全新的 URL,没有任何旧缓存可命中,只能老老实实去服务器拉新资源。
这意味着游戏更新资源时,代码层面完全不用关心旧缓存,你只需要保证服务器上有新文件、CDN 能及时回源同步即可。我后来推进所有远程 Bundle 都必须开 MD5 Cache 构建,理由就是这个。
如果工程实在因为某些历史原因不能开 MD5 Cache,那你只能退而求其次,把版本号拼到 URL 或者目录层级里。比如https://static.example.com/bundles/launch/v1.0.0/换成v1.0.1/,保证每次更新后的 URL 是全新的。不要嫌麻烦,这是同等工作量里的最低成本方案。
3.3 正确姿势二:配置层“no-cache”,资源层“immutable”,CDN 才不会拖后腿
资源本身要长缓存,但 Bundle 的入口配置不能长缓存。
Bundle 的索引文件、描述文件这类“元信息”决定了整个 Bundle 的加载入口。如果这类文件被浏览器或 CDN 缓存住,那么哪怕资源文件已经更新了,客户端拿到的还是旧清单,照样加载旧资源。所以我的处理方式是:
- 带哈希的资源文件:
Cache-Control: public, max-age=31536000, immutable - 入口索引、版本配置类文件:
Cache-Control: no-cache, must-revalidate
对应的 Nginx 配置可以这么写:
location ~* \.(png|jpg|jpeg|gif|webp|bin|mp3|mp4|woff2)$ { add_header Cache-Control "public, max-age=31536000, immutable"; add_header Access-Control-Allow-Origin *; } location ~* (index\.json|config\.json|version\.json)$ { add_header Cache-Control "no-cache, must-revalidate"; add_header Access-Control-Allow-Origin *; }这样配置之后,CDN 层面也要同步设置缓存规则。大多数 CDN 支持按文件后缀或者目录设置缓存过期时间,记得把入口配置类文件的缓存设为“不缓存”。我踩过的一次教训是:服务器源头配置好了,但 CDN 侧默认缓存了大目录下所有文件,结果更新入口配置很久,CDN 还是返回旧版本,白白浪费了大半天。
4. 资源外置后,Asset Bundle 的划分决定了线上体验是流畅还是卡死
4.1 一个巨型远程 Bundle,带来的是五分钟白屏
第一版资源外置我图省事,把所有非启动资源全部塞进了一个 Bundle,然后把这个 Bundle 放到服务器上,客户端启动后就全量加载。结果就是:用户在 Loading 界面转了很长时间,等下载完 300MB 之后才进入主界面。
这从技术上其实“正常工作”,但从体验上完全不可接受。远程加载的核心逻辑不是“资源放服务器”,而是“资源何时下载、下载多少”。把一个巨大的包堆在一起,等于抛开了需求里的第三个目标:加载过程要平滑。
所以后来我把 Bundle 拆分变成了一次核心重构:启动包只放登录、主界面、公共 UI 的图集和最常见的基础模型;关卡专属资源、活动资源、特殊玩法资源各自独立成 Bundle,并在真正需要的时候才去加载。
4.2 按“场景进入时机”拆分,而不是按目录随手拆
拆分听起来简单,真正做的时候要克制。我当时差点按美术目录来拆:角色一个包、场景一个包、特效一个包。这么拆可以,但粒度太粗容易出现“进入某个玩法时需要同时拉三四个大包”的尴尬状况。
后来我定的拆包标准很简单:按“用户可能的使用路径”拆。用户进入一个模式时,需要同时出现的所有资源尽量放进同一个 Bundle,避免一个界面发起多次独立下载;不需要同时出现的资源,即使属于同一个美术分类,也拆开放。
实际落地时可以这样:先列出所有场景和玩法,标出每个玩法第一时间需要的资源集合,再把这些集合合并成 Bundle。如果一个Bundle过大,先检查里面有没有“进入该玩法后很久才会用到”的资源,比如胜利结算动画、下一关素材,把它们拆成二级 Bundle,等进入对应状态时再加载。
加载代码也能做得更细腻一点。进入玩法前先加载对应 Bundle,再加载该玩法依赖的 Prefab:
async loadGameMode(modeName: string): Promise<boolean> { const remoteUrl = `https://static.example.com/bundles/${modeName}`; return new Promise((resolve) => { assetManager.loadBundle(remoteUrl, { version: this.versionMap[modeName] }, (err, bundle) => { if (err) { console.error('加载模式失败', modeName, err); resolve(false); return; } bundle.load(`prefabs/${modeName}Main`, Prefab, (loadErr, prefab) => { if (loadErr) { console.error(loadErr); resolve(false); return; } // 实例化 prefab,进入玩法 resolve(true); }); }); }); }4.3 弱网和更新残留:重试、进度、旧缓存之间的平衡
资源外置上线之后,你会发现一个现实问题:不是每个用户的网络都好。弱网环境下,几十 MB 的 Bundle 加载可能反复失败。我当时的处理是在加载入口统一包一层重试逻辑,失败后最多重试 2 次,并且给加载过程提供进度回调,界面显示真实的百分比而不是一个假 Loading。
不过重试逻辑要考虑“残留缓存”这个因素。因为远程 Bundle 下载成功后,资源会被缓存下来。如果你更新了 Bundle 内容,但 URL 没有变,那么客户端可能会直接使用本地缓存里的旧内容。这时候就算你在服务器上把新文件放得再及时,用户端依然纹丝不动。
解决思路还是那两条:要么资源文件名带 MD5 哈希,内容一变 URL 必变;要么加载时传入新的版本号和资源地址对应。用完一个不再需要的 Bundle,记得通过assetManager.removeBundle释放引用,避免内存占用异常。这一点在原生平台、Web 平台表现不太一样,但核心原则一致——以 URL 为缓存的 key,URL 不变就必然复用旧缓存。
5. 我自己沉淀下来的部署清单,可以直接抄
5.1 服务器目录、Nginx 配置和 CDN 刷新规则
最后整理一份我在实际项目中跑通的部署结构。远程 Bundle 全部放到服务器上的独立目录,和构建产物静态页面分开管理:
/data/static/ ├── web-mobile/ # 构建产物(页面、首包资源) │ ├── index.html │ ├── assets/ │ └── src/ └── bundles/ # 远程 Bundle ├── launch/ ├── gameModeA/ └── gameModeB/Nginx 这边按照 2.1 和 3.3 里的配置组合起来用就行。注意跨域头、Cache-Control 头要分别加。CDN 侧把/bundles/目录纳入缓存,但对index.json、config.json这类入口文件设置不缓存,或设置极短的缓存时间。
一个细节:如果你用了对象存储之类的服务当资源源站,一定要确认它默认返回的Content-Type正确。.json文件必须返回application/json,.bin文件返回application/octet-stream。Content-Type 错了,Cocos 的解包逻辑会直接异常,而且报错信息往往让人摸不着头脑。
5.2 上线前的三分钟体检:curl、清缓存、看控制台
部署完不要急着测试,先在服务器上做一轮快速检查。我的固定流程是:
# 检查跨域头 curl -sI -H "Origin: https://game.example.com" https://static.example.com/bundles/launch/index.json # 检查缓存策略 curl -sI https://static.example.com/bundles/launch/index.json # 检查一个带哈希的资源文件 curl -sI https://static.example.com/bundles/launch/assets/texture-3a2f9c.bin第一轮看响应头里有没有Access-Control-Allow-Origin,第二轮看入口文件是不是no-cache,第三轮看资源文件是不是immutable长缓存。这三条过了,浏览器端的缓存和跨域基本稳了。
然后是客户端测试。不要直接开着顽固缓存测试,先无痕窗口打开页面,Network 面板勾选 Disable cache,模拟一个全新用户的首次加载;再正常刷新一次,模拟二次进入,确认缓存命中逻辑符合预期。
5.3 排错速查表:每个报错对应的根因
我再把整个排查过程中遇到的典型报错整理成一张速查表,方便你遇到问题时直接对着看:
| 现象 | 常见根因 | 解决方向 |
|---|---|---|
| 控制台报 CORS 错误 | 服务器/源站缺少跨域响应头 | 给静态资源响应加Access-Control-Allow-Origin |
Cannot load bundle或找不到配置 | loadBundle 传了文件路径,而不是目录路径 | 确认 URL 指向 Bundle 所在目录 |
| 找不到 Bundle 入口 | 构建时没有勾选“远程包” | 勾选远程包后重新构建并部署 |
| HTTPS 页面远程资源静默失败 | 页面是 HTTPS,资源是 HTTP | 统一使用 HTTPS |
| 更新后客户端一直加载旧资源 | 资源 URL 没变,命中旧缓存 | 开 MD5 Cache 或使用版本化 URL |
| 入口配置更新失败 | CDN 缓存了 index/config 类文件 | CDN 规则设置不缓存入口文件 |
| 大 Bundle 加载白屏 | Bundle 粒度太大 | 按玩法/场景拆包,按需加载 |
资源外置这件事,走到最后我最大的感受是:真正的难点从来不是“把文件放到服务器上”,而是让客户端每一次拿到的资源内容都可预期。跨域头是看你配置得对不对,路径语义是看你理不理解引擎,缓存策略是看你想不想省事。只要把这几个问题按顺序过一遍,后续的资源和版本更新,基本就是固定流水线了。