Skip to content

Stage03|Runtime Objects & Assets ​

Lesson031|异步加载、预加载、进度和回调竞态 ​

Tags: #Creator2.x #Stage03 #AsyncLoad #Preload #RaceConditionDifficulty: ⭐⭐⭐⭐☆


真实问题:进度条到 100%,页面为什么还卡了两秒 ​

活动页进入前调用 preload,进度条显示 100% 后立刻隐藏加载页。玩家看到的却是空背景,随后图集、Prefab 和 Label 才陆续出现。

这里混淆了四个完成点:

text
下载/读取完成
→ Asset 及依赖准备完成
→ Prefab instantiate 和 UI 绑定完成
→ Renderer 真正提交出首个完整画面

预加载只负责其中一部分。进度回调也只是加载任务的观察值,不是产品体验的绝对百分比。

一、本课目标 ​

资源加载经常和页面切换、加载进度和用户操作同时发生。本课解决:

  • preload 和正式 load 的区别。
  • 如何管理加载进度。
  • 为什么旧回调会覆盖新页面。
  • 如何取消、忽略或合并过期请求。

二、预加载和正式加载 ​

在 Creator 2.4.x 的 Asset Manager 中,应把两者明确拆开:

text
preload:提前下载资源及必要依赖,不做完整反序列化和初始化
load:取得下载结果,继续解析、反序列化和初始化,最后返回 Asset

例如进入战斗前:

text
大厅空闲阶段
→ 预加载战斗资源
→ 点击开始
→ 尽量减少首帧等待

因此 preload 主要前移网络或文件读取等待,不代表 Prefab 已经反序列化、Texture 已经上传 GPU,也不代表业务已经持有可直接使用的 Asset。下载缓存仍可能占用磁盘或平台缓存空间,但不能把它和完整运行时资源内存混为一谈。

版本边界:这里描述的是 Creator 2.4.x Asset Manager。旧版 cc.loader、平台缓存和自定义下载管线可能有不同表现。

官方版本依据:Asset Manager|Loading and Preloading。


三、进度不是简单的文件数量 ​

如果资源依赖不同大小,加载 5 个文件不代表每个占 20%:

text
小图标
大纹理
字体
图集
音频

更合理的进度模型可能按:

  • 资源数量。
  • 估算字节数。
  • 依赖任务权重。
  • 已完成和正在解码的阶段。

产品进度条应明确是“加载任务进度”还是“用户感知进度”,不必伪装成精确磁盘百分比。


四、请求竞态是什么 ​

text
用户打开页面 A
→ 请求资源 A
用户快速切到页面 B
→ 请求资源 B
资源 B 先返回
→ 页面 B 显示
资源 A 后返回
→ 旧回调覆盖页面 B

这就是异步回调竞态。解决方案包括:

text
请求 ID
页面生命周期标记
取消加载句柄(版本支持时)
请求合并
结果归属校验

五、请求 ID 模式 ​

ts
private requestId = 0;

load(path: string) {
    const id = ++this.requestId;

    cc.resources.load(path, cc.SpriteFrame, (err, frame) => {
        if (id !== this.requestId || !this.isValid) {
            return;
        }

        if (err) {
            this.showError();
            return;
        }

        this.sprite.spriteFrame = frame;
    });
}

新请求产生时,旧请求即使不能真正取消,也可以让它的结果失效。


六、预加载的工程边界 ​

适合预加载:

  • 下一场景必需的大资源。
  • 用户马上会看到的 UI。
  • 已知路线中的核心内容。

不适合盲目预加载:

  • 所有活动和所有关卡。
  • 低概率使用的大图集。
  • 只为避免一次回调而加载的资源。

预加载需要同时看下载时机、网络并发、缓存空间和后续 load 的实际命中收益;完整运行时内存峰值仍要在正式加载、反序列化和实例化阶段测量。


七、常见误区 ​

误区一:预加载就是免费优化 ​

它主要改变下载成本发生时间,可能占用网络、IO 和下载缓存;正式解析、初始化和运行时内存成本通常仍发生在后续 load。

误区二:进度条到 100% 就代表首帧已经完成 ​

资源加载之后还可能有反序列化、实例创建、布局和纹理上传。

误区三:异步回调返回顺序和请求顺序一致 ​

不同资源大小和缓存状态会导致返回顺序不同。

误区四:旧请求只能通过强制取消解决 ​

如果版本或 Loader 不支持取消,可以用请求 ID 忽略过期结果。


八、练习与答案 ​

练习 ​

  1. 预加载改变了什么?
  2. 为什么资源数量不能直接作为精确进度?
  3. 如何防止旧页面回调更新新页面?
  4. 什么时候不应该预加载?

参考答案 ​

  1. 在 Creator 2.4.x 中主要把资源下载提前;解析、反序列化和初始化仍由后续 load 完成。
  2. 资源大小、依赖和解码成本不同。
  3. 使用请求 ID、生命周期状态或取消句柄校验结果归属。
  4. 低概率、大体积、会明显增加启动时间或内存峰值的资源。

Creator 2.4.x 编辑器实验:预加载不等于页面完成 ​

通过 Creator 编辑器准备实验资源。不要修改 .meta、序列化资源或构建缓存。

准备 ​

在 assets/resources/preload-lab/ 中准备三张不同尺寸图片和一个引用它们的 Prefab。创建空场景与进度 Label。

PreloadProbe.ts ​

ts
const { ccclass, property } = cc._decorator;

@ccclass
export default class PreloadProbe extends cc.Component {
    @property(cc.Label)
    progressLabel: cc.Label = null;

    @property(cc.Node)
    container: cc.Node = null;

