Skip to content

Stage03|Runtime Objects & Assets ​

Lesson036|阶段复习:从磁盘资源到运行时对象,再到安全释放 ​

Tags: #Creator2.x #Stage03 #Scene #Prefab #Asset #MemoryDifficulty: ⭐⭐⭐⭐☆


阶段挑战:你能解释一次活动页的完整生命吗 ​

最终项目中,活动页不是“加载 Prefab 然后显示”这么简单。它经历:

text
源图片和 Prefab 被 AssetDB 导入
→ UUID 与序列化引用建立
→ 构建系统把活动 Bundle 发布
→ 运行时加载 Bundle 和 Prefab
→ instantiate 还原 Node/Component
→ 组件建立事件与资源引用
→ Renderer 使用 SpriteFrame/Texture
→ 页面关闭并清理监听
→ 节点销毁
→ 资源所有者归还引用

任何一步含糊,都会在另一端表现为丢引用、竞态、重复回调、黑图或内存增长。

本课不是把 L022~L035 再列一遍,而是要求你用一个可运行项目把整条链串起来,并能根据证据定位故障层。

一、本课目标 ​

本课串联 Lesson022~Lesson035,完成 Runtime Objects & Assets 阶段闭环。

核心问题是:

一个编辑器资源,如何变成运行时对象,被场景使用,最后安全退出?


二、阶段完整数据流 ​

text
源文件
    ↓
AssetDB 导入
    ↓
.meta / UUID / 子资源
    ↓
构建 / Bundle / 远程分发
    ↓
Loader 按路径加载
    ↓
依赖图和缓存
    ↓
Scene / Prefab 反序列化
    ↓
Node / Component 实例
    ↓
生命周期、事件、渲染和业务使用
    ↓
解除引用、销毁对象、释放资源

这条链路中,每一层都有自己的身份、缓存和生命周期。


三、Scene、Prefab、Node 的关系 ​

text
Scene / Prefab:序列化描述
Node:运行时层级和空间对象
Component:挂载能力
Asset:被组件引用的资源

实例化时:

text
Prefab 描述
→ 新 Node 树
→ 新 Component 实例
→ 共享或恢复 Asset 引用

运行时修改实例状态不会自动修改 Prefab 默认值。


四、UUID、.meta 和 AssetDB ​

text
资源文件
↔ .meta / UUID
↔ AssetDB 索引
↔ Scene / Prefab 引用

稳定原则:

  • 替换已有资源内容时保留原 .meta。
  • 新资源让 Creator 生成新的 .meta。
  • 不手工伪造 UUID。
  • 移动和重命名资源后等待编辑器导入完成。

五、动态加载的安全模型 ​

ts
load(path, callback)

必须同时考虑:

text
路径和类型
    ↓
缓存和依赖
    ↓
异步返回顺序
    ↓
页面是否仍有效
    ↓
加载失败和回退

一段正确的资源代码不只是“能拿到资源”,还要能处理过期请求和失败状态。


六、事件和场景生命周期 ​

节点事件依赖节点树,资源异步回调依赖请求和对象生命周期:

text
页面打开
→ 注册事件
→ 发起加载
→ 显示资源
→ 页面关闭
→ 取消事件
→ 忽略过期回调
→ 解除资源引用

如果只清理显示节点,不清理事件和异步关系,场景仍可能被旧对象或闭包持有。


七、阶段综合案例:活动页面 ​

资源结构 ​

text
activity.prefab
├── ActivityPanel
├── Banner SpriteFrame
├── Reward Icon SpriteFrame
└── ActivityController

进入页面 ​

text
加载 Bundle / Prefab
→ 加载 Texture、SpriteFrame、配置
→ instantiate
→ 设置 parent
→ 绑定组件引用
→ onLoad / onEnable
→ 显示页面

页面关闭 ​

text
active=false 或 destroy
→ 取消事件和定时器
→ 使异步请求失效
→ 解除页面引用
→ 判断资源是否仍被其他页面使用
→ 按缓存策略释放

八、阶段练习 ​

题目一 ​

为什么一个 20 KB 的 Prefab 可能加载出很大的内存峰值?

题目二 ​

移动一张已被多个 Prefab 引用的图片时,为什么不能直接删除 .meta?

题目三 ​

页面 A 请求图片后切到页面 B,A 的回调晚于 B 返回,应该如何处理?

