外观
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 忽略过期结果。
八、练习与答案
练习
- 预加载改变了什么?
- 为什么资源数量不能直接作为精确进度?
- 如何防止旧页面回调更新新页面?
- 什么时候不应该预加载?
参考答案
- 在 Creator 2.4.x 中主要把资源下载提前;解析、反序列化和初始化仍由后续
load完成。 - 资源大小、依赖和解码成本不同。
- 使用请求 ID、生命周期状态或取消句柄校验结果归属。
- 低概率、大体积、会明显增加启动时间或内存峰值的资源。
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 类型可能略有差异,以项目声明文件为准。
观察点
- 记录 onProgress 是否均匀增长。
- 记录 preload complete 与 load callback 的间隔。
- 记录 instantiate 和 next frame。
- 预加载途中点击取消,再确认旧结果不显示。
预期结论:
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,而不是只在最外层检查一次;检查是否有另一个回调入口绕开状态机。
深化练习与答案
- 为什么“强制取消底层请求”不是解决竞态的唯一方法? 因为底层 API 可能不支持取消,且共享请求不应被单个页面终止。让过期结果失去提交资格,通常就能保证业务正确性。
- 进度到 100% 后还应有哪些阶段? 至少包括正式取得 Asset、instantiate、数据绑定、布局/渲染准备以及首帧呈现;项目可把它们组合为加权阶段。
- 三个并行任务中一个失败,页面状态怎么设计? 先定义该资源是否必需。必需项失败进入 failed 并提供回退/重试;可选项使用占位资源,同时记录降级,不能只靠 completed === total。
- 为什么请求 ID 比单个 loading 布尔值更强? 布尔值只能表示是否有任务,不能区分哪次任务拥有当前结果;ID 能判断回调属于哪个代次。
九、本课总结
text
Creator 2.4.x 的 preload 主要提前下载,不等于完成 load。
它仍会消耗网络、IO 和缓存空间,但不要把它直接等同于完整 Asset 内存。
进度条应反映任务模型,而不是虚假的文件百分比。
异步请求必须处理返回乱序和页面切换竞态。
请求 ID 是一种简单可靠的过期结果防护方式。十、下一课预告
text
Lesson032|资源依赖图、缓存与引用关系