    private generation = 0;

    preloadPanel() {
        const id = ++this.generation;
        const begin = Date.now();

        cc.resources.preload(
            'preload-lab/panel',
            cc.Prefab,
            (completed: number, total: number) => {
                if (id !== this.generation) {
                    return;
                }
                this.progressLabel.string =
                    completed + ' / ' + total;
            },
            (err) => {
                cc.log('[preload] complete', Date.now() - begin);
                if (err || id !== this.generation) {
                    return;
                }
                this.loadAndShow(id);
            }
        );
    }

    private loadAndShow(id: number) {
        cc.resources.load(
            'preload-lab/panel',
            cc.Prefab,
            (err, prefab: cc.Prefab) => {
                if (err
                    || id !== this.generation
                    || !cc.isValid(this.node)) {
                    return;
                }

                const panel = cc.instantiate(prefab);
                this.container.addChild(panel);
                cc.log('[preload] instantiated');

                this.scheduleOnce(() => {
                    cc.log('[preload] next frame');
                }, 0);
            }
        );
    }

    cancelView() {
        this.generation++;
        this.container.removeAllChildren();
    }
}

不同 2.4.x 小版本的 overload 类型可能略有差异,以项目声明文件为准。

观察点 ​

  1. 记录 onProgress 是否均匀增长。
  2. 记录 preload complete 与 load callback 的间隔。
  3. 记录 instantiate 和 next frame。
  4. 预加载途中点击取消,再确认旧结果不显示。

预期结论:

text
进度增量不保证均匀
preload 完成不等于节点已创建
节点创建不等于首帧已经呈现
取消视图后底层任务可能完成,但旧结果应失效

为什么进度不能直接当字节百分比 ​

进度回调中的 completed/total 可能对应任务或资源项,单个任务成本不同:

text
小 JSON:1 个任务,几 KB
大 Texture:1 个任务,数 MB + 解码
Prefab:1 个入口,背后还有多项依赖

因此 9/10 不一定只剩 10% 时间。产品进度条常需要:

  • 对阶段加权,而不是直接显示内部计数;
  • 防止数值倒退;
  • 最小展示时间,避免闪烁;
  • 失败和重试状态;
  • 资源完成后追加初始化阶段;
  • 不伪装成精确下载字节,除非确实拿到字节数据。

竞态的三种不同形态 ​

旧请求覆盖新请求 ​

搜索 A 后立刻搜索 B,A 后返回,把 B 的界面覆盖。

页面退出后结果返回 ​

组件销毁或进入对象池,回调继续修改旧引用。

多个并行任务只失败一部分 ​

页面等待 A、B、C,A/B 成功而 C 失败;如果只统计完成数量,可能错误进入 ready。

聚合状态应明确:

text
idle
→ preloading
→ loading
→ constructing
→ ready
或 failed / cancelled

真实项目故障:赛季页偶尔显示上一赛季背景 ​

时间线 ​

text
请求 season/12
→ 用户切换到 season/13
→ 13 命中缓存先返回
→ UI 显示 13
→ 12 的远程请求后返回
→ UI 又被覆盖成 12

仅检查 cc.isValid(this.node) 不够,因为页面仍然活着,只是请求语义过期。

必须比较请求版本或业务键:

ts
private selectedSeason = 0;

showSeason(id: number) {
    this.selectedSeason = id;
    const path = 'season/' + id + '/background';

    cc.resources.load(
        path,
        cc.SpriteFrame,
        (err, frame: cc.SpriteFrame) => {
            if (err || this.selectedSeason !== id) {
                return;
            }
            this.background.spriteFrame = frame;
        }
    );
}

实验失败诊断 ​

preload 完成后 load 仍很慢 ​

确认两次使用相同 Bundle、逻辑路径和类型;检查资源是否在 preload 后被释放;再区分耗时是在 load callback 之前,还是 instantiate、布局、Label 或首帧渲染阶段。

进度长期不变化 ​

不要用定时器伪造底层进度来掩盖卡住。记录当前任务、网络状态、Bundle、错误回调和超时策略。产品层可以平滑显示,但诊断层必须保留真实数据。

取消后仍创建节点 ​

确认每一个异步边界都检查 generation,而不是只在最外层检查一次;检查是否有另一个回调入口绕开状态机。

深化练习与答案 ​

  1. 为什么“强制取消底层请求”不是解决竞态的唯一方法? 因为底层 API 可能不支持取消,且共享请求不应被单个页面终止。让过期结果失去提交资格,通常就能保证业务正确性。
  2. 进度到 100% 后还应有哪些阶段? 至少包括正式取得 Asset、instantiate、数据绑定、布局/渲染准备以及首帧呈现;项目可把它们组合为加权阶段。
  3. 三个并行任务中一个失败,页面状态怎么设计? 先定义该资源是否必需。必需项失败进入 failed 并提供回退/重试;可选项使用占位资源,同时记录降级,不能只靠 completed === total。
  4. 为什么请求 ID 比单个 loading 布尔值更强? 布尔值只能表示是否有任务,不能区分哪次任务拥有当前结果;ID 能判断回调属于哪个代次。

九、本课总结 ​

text
Creator 2.4.x 的 preload 主要提前下载,不等于完成 load。
它仍会消耗网络、IO 和缓存空间,但不要把它直接等同于完整 Asset 内存。
进度条应反映任务模型,而不是虚假的文件百分比。
异步请求必须处理返回乱序和页面切换竞态。
请求 ID 是一种简单可靠的过期结果防护方式。

十、下一课预告 ​

text
Lesson032|资源依赖图、缓存与引用关系