题目四 ​

销毁页面节点后,为什么对应 Texture 仍可能留在内存?

题目五 ​

对象池为什么会降低创建成本,同时增加资源管理难度?


九、阶段练习参考答案 ​

题目一 ​

Prefab 可能依赖大纹理、字体、音频、材质和其他资源,文件大小不能代表完整依赖图。

题目二 ​

.meta 中包含 UUID 和导入设置,删除后可能生成新身份,使已有 Scene / Prefab 引用失效。

题目三 ​

使用请求 ID、页面生命周期标记或取消句柄,确保只有当前有效请求能够应用结果。

题目四 ​

其他页面、Loader 缓存、Bundle、异步回调或对象池仍可能持有资源引用。

题目五 ​

池化复用 Node 和 Component,减少分配和生命周期成本,但池子会保留对象、组件和资源引用。


十、Stage03 面试题 ​

1. Scene 文件和运行时 Scene 有什么区别? ​

前者是序列化数据,后者是引擎创建并管理的运行时对象树。

2. Prefab 实例之间共享什么? ​

Node、Component 和业务状态通常独立,Texture、SpriteFrame 等资源可能共享。

3. AssetDB 和 Loader 的区别是什么? ​

AssetDB 主要负责编辑器导入、UUID 和索引,Loader 负责运行时加载、缓存和依赖处理。

4. 为什么 destroy 不等于 release asset? ​

destroy 主要结束 Node / Component 生命周期,资源还可能被其他对象或缓存使用。

5. 为什么动态加载需要生命周期校验? ​

异步结果可能晚于页面或对象的有效期,必须防止旧回调写入无效对象。

6. 为什么 Atlas 既能优化也能浪费? ​

它可以减少纹理切换和帮助合批,但加载一个小图可能带来整张大纹理的内存成本。


阶段综合实验:SeasonPanel 生命周期实验室 ​

资源和序列化对象全部通过 Creator 2.4.x 编辑器创建、绑定和移动。不要手工修改 .meta、.scene、.prefab、.plist 或生成目录。

一、资源结构 ​

通过编辑器建立:

text
assets/
├── resources/
│   └── stage03-lab/
│       ├── SeasonPanel.prefab
│       ├── season-bg.png
│       └── reward-icon.png
└── scripts/
    ├── SeasonPanel.ts
    └── Stage03Lab.ts

Prefab 结构:

text
SeasonPanel
├── Background/Sprite
├── Title/Label
├── RewardIcon/Sprite
├── ClaimButton/Button
└── CloseButton/Button

在 Inspector 中绑定 Prefab 内部 Label、Sprite 和按钮节点引用。不要在脚本中硬编码 UUID。

二、Panel 组件 ​

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

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

    @property(cc.Sprite)
    rewardIcon: cc.Sprite = null;

    private bindVersion = 0;
    private seasonId = 0;

    configure(seasonId: number) {
        this.seasonId = seasonId;
        const version = ++this.bindVersion;
        this.titleLabel.string = 'Season ' + seasonId;

        cc.resources.load(
            'stage03-lab/reward-icon',
            cc.SpriteFrame,
            (err, frame: cc.SpriteFrame) => {
                if (err) {
                    cc.error('[SeasonPanel] icon failed', err);
                    return;
                }
                if (version !== this.bindVersion
                    || !cc.isValid(this.node)) {
                    return;
                }
                this.rewardIcon.spriteFrame = frame;
            }
        );
    }

    onEnable() {
        this.node.on(
            'season-refresh',
            this.onSeasonRefresh,
            this
        );
    }

    onDisable() {
        this.node.off(
            'season-refresh',
            this.onSeasonRefresh,
            this
        );
        this.bindVersion++;
    }

    private onSeasonRefresh() {
        cc.log('[SeasonPanel] refresh', this.seasonId);
    }
}

这里故意使用 Node 自定义事件做局部实验。真实全局服务应使用项目统一 EventBus,并保持相同的订阅/取消原则。

三、入口与资源所有者 ​

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

@ccclass
export default class Stage03Lab extends cc.Component {
    @property(cc.Node)
    container: cc.Node = null;

    private panelPrefab: cc.Prefab = null;
    private panel: cc.Node = null;
    private openVersion = 0;

