Cocos 小游戏平台缓存问题踩坑记录
本文记录了在微信/抖音小游戏平台中遇到的 远端分包文件下载超时、zip 分包、缓存记录丢失、bundle 释放 四大类问题,以及最终的完整解决方案。
问题与方案均已在AssetManager.js/cache-manager.js中落地。
环境:
以下案例均是在creator 2.x版本出现, 并且都是远端分包引起的小游戏平台缓存问题
目录
- 问题1:大文件下载超时/失败
- 问题2:zip 分包解压失败反复下载
- 问题3:缓存记录(cachedFiles)丢失
- 问题4:bundle 释放后资源 URL 拼成 undefined
- 附录:伪代码解决方案
问题1:大文件下载超时/失败
现象
总是有用户下载 2MB 大的 zip 分包 或其它大文件时失败。
排查过程
- 发现
game.json默认设置 下载超时 5s 时间过短; - 且小游戏下载 API 没有断点续传,超时失败后需要重新开始下载;
- 结果:网速慢的玩家一直「下载超时 → 重试」,网速快的玩家则没影响。
尝试修复(均未最终采用)
| 尝试 | 结果 |
|---|---|
| 延长下载超时到 50s | 大文件问题解决,但 1kb 小文件因网络丢包不能及时重试,也要卡 50s,影响体验 |
| 减少下载并发数为 3 | 大文件超时缓解,但拖慢小文件下载速度 |
最终解决方案
以上方法不再使用,改为以下优化:
-
不用 小游戏平台
downloadAPI(不支持断点续传),改用request实现断点续传; - 将下载文件按 254KB 大小分割成多数据流请求:
- 每个请求单独设置超时 10s(不使用
game.json默认的 5s); - 每个请求单独处理超时,失败只重下该块;
- 所有分块下载完后合并文件,校验文件完整性;
- 有错误则返回错误信息,由业务逻辑层处理;
- 每个请求单独设置超时 10s(不使用
- 缺点:下载数据需临时放内存直到合并完成,内存占用增加;
- 新增 下载进度回调,用户可实时查看下载进度和网速(普通
downloadAPI 没有此功能); - 我的项目中唯一的大文件是 zip 分包,因此分块下载流程针对 zip 分包展开;普通小文件仍走原
downloadAPI。
问题2:zip 分包解压失败反复下载
现象
案例游戏使用大量 zip 分包,总是有用户反馈 一直卡加载或加载缓慢。
排查过程
除了问题1的大文件问题,还发现 zip 分包专属问题:
-
缓存空间不足:当缓存空间不足 200MB 时,zip 分包解压失败,底层会调用
clearLRU清缓存,但这期间会 重复「下载 → 解压失败 → 重复下载」 直到解压成功; -
损坏 zip 污染缓存:有时候下载的 zip 包不完整,解压却没任何报错,Cocos 会将其记录到
cachedFiles当作已正常下载,导致后续加载失败,重启也救不回来,只能清缓存; -
退出丢失记录:下载和解压期间游戏退出,
cachedFiles记录有概率丢失,导致后续加载失败,或clearLRU无法清理游离文件。
最终解决方案
- 大 zip 分包 断点续传,超时/丢包不整体重下;
- 损坏 zip 被 完整性校验拦截,不再污染缓存;
- 解压失败等待自动腾空间重试,不再无限重复下载;
- 任何阶段崩溃,下次启动自愈(清理残留 / 补完删除),load文件判断无效缓存重下载丢失缓存文件;
- 新增 API 供业务层查询 剩余可用缓存大小,当可用空间少于 30MB 时主动调用
clearLRU清理缓存,避免加载关卡时还要「等待清理缓存 → 解压」,提前规避许多加载 bug。
问题3:缓存记录(cachedFiles)丢失
现象
总是有用户反馈 启动加载页卡死,只有点「清理缓存重启」后才能正常进入游戏。
排查过程
除了问题1、2 导致的缓存记录异常,还有其它缓存记录问题:
游戏运行期间下载 bundle 包文件途中,游戏退出,缓存记录有概率丢失。
-
现象1:
cacheList.json里有缓存记录,但实际文件已被删除 → bundle 每次命中缓存后读取失败,形成 死循环,只能清理缓存修复; -
现象2:
cacheList.json里没有缓存记录,但文件实际存在 →clearLRU无法清理这些游离文件,缓存空间泄露越来越大,最终无法下载新文件,只能清理缓存修复。
注:上述问题可能还有其它原因,比较复杂,此处不展开。
最终解决方案
核心目标:保证 cacheList.json(记录)与磁盘文件(目录)之间,无论何时崩溃/中断,最终都一致,或下次启动能修复意外退出的未完成的记录;同时把 cacheList.json 当作 参考 而非权威数据,对记录做二次验证。
(1) 记录存在但文件丢失(现象1)→ 命中自愈
-
download:缓存读取失败时, 不再信任cachedFiles缓存记录,仅依赖磁盘文件存在性判断是否需要重复下载丢失的文件。
(2) 文件存在但记录丢失(现象2)→ clearUnreferenced() 回收
- 新增 clearUnreferenced函数: 清除缓存目录中所有未在 cachedFiles 中记录的文件和目录
- 只有在 缓存满
outOfStorage触发clearLRU后才调用 clearUnreferenced - 业务层主动判断缓存可用空间小于30MB时主动 clearLRU 避免频繁触发 clearUnreferenced
- 实在是要溢出了才触发该兜底方案
(3) 减少记录丢失概率
- 防抖写盘改为 onHide 时立即强制
_write()(小游戏切后台前落盘); - 缓存文件名带
time_suffix防时间戳碰撞; - 永久性错误(404 / 文件不存在 / ENOTFOUND)缓存失败直接跳过,不再留脏记录。
(4) 业务层配合
- 新增getCacheSize 函数获得缓存可用空间
- 业务层退出关卡时前用
getCacheSize()判断剩余可用缓存空间,< 阈值主动clearLRU腾空间;
(5) 删改「两阶段」:标记 → 写盘 → 异步删文件 → 删完才移除记录
-
标记:
cachedFiles记录上加isDeleting = true并writeCacheFile(立即持久化); -
删除:异步删文件/目录,成功后
cachedFiles.remove(url)再写盘; - 删除前再次检查
isDeleting:若被访问清除则跳过删除,保全缓存; -
崩溃点全覆盖:
- 标记后崩溃 → 启动时
_cleanupDeletingItems补完删除; - 文件删完记录未删 → 启动扫描
exists发现文件没了,直接移除记录;
- 标记后崩溃 → 启动时
-
clearLRU/clearLRUWithSize/removeCache全部统一走该流程,不再「先删记录后删文件」。
(6) 解压与修改「两阶段」:写 pending 标记 → 解压 → 成功清标记 / 失败清标记+删目录
-
unzipAndCacheBundle解压前pendingBundles[id] = bundleRoot立即写盘; - 解压成功/失败都
clearPending并写盘; -
崩溃点全覆盖:
- 解压中崩溃 → 启动时
_cleanupPendingBundles删整个 bundle 目录 + 相关记录; - 解压目录残留 → 每次解压目标固定
cacheDir/<bundleRoot>/unzip,先rmdirSync清空再解压。
- 解压中崩溃 → 启动时
最终效果
- 任何时刻崩溃/中断,下次启动时用最少的开销让记录与磁盘自动收敛一致;
- 不再出现「缓存记录死循环卡加载」和「游离文件吃满空间」两种只能靠用户清缓存修复的问题。
测试方法
在开发者工具里,游戏运行期间下载 bundle 包文件,然后清空文件缓存目录,模拟游戏途中文件异常丢失的现象。
问题4:bundle 释放后资源 URL 拼成 undefined
现象
玩家切换关卡时加载慢或卡死。
排查过程
因网速超慢,在游戏途中加载背景音乐还没完成时,玩家退出关卡释放音乐所在的 bundle,触发以下错误:
undefined/e9/e91d5c3f-ae7d-4cea-a4ef-680175943d8e47d.mp3 does not exist! Error: file
undefined/e9/e91d5c3f-ae7d-4cea-a4ef-175943d8e47d.mp3 does not exist! at tt
file:///game/adapter-min.js:585:40
undefined 来源:
当远端资源下载好后才跑内部
combine函数,该函数作用是组合 url 路径。因为 bundle 已释放,所以config是空的,base == undefined,导致拼接前缀为undefined。
复现 bug 代码(放到开发者工具命令行执行即可复现):
// 复现bug代码, 复制到小游戏控制台运行
cc.assetManager.loadBundle("Mode180", (err, bundle) => {
// 立即加载远程端 prefab
bundle.load("bgm.mp3", cc.Prefab, (err, k) => { console.log('load:', err, k) });
// 立即释放 bundle(或没等 bgm.mp3 完成就释放)就会触发报错
setTimeout(() => {
bundle.releaseAll();
cc.assetManager.removeBundle(bundle);
}, 1)
})
隐蔽性:
- 触发概率比较低,网速快的时候测不出来;
- 触发错误不会被小游戏平台异常捕获。
解决方法
最简单方法:加载 bundle 后不释放 bundle,只释放 bundle 内的资源。
附录:伪代码解决方案
问题1:大文件(>254KB)下载超时/失败 → 分块断点续传(AssetManager.js)
// ============ 配置 ============
downloader.isSupportResumeZip = true; // 是否启用分块续传
downloader.zipTimeout = 50000; // 每块超时
downloader.zipMaxRetryCount = 50; // 每块最大重试
downloader.zipMaxChunkSize = 254KB; // 分块大小
downloader.zipMaxConcurrentChunks = 6; // 并发块数
// 下载主流程: 仅对 zip 分包走分块; 普通小文件仍用平台 download api
handleZip(url, options, onComplete):
if cachedFiles.has(url): onComplete(null, cached.url); return
if isRemote(url) && downloader.isSupportResumeZip:
downloadToMemory(url, options, onProgress, (err, arrayBuffer) ->
saveArrayBufferToTempFile(arrayBuffer, url, (err, tempPath) ->
unzipAndCacheBundle(url, tempPath, bundleRoot, (unzipErr, unzipPath) ->
deleteTempFile(tempPath)
onComplete(unzipErr, unzipPath)
)
)
)
else:
downloadFile(url, ...) // 原版下载
cacheManager.unzipAndCacheBundle(url, path, bundleRoot, onComplete)
// ============ 分块续传核心 ============
downloadToMemory(url, options, onProgress, onComplete, storedData, retryCount):
state = storedData || { totalBytes: 0, chunks: {}, completedChunks: 0 }
// 第一步: Range: bytes=0-0 探测总大小(从 Content-Range: bytes 0-0/N 取 N)
if state.totalBytes == 0:
request({ url, header: { Range: 'bytes=0-0' }, responseType: 'arraybuffer' })
.success: state.totalBytes = N; startParallelDownload()
.fail: onComplete(Error)
// 第二步: 并发分块下载
startParallelDownload():
numChunks = ceil(totalBytes / chunkSize)
startNextChunk(): // 每次补满 concurrent 个并发
for idx in nextChunks:
request({ url, header: { Range: 'bytes=start-end' }, timeout })
.success: state.chunks[idx] = data; onProgress(已下载/总数)
completedChunks == numChunks ? combineAndComplete() : startNextChunk()
.fail/空数据: retryChunk(idx) // 单独重试该块, 不重下整个文件
retryChunk(idx): retryCount > zipMaxRetryCount ? onComplete(Error) : 1s后重下该块
// 第三步: 合并 + 完整性校验
combineAndComplete():
combined = new Uint8Array(totalBytes)
校验1: 拼接后 offset === state.totalBytes // 长度一致(防止服务端内容变了)
校验2: 前4字节是 ZIP 魔数 PK(0x504B0304/0506/0708)
失败 → onComplete(Error('Download size mismatch' / 'not a valid ZIP'))
成功 → onComplete(null, combined.buffer)
// ============ 写临时文件遇存储满: 清缓存后重试写 ============
saveArrayBufferToTempFile(arrayBuffer, url, onComplete):
writeFile(tempPath, arrayBuffer, 'binary', (err) ->
if err:
if isOutOfStorage(err.message):
cacheManager.clearLRU() // 清缓存
waitForCleanupThenRetry(): // 轮询/延时后重试 doWrite(), 上限50次
else: onComplete(err)
else: onComplete(null, tempPath)
)
问题2:zip 分包解压失败反复下载 / 损坏 zip 污染缓存(cache-manager.js)
// 解压 + 缓存, 关键改动: 失败清缓存后重试解压, 不再重复下载 zip
unzipAndCacheBundle(id, zipFilePath, cacheBundleRoot, onComplete, tryCount=0):
targetPath = cacheDir + '/' + bundleRoot + '/unzip'
rmdirSync(targetPath, true) // 固定目录, 先清空旧的避免残留
makeDirSync(targetPath, true)
// 解压前写 pending 标记(异常退出可检测, 见问题3)
pendingBundles[id] = cacheBundleRoot; writeCacheFile()
unzip(zipFilePath, targetPath, (err) ->
if err:
rmdirSync(targetPath, true); clearPending()
if isOutOfStorage(err.message):
if !autoClear: onComplete(err); return
if tryCount > 5: onComplete(err); return // 重试上限
size = calculateFileSize(zipFilePath) // 按需清出足够空间
clearLRUWithSize(size + 1MB, (err, remaining, caches) ->
1s后 unzipAndCacheBundle(id, zipFilePath, bundleRoot, onComplete, tryCount+1)
)
else: onComplete(err)
else:
size = calculateDirSize(targetPath)
cachedFiles.add(id, { bundle, url: targetPath, lastTime, size })
clearPending(); writeCacheFile()
onComplete(null, targetPath)
)
// 按目标大小清 LRU(原 clearLRU 只能固定清 1/3)
clearLRUWithSize(targetSize, onComplete):
if cleaning: onComplete(Error('cleaning in progress')); return
cleaning = true
// 1. 收集所有非 internal、非 isDeleting、非"使用中bundle"的缓存项(带 size/lastTime)
caches = cachedFiles 过滤: 跳过使用中 = isZip(key) && bundles.find(b => val.url.indexOf(b.base) !== -1)
if totalSize <= targetSize: cleaning=false; onComplete(null, 0)
// 2. 按 lastTime 升序排序
// 3. 逐个累计 size 到 >= targetSize, 收集 removedList(跳过 isDeleting)
// 4. 全部标记 isDeleting → 写盘 → 异步删文件(rmdir/deleteFile) → 删完移除记录
// 5. 清完算剩余 size, 写盘, cleaning=false, onComplete(null, remainingSize)
问题3:缓存记录与磁盘文件不一致(卡死/泄露)→ 自愈机制(cache-manager.js)
// 核心原则: cacheList.json 只做"参考", 以磁盘文件存在性为准; 删除/解压两阶段持久化
// ---------- (a) 版本绑定, 结构不兼容整体重建 ----------
init():
result = readJsonSync(cacheFilePath)
if result 是Error || !result.version || result.version !== cacheManager.version:
rmdirSync(cacheDir, true) // 清空全部
cachedFiles = new Cache(); makeDirSync(cacheDir)
writeFileSync(cacheFilePath, 新结构 + version)
else:
cachedFiles = new Cache(result.files)
pendingBundles = result.pendingBundles || {}
_cleanupPendingBundles() // 见 (d)
_cleanupDeletingItems() // 见 (c)
// ---------- (b) 记录存在但文件丢失(现象1) → 命中自愈 ----------
handleZip(url, options, onComplete):
cached = cachedFiles.get(url)
if cached:
exists(cached.url, (existence) ->
if existence: updateLastTime(url); onComplete(null, cached.url)
else:
cachedFiles.remove(url) // 已确认不存在, 同步删记录(不走异步流程)
writeCacheFile()
onComplete(Error('Cached bundle missing')) // 触发上层重下载
download(url, func, options, onComplete):
result = transformUrl(url, options)
if result.inLocal && result.url.startsWith(cacheDir):
func(result.url, options, (err, data) ->
err ? handleCacheReadError(url, options, onComplete, err) : onComplete(null, data))
else if result.inCache:
updateLastTime(url)
func(result.url, options, (err, data) ->
err ? handleCacheReadError(url, options, onComplete, err) : onComplete(null, data))
handleCacheReadError(url, options, onComplete, err):
if !isNotFoundError(err): // 仅"文件不存在"(no such file/ENOENT/not found)才清理, 权限/磁盘满不误删
onComplete(err); return
bundleRoot = options.__cacheBundleRoot__
if bundleRoot:
收集该bundle下所有记录(key.url 前缀匹配 cacheDir+bundleRoot+'/'), 记录 isZip 存在性
cachedFiles.remove(收集的keys)
if hasZipRecord: rmdirSync(cacheDir + bundleRoot, true) // 删整个 bundle 目录
else:
cachedFiles.remove(url) // 或按 val.url===url 反查删除
writeCacheFile()
onComplete(err)
// ---------- (c) 删除改两阶段 + 崩溃自愈 ----------
removeCache(url):
if !cachedFiles.has(url): return
record = cachedFiles.get(url)
if record.isDeleting: return
record.isDeleting = true // 阶段1: 标记
writeCacheFile(() -> // 写盘(持久化标记)
_deleteCacheFile(url, () -> 删除计数) // 阶段2: 删文件
)
_deleteCacheFile(url, onDone):
record = cachedFiles.get(url)
if !record || !record.isDeleting: // 被访问清除了标记 → 跳过删除, 保全缓存
onDone(); return
path = record.url; isZip = isZipFile(url)
(isZip ? rmdir(path, true, cb) : deleteFile(path, cb))(() ->
cachedFiles.remove(url) // 删完文件才移除记录
writeCacheFile()
onDone()
)
updateLastTime(url) / getCache(url): // 访问时取消删除意图
if cache.isDeleting: cache.isDeleting = false; writeCacheFile()
_cleanupDeletingItems(): // 启动时补完上次删除
toRemove = cachedFiles 中所有 isDeleting 的项
cachedFiles.remove(全部 toRemove) // 先移除记录
逐个 exists(item.url): 存在则 rmdir/deleteFile, 不存在则不管; 完成后 writeCacheFile
clearLRU():
// 选 oldest 1/3, 全部先标记 isDeleting → 写盘 → 每隔 deleteInterval 逐个 _deleteCacheFile
// ---------- (d) 解压中崩溃 → pendingBundles 自愈 ----------
_cleanupPendingBundles(): // init 时调用
for id in pendingBundles:
bundleRoot = pendingBundles[id]
删除该 bundle 下所有缓存记录(url 前缀匹配 cacheDir+bundleRoot)
rmdirSync(cacheDir + '/' + bundleRoot, true) // 含残留不完整文件
pendingBundles = {}; writeCacheFile()
// ---------- (e) 文件存在但记录丢失(现象2) → 扫描回收游离文件 ----------
clearUnreferenced(onComplete):
// 1. 收集引用: cachedFiles 中 url 位于 cacheDir 下的相对路径; zip 解压目录子文件全部视为被引用
// 2. stat(cacheDir, { recursive: true }) 扫描全部条目(兼容微信 item.stats / 抖音 item.stat)
// 3. 过滤: 跳过 cacheList.json / .DS_Store / 被引用路径 / zip 目录内文件
// 4. 目录单独判断: 含被引用子文件则保留
// 5. 先删孤儿文件再删孤儿目录(目录深到浅), 全完成 onComplete
// 触发时机: outOfStorage 触发 clearLRU 之后自动调用; 也可业务主动调用
// ---------- (f) 减少记录丢失 ----------
onHide: 强制 _write()(立即落盘) // 防抖写盘改切后台前强制
缓存文件名: `${time}_${suffix++}${ext}` // 防时间戳碰撞
cacheFile失败: 永久性错误(404/文件不存在/ENOTFOUND)跳过重试, 不留脏记录
问题4:bundle 释放后资源 URL 拼成 undefined/... → 快照重拼(AssetManager.js)
// ---------- 第一步: bundle 加载成功时记录 base + uuid 快照 ----------
recordBundleInfo(bundleName, data):
if !data.uuids: return
bundleBaseMap[bundleName] = { base, importBase, nativeBase } // 记录 base 快照
versions 解析为 importVers/nativeVers
for uuid in data.uuids:
uuidInfoMap[decodeUuid(uuid)] = { bundle: bundleName, ver, nativeVer } // uuid → 所属bundle
// downloadBundle 的三个成功出口都调用 recordBundleInfo(bundleName, data)
// 普通本地 bundle / 远程 bundle / 子包 config 加载成功处各加一行
// ---------- 第二步: download() 检测到 undefined/ 开头 URL 时重拼 ----------
download(url, func, options, onComplete, retryCount=0):
result = transformUrl(url, options)
if result.inLocal && result.url.startsWith('undefined/') && !window.__ASSET_REBUILD_DISABLE__:
uuid = 从 result.url 提取
isJson = /\.jsonc?$/i.test(result.url)
rebuild = null
// 1) 优先: bundle 已注册 → 用它的 config 重拼
ownerBundle = bundles.find(b => b.getAssetInfo(uuid))
if ownerBundle:
info = ownerBundle.getAssetInfo(uuid)
rebuild = { base: ownerBundle.base, importBase, nativeBase, ver: isJson ? info.ver : info.nativeVer }
// 2) bundle 已释放 → 用 uuid 快照重拼
else if uuidInfoMap[uuid]:
snap = uuidInfoMap[uuid]; bbase = bundleBaseMap[snap.bundle]
if bbase: rebuild = { base: bbase.base, importBase, nativeBase, ver: isJson ? snap.ver : snap.nativeVer }
// 3) 都没有 → 短时重试(500ms × 6 ≈ 3s), 等 bundle 注册; 超时 fallthrough 原逻辑
if !rebuild:
if retryCount < 6: setTimeout(() => download(url, func, options, onComplete, retryCount+1), 500); return
onComplete(Error('无法定位资源所属bundle')) // 附 uuid 快照/已注册bundles日志
// 重拼: base + (importBase|nativeBase) + /xx/uuid + (.ver) + ext
url = rebuild.base + prefix + '/' + uuid.slice(0,2) + '/' + uuid + (ver ? '.' + ver : '') + ext
result = transformUrl(url, options)
if result.url 仍是 undefined/: result = { url, inLocal: true, inCache: false }
// 继续正常 inLocal / inCache 流程(见问题3 download 伪代码)
...