Skip to content

后台切换导致资源下载卡死

摘要

状态✅ 已修复
日期2026年1月4日
影响范围Resource-v2 系统(所有风格)
修复类型三层防护机制
OpenSpecopenspec/archive/2026-01-04-fix-background-download-stuck/

问题

在 resource-v2 重构后,当应用切到后台再回到前台时,资源加载任务会永久卡住,导致用户无法进入关卡。

触发场景

  • 资源正在下载时,用户切到后台(如查看通知、接电话)
  • 后台下载完成
  • 用户回到前台
  • 资源加载永久卡住,进度条不再更新

问题表现

  • 进度条停在某个百分比,不再继续
  • 无法进入关卡(需要等待资源加载完成)
  • 用户体验严重受损

根本原因

技术原理

这是一个经典的多线程与事件循环同步问题

  1. Native AssetsManager 在独立线程中运行

    • jsb.AssetsManager 是 Native 层的 C++ 对象(通过 JSB 绑定)
    • 在独立的 Native 线程中运行,与 JavaScript 引擎的生命周期无关
    • Native 线程在应用进入后台时不会停止
  2. JavaScript 事件循环被暂停

    • cc.game.pause() 调用 cancelAnimationFrame(this._intervalId) 停止绘制循环
    • Cocos2d 的事件管理器依赖主循环,无法在后台分发事件
    • 所有 setTimeout/requestAnimationFrame 回调被推迟
  3. 事件回调链完全断裂

    • Native 下载完成后触发 jsb.EventListenerAssetsManager 事件
    • cc.eventManager 无法分发事件(主循环已停止)
    • 整条回调链永远无法执行

完整的断裂流程

下载启动

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.jsgameOnHide/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 崩溃。

修复(同一次提交):

  1. 主动清理:在 LogicMan.gameOnHide() 中清理 ResourceMan 事件监听器
  2. 防御性检查:在 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)

经验总结

  1. 多线程同步问题:Native 和 JavaScript 层生命周期不同步,需要主动查询状态
  2. 事件循环依赖:不能假设所有回调都会被触发,需要补偿机制
  3. 防抖 + 幂等性:处理频繁前后台切换的标准方案
  4. 三层防护:预防 + 恢复 + 兜底,最大化可靠性
  5. 状态查询缓存:减少 Native API 调用开销
  6. 现有 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

Released under the MIT License.