    openSeason(seasonId: number) {
        const version = ++this.openVersion;

        cc.resources.load(
            'stage03-lab/SeasonPanel',
            cc.Prefab,
            (err, prefab: cc.Prefab) => {
                if (err) {
                    cc.error('[Stage03Lab] prefab failed', err);
                    return;
                }
                if (version !== this.openVersion
                    || !cc.isValid(this.node)) {
                    return;
                }

                if (!this.panelPrefab) {
                    this.panelPrefab = prefab;
                    this.panelPrefab.addRef();
                }

                this.closePanelOnly();
                this.panel = cc.instantiate(prefab);
                this.container.addChild(this.panel);

                const view = this.panel.getComponent(SeasonPanel);
                view.configure(seasonId);
                cc.log('[Stage03Lab] opened', seasonId);
            }
        );
    }

    closePanelOnly() {
        this.openVersion++;
        if (this.panel && cc.isValid(this.panel)) {
            this.panel.destroy();
        }
        this.panel = null;
    }

    releaseLab() {
        this.closePanelOnly();
        if (this.panelPrefab) {
            this.panelPrefab.decRef();
            this.panelPrefab = null;
        }
    }

    onDestroy() {
        this.releaseLab();
    }
}

注意:openSeason 内调用 closePanelOnly 会再次增加 openVersion,导致当前 version 失效的风险。这个代码故意留下一个可诊断缺陷。

正确做法是拆分“关闭旧实例”和“取消当前打开请求”:

ts
private destroyCurrentPanel() {
    if (this.panel && cc.isValid(this.panel)) {
        this.panel.destroy();
    }
    this.panel = null;
}

closePanel() {
    this.openVersion++;
    this.destroyCurrentPanel();
}

在 load 回调中调用 destroyCurrentPanel,不能调用会递增请求版本的公开 closePanel。这体现了状态机方法必须区分“内部状态转换”和“用户取消命令”。

四、实验步骤 ​

  1. 打开 Season 1,观察 Prefab 加载、instantiate 和内部图标回调。
  2. 关闭页面,确认 onDisable 执行且异步代次失效。
  3. 快速连续打开 Season 2、Season 3,最终只允许 Season 3 提交。
  4. 重复开关五次,确认一次 refresh 只打印一次。
  5. 只 destroy Panel,确认 Lab 仍持有 Prefab。
  6. 调用 releaseLab,确认所有者归还 Prefab 引用。
  7. 在 Creator 中重命名 reward-icon,通过实验观察路径字符串失效;再恢复代码路径。
  8. 使用错误类型加载 Prefab,记录 err 分支。

五、每步应该看到什么 ​

text
静态内部引用:instantiate 后立即恢复
动态图标:load 回调后赋值
快速切换:旧 season 结果被 generation 拒绝
关闭页面:节点生命周期结束,Prefab Asset 所有权仍独立存在
releaseLab:只归还 Lab 自己 addRef 的引用
资源重命名:UUID 静态引用与动态路径表现不同

Stage03 故障定位矩阵 ​

现象优先层级第一批证据
Inspector 字段为 null序列化/脚本字段组件类型、字段名、编辑器绑定、脚本编译
resources.load 找不到运行时路径resources 根、路径、扩展名、大小写、类型
Prefab 创建后缺组件instantiate/资源版本Prefab 实际内容、脚本注册、错误日志
点击一次执行多次事件生命周期on/off 次数、target/currentTarget、Button ClickEvents
快速切换显示旧图异步竞态请求 ID、业务键、回调完成顺序
关闭页面后黑图资源所有权addRef/decRef 调用栈、共享依赖、仍活跃 Sprite
节点消失但内存不降多层生命周期Node 数、Asset 缓存、Texture、对象池、循环平台
构建包找不到远程资源Bundle/发布Bundle 版本、最终 URL、状态码、CDN/本地缓存

矩阵的作用是确定第一组证据,不是看到某现象就直接下结论。

阶段真实案例:节日活动热更后偶发黑屏 ​

现象组合 ​

  • 新 Bundle 能下载;
  • Panel Prefab 可以 instantiate;
  • 一部分按钮黑图;
  • 返回大厅再进入,有时恢复;
  • 内存中同时存在新旧两张 Atlas。

分层推理 ​

text
Prefab 能创建
→ Bundle 入口和脚本大体可用

部分图片失败
→ 查 SpriteFrame → Texture 依赖与远程 URL

