后台切换导致资源下载卡死
摘要
| 状态 | ✅ 已修复 |
| 日期 | 2026年1月4日 |
| 影响范围 | Resource-v2 系统(所有风格) |
| 修复类型 | 三层防护机制 |
| OpenSpec | openspec/archive/2026-01-04-fix-background-download-stuck/ |
问题
在 resource-v2 重构后,当应用切到后台再回到前台时,资源加载任务会永久卡住,导致用户无法进入关卡。
触发场景:
- 资源正在下载时,用户切到后台(如查看通知、接电话)
- 后台下载完成
- 用户回到前台
- 资源加载永久卡住,进度条不再更新
问题表现:
- 进度条停在某个百分比,不再继续
- 无法进入关卡(需要等待资源加载完成)
- 用户体验严重受损
根本原因
技术原理
这是一个经典的多线程与事件循环同步问题:
Native AssetsManager 在独立线程中运行
jsb.AssetsManager是 Native 层的 C++ 对象(通过 JSB 绑定)- 在独立的 Native 线程中运行,与 JavaScript 引擎的生命周期无关
- Native 线程在应用进入后台时不会停止
JavaScript 事件循环被暂停
cc.game.pause()调用cancelAnimationFrame(this._intervalId)停止绘制循环- Cocos2d 的事件管理器依赖主循环,无法在后台分发事件
- 所有
setTimeout/requestAnimationFrame回调被推迟
事件回调链完全断裂
- Native 下载完成后触发
jsb.EventListenerAssetsManager事件 - 但
cc.eventManager无法分发事件(主循环已停止) - 整条回调链永远无法执行
- Native 下载完成后触发
完整的断裂流程
下载启动
↓
NativeDownloader.download()
├→ jsb.AssetsManager(manifestPath, storagePath)
├→ jsb.EventListenerAssetsManager 注册回调
└→ cc.eventManager.addListener(listener, 1)
下载进行中(Native 线程)
├→ jsb.AssetsManager 在 Native 线程中下载
└→ Native 线程继续运行
应用切后台
↓
cc.game.EVENT_HIDE 触发
├→ cc.game.pause()
├→ cancelAnimationFrame(this._intervalId) ← JavaScript 主循环停止
└→ jsb.AssetsManager 线程继续运行 ← **问题所在**
下载完成(在后台)
↓
Native 层触发 jsb.EventListenerAssetsManager 事件
↓
❌ cc.eventManager 无法分发事件(主循环已停止)
↓
❌ AssetsManager._listener 回调无法执行
↓
❌ endCallBack(error) 无法调用
↓
❌ NativeDownloader.onComplete 无法调用
↓
❌ ResourceMan._onTaskComplete() 无法执行
↓
❌ DownloadQueue.onTaskComplete() 无法执行
↓
❌ CacheManager.markAsDownloaded() 无法执行
↓
❌ DownloadQueue._scheduleNextQueue() 无法执行
回到前台
↓
cc.game.resume()
↓
❌ 但已经错过的事件永远无法恢复
↓
❌ 下载任务永久卡住关键证据
证据 1:Native 下载不受 JS 控制
javascript
// src/common/asset/AssetsManager.js:246
cc.eventManager.addListener(this._listener, 1);
this._am.update(); // 启动 Native AssetsManager(独立线程)证据 2:JavaScript 主循环被暂停
javascript
// frameworks/cocos2d-html5/CCBoot.js:2342-2355
pause: function () {
if (this._paused) return;
this._paused = true;
// 暂停主循环(requestAnimationFrame)
if (this._intervalId)
window.cancelAnimationFrame(this._intervalId); // 停止绘制循环
this._intervalId = 0;
},证据 3:事件监听器依赖主循环
javascript
// src/common/asset/AssetsManager.js:144-148
this._listener = new jsb.EventListenerAssetsManager(this._am, function (event) {
var _codeStr = that.getCodeTrans(event.getEventCode());
cc.log("EventListenerAssetsManager event: " + _codeStr);
// 这个回调依赖 cc.eventManager,主循环暂停时无法执行
});解决方案
三层防护机制
第一层:阻止后台启动新任务
原理:后台时标记 _isBackgroundMode = true,_processQueue() 检查标记,阻止新任务启动。
实现:
javascript
// LogicMan.js - gameOnHide
if (game.ResourceMan) {
var resourceMan = game.ResourceMan.getInstance();
if (resourceMan && resourceMan.getDownloadQueue) {
resourceMan.getDownloadQueue().onEnterBackground();
}
}
// DownloadQueue.js - _processQueue
_processQueue: function () {
if (this._isBackgroundMode) {
cc.log(_G('Skip processing queue in background mode'));
return;
}
// ... 原有逻辑
}效果:
- ✅ 后台时不启动新下载
- ✅ 已启动的下载继续运行(无法停止,也不需要停止)
- ✅ 防止问题扩散
第二层:前台恢复时状态查询 + 回调补偿
原理:使用 jsb.AssetsManager.getState() 查询 Native 真实状态,检测到下载已完成则手动触发完成回调。
实现:
javascript
// LogicMan.js - gameOnShow
if (game.ResourceMan) {
var resourceMan = game.ResourceMan.getInstance();
if (resourceMan && resourceMan.getDownloadQueue) {
resourceMan.getDownloadQueue().onEnterForeground();
}
}
// DownloadQueue.js - _checkAndResumeDownloads
_checkAndResumeDownloads: function() {
for (var i = 0; i < downloadingTasks.length; i++) {
var taskId = downloadingTasks[i];
var task = this._downloadingMap[taskId];
var adapter = this._getAdapterForTask(task);
var actualState = adapter.checkTaskState(task);
if (actualState === 'completed') {
// 手动触发完成回调
this.onTaskComplete(task, null);
} else if (actualState === 'failed') {
this.onTaskComplete(task, 'Download failed in background');
}
}
}
// NativeDownloader.js - checkTaskState
checkTaskState: function(task) {
var assetsManager = this._assetsManagerMap[task.id];
var state = assetsManager.getState();
if (state === jsb.AssetsManager.State.UP_TO_DATE) {
return 'completed';
} else if (state === jsb.AssetsManager.State.FAIL_TO_UPDATE) {
return 'failed';
}
// ... 其他状态映射
}效果:
- ✅ 自动恢复后台完成的下载
- ✅ 补偿错过的回调
- ✅ 解决根本问题
第三层:卡死任务检测和重试
原理:检查任务是否长时间(60秒)无进度,超时则清理并重试。
实现:
javascript
// DownloadQueue.js - _handleStuckTask
_handleStuckTask: function(task) {
delete this._downloadingMap[task.id];
this._downloadingCount--;
var adapter = this._getAdapterForTask(task);
adapter.cancel(task.id);
task.reset();
task.priority = Math.max(0, task.priority - 100); // 提升优先级
this.addTask(task);
}效果:
- ✅ 兜底机制
- ✅ 自动清理异常任务
- ✅ 提升优先级重试
关键技术细节
防抖机制(500ms 延迟)
防止频繁前后台切换导致重复处理:
javascript
onEnterForeground: function() {
var timeSinceLastResume = now - this._lastResumeTime;
if (timeSinceLastResume < this._resumeDebounceDelay) {
if (this._resumeTimer) clearTimeout(this._resumeTimer);
this._resumeTimer = setTimeout(function() {
self._performResume();
}, this._resumeDebounceDelay);
return;
}
this._performResume();
}幂等性保护
防止同一任务被重复处理:
javascript
_checkAndResumeDownloads: function() {
for (var i = 0; i < downloadingTasks.length; i++) {
var task = this._downloadingMap[taskId];
if (task._isResumeProcessing) {
continue; // 跳过正在处理的任务
}
task._isResumeProcessing = true;
try {
// ... 状态查询和恢复
} finally {
task._isResumeProcessing = false;
}
}
}状态查询缓存(500ms 有效期)
减少频繁调用 Native API 的开销:
javascript
checkTaskState: function(task) {
var now = Date.now();
if (task._lastStateCheckTime &&
now - task._lastStateCheckTime < 500 &&
task._lastState) {
return task._lastState; // 返回缓存
}
var state = assetsManager.getState();
// ... 状态映射
task._lastStateCheckTime = now;
task._lastState = result;
return result;
}修改文件
| 文件 | 修改内容 | 代码行数 |
|---|---|---|
| src/common/asset/AssetsManager.js | 添加 getState() 包装方法 | +12 |
| src/common/model/LogicMan.js | gameOnHide/Show 添加队列通知 | +16 |
| src/resource_v2/adapters/NativeDownloader.js | 添加状态查询 + 监听器重附加 | +76 |
| src/resource_v2/core/DownloadQueue.js | 添加后台模式 + 状态检查 + 卡死处理 | +207 |
总计:311 行代码
jsb.AssetsManager.State 状态映射
Native State → JavaScript 判断
─────────────────────────────────────────────────
UP_TO_DATE (10) → 'completed'
FAIL_TO_UPDATE (11) → 'failed'
UPDATING (8) → 'downloading'
UNZIPPING (9) → 'downloading'
DOWNLOADING_VERSION (2) → 'downloading'
DOWNLOADING_MANIFEST (5) → 'downloading'
其他 → 'unknown'验证测试
基础场景(待测试)
- [ ] 测试场景 1:后台完成下载(小资源)
- [ ] 测试场景 2:后台仍在下载(大资源)
- [ ] 测试场景 3:后台下载失败(模拟网络故障)
- [ ] 测试场景 4:多任务混合(完成、失败、进行中)
频繁切换测试(待测试)
- [ ] 快速连续切换(< 500ms)验证防抖
- [ ] 中等间隔切换(1-2 秒)验证正常恢复
- [ ] 极端频繁切换(< 100ms)验证幂等性保护
边界情况测试(待测试)
- [ ] 验证 jsb.AssetsManager.State 枚举值
- [ ] 测试状态查询异常处理
- [ ] 测试监听器重复附加保护
- [ ] 测试回调重复触发保护
技术约束
不能修改 Native 层
原因:无法更新安装包
解决:使用现有 jsb.AssetsManager.getState() API
后台下载继续
现象:后台时 Native 下载仍在继续
影响:可能消耗电量和流量
决策:可接受
- Native 层不支持暂停/取消
- 好处:资源提前下载完成,前台时立即可用
无法停止已启动的下载
原因:jsb.AssetsManager API 缺失
- ❌ 无
pause()方法 - ❌ 无
resume()方法 - ❌ 无
cancel()方法 - ✅ 仅有
update()启动和purge()释放
解决:采用"阻止新任务 + 状态查询"策略
相关问题
事件监听器泄漏导致崩溃
问题:LoadingProgressIndicatorController 的 Node 被系统回收,但 LoaderEventBus 仍持有监听器引用,导致野指针访问 SIGSEGV 崩溃。
修复(同一次提交):
- 主动清理:在
LogicMan.gameOnHide()中清理 ResourceMan 事件监听器 - 防御性检查:在
LoaderEventBus.emit()中使用cc.sys.isObjectValid()检查 context 有效性
修改文件:
src/common/model/LogicMan.js- +20 行src/resource_v2/core/LoaderEventBus.js- +9 行
提交记录
OpenSpec 文档:
- commit:
c17e1c8fe47 - 文件:528 行(proposal.md, tasks.md, design.md, spec.md)
后台下载卡死修复:
- commit:
e2a88ea51da - 文件:311 行(AssetsManager.js, LogicMan.js, NativeDownloader.js, DownloadQueue.js)
事件监听器泄漏修复:
- commit:
3721e586eff - 文件:29 行(LogicMan.js, LoaderEventBus.js)
经验总结
- 多线程同步问题:Native 和 JavaScript 层生命周期不同步,需要主动查询状态
- 事件循环依赖:不能假设所有回调都会被触发,需要补偿机制
- 防抖 + 幂等性:处理频繁前后台切换的标准方案
- 三层防护:预防 + 恢复 + 兜底,最大化可靠性
- 状态查询缓存:减少 Native API 调用开销
- 现有 API 利用:
getState()API 存在但从未使用,避免了修改 Native 层
参考资料
- OpenSpec 归档:
openspec/archive/2026-01-04-fix-background-download-stuck/ - 技术设计文档:
openspec/archive/2026-01-04-fix-background-download-stuck/design.md - 实现任务清单:
openspec/archive/2026-01-04-fix-background-download-stuck/tasks.md - 规范变更:
openspec/archive/2026-01-04-fix-background-download-stuck/specs/resource-management/spec.md