新旧 Atlas 同时存在
→ 查版本缓存键与旧所有者

重进偶尔恢复
→ 查竞态和缓存命中顺序

最终发现发布入口先切到新配置,新 Atlas 尚未完成所有 CDN 节点同步;失败回退又从旧缓存取 Frame,而页面缓存同时 addRef 新旧资源。

系统修复 ​

  1. Bundle 使用不可变版本目录;
  2. 全部上传验证后再切入口;
  3. 页面请求带 Bundle 版本和 generation;
  4. 失败回退只使用同一兼容版本;
  5. 缓存所有权按版本隔离并设置淘汰;
  6. 连续开关与版本切换做自动化回归。

这类事故无法靠“多 load 一次”解决,因为它跨越发布一致性、竞态和资源所有权三层。

阶段能力验收 ​

完成 Stage03 不以读完文档为准。你应该能够独立完成:

  1. 在编辑器中创建带内部引用的 Prefab,并解释序列化如何恢复。
  2. 用 Inspector 绑定固定资源,用 resources 加载动态资源,并说明选择依据。
  3. 写出带错误、代次和对象有效性检查的异步代码。
  4. 画出 Prefab、SpriteFrame、Texture 的依赖图。
  5. 为一个动态资源缓存定义 addRef/decRef 所有者。
  6. 构造一次重复监听并用日志证明根因。
  7. 构造一次旧请求覆盖新请求并修复。
  8. 解释 destroy、对象池、Asset 释放和 GPU 内存的差别。
  9. 不修改 .meta 和生成目录完成资源迁移与验证。

新增综合练习与答案 ​

练习一 ​

商城 Prefab 静态引用公共按钮图集,商品图通过 resources 动态加载。关闭商城时哪些资源能由商城释放?

答案: 商城只能归还自己显式拥有的动态资源/缓存引用。公共按钮图集若由更长生命周期 UI 模块或静态依赖拥有,商城不能擅自释放。商品图也要先确认没有列表池和其他页面共享,再由实际 addRef 所有者归还。

练习二 ​

为什么 cc.isValid(this.node) 不能解决搜索结果竞态?

答案: 页面可能仍有效,但搜索关键词已从 A 变成 B。对象有效性只能证明接收者活着,请求 ID 或业务键才能证明结果仍属于当前状态。

练习三 ​

Prefab 中固定 Label 引用重命名字段后变成 null,为什么重新 load Prefab 没用?

答案: 问题在序列化字段契约和编辑器绑定,不在运行时缓存。应确认字段迁移策略,并通过 Creator 编辑器重新绑定、保存和验证所有受影响资源,不能手改 Prefab 文本。

练习四 ​

一个 SpriteFrame 已 decRef,为什么不能断言 Texture 会立刻销毁?

答案: 其他 Frame/Asset/组件可能仍依赖 Texture,Asset Manager 还要处理依赖关系,GPU 和分配器释放也可能延迟。

练习五 ​

远程 Bundle 的 config 返回 200,能否证明活动可用?

答案: 不能。还需验证版本匹配、内部 Asset 路径、依赖文件、hash/大小、解码、instantiate 和业务初始化。配置成功只是链路第一段。

版本边界与验证入口 ​

Stage03 主线固定为 Creator 2.4.x Asset Manager。旧 cc.loader 只用于存量项目理解,不与新资源引用计数混为一套;Creator 3.x 的模块 API 与序列化结构需单独迁移。

十一、Stage03 总结 ​

text
编辑器资源不是运行时对象。
AssetDB 管导入和身份,Loader 管运行时加载。
Scene / Prefab 反序列化后才成为 Node / Component。
资源通过依赖图和缓存被多个对象共享。
场景退出要同时处理对象、事件、异步请求和资源引用。
destroy、对象池和资源释放是不同层级的策略。

最终能力是:

能从一个“页面加载慢、切换后内存不降或资源显示异常”的问题,沿资源身份、依赖、对象创建、生命周期和释放链路逐层排查。


十二、下一阶段预告 ​

Stage03 完成,下一阶段进入:

text
Stage04|Renderer
Lesson037|Cocos 2.x 渲染流程总览

下一阶段将继续追踪:

text
Node / Component 状态
→ RenderData / Assembler
→ Material / Texture / Render State
→ Batch / DrawCall
→ GPU / Screen