Skip to content

Stage03|Runtime Objects & Assets ​

Lesson024|instantiate 一个 Prefab 时发生了什么? ​

Tags: #Creator2.x #Stage03 #Prefab #Instantiate #Clone #ObjectPoolDifficulty: ⭐⭐⭐⭐☆


一、本课从一次卡顿开始 ​

战斗中生成一颗子弹:

ts
const bullet = cc.instantiate(this.bulletPrefab);
this.bulletRoot.addChild(bullet);

看起来只有两行代码。单独执行没有问题,但当一帧生成 300 颗子弹时,画面突然卡顿。

原因不能简单总结成“instantiate 很慢”。需要继续问:

text
Prefab 里有多少 Node?
有多少 Component?
哪些属性要复制?
哪些内部引用要重映射?
onLoad/onEnable 做了多少工作?
Layout、Animation、Renderer 是否立即参与?
是否产生大量内存分配和 GC?

本课要把一行 cc.instantiate 展开成完整对象创建链。


二、本课目标 ​

完成本课后,你应该能够:

  1. 区分 Prefab Asset、Prefab 数据和实例 Node。
  2. 解释 instantiate 为什么不是简单的浅拷贝。
  3. 区分实例内部对象、外部 Asset 和运行时临时状态。
  4. 解释内部组件引用为什么要重新映射到新实例。
  5. 分析 instantiate、addChild、生命周期和首帧渲染的不同成本。
  6. 设计一个可验证的 Prefab 实例化性能实验。
  7. 判断什么时候应直接创建、分帧创建或使用对象池。

三、先复习:Prefab Asset 不是 Node ​

ts
@property(cc.Prefab)
bulletPrefab: cc.Prefab = null;

bulletPrefab 是资源对象。调用:

ts
const bulletNode = cc.instantiate(this.bulletPrefab);

才得到运行时 Node。

text
cc.Prefab
    ↓ instantiate
Node + Component 对象图

如果只加载 Prefab 而不 instantiate,它不会自动出现在场景中,也不会自己执行 update。


四、为什么不能只做浅拷贝 ​

假设 Prefab:

text
Bullet
├── Trail
│   └── ParticleSystem
├── HitPoint
└── BulletController
    ├── trail → Trail/ParticleSystem
    └── hitPoint → HitPoint Node

错误的浅拷贝模型:

text
只复制 Bullet 根节点
子节点和 Component 仍指向模板或旧实例

这会导致多个实例共享同一个 Trail、同一个 BulletController 或同一个运行时 Node,任何一个实例修改状态都会影响其他实例。

正确方向是克隆完整对象图:

text
模板 Bullet Node
→ 新 Bullet Node

模板 Trail Node
→ 新 Trail Node

模板 ParticleSystem
→ 新 ParticleSystem

模板 BulletController
→ 新 BulletController

然后重建新实例内部的关系。


五、对象图克隆的高层过程 ​

可以把 instantiate 理解为以下阶段:

text
读取 Prefab 模板对象图
    ↓
创建新的 Node 对象
    ↓
创建新的 Component 对象
    ↓
复制可序列化属性
    ↓
恢复父子关系
    ↓
把内部引用重映射到新对象
    ↓
保留或连接外部 Asset 引用
    ↓
得到完整运行时实例

Creator 2.4.x 的内部优化和具体函数随版本而变化,但“新对象、属性复制、内部引用重映射、外部资源连接”是理解行为的稳定模型。


六、内部引用为什么必须重映射 ​

模板中的 BulletController:

text
trail → 模板 Trail 的 ParticleSystem

实例 A 应该得到:

text
BulletController-A.trail → Trail-A/ParticleSystem-A

实例 B 应该得到:

text
BulletController-B.trail → Trail-B/ParticleSystem-B

不能出现:

text
BulletController-B.trail → Trail-A/ParticleSystem-A

否则实例之间会串状态。

这一步可以称为内部引用重映射:模板引用描述在每个新实例中指向对应的新对象。


七、哪些东西复制,哪些东西共享 ​

通常属于实例对象图,需要独立 ​

text
Node
Component
Transform 状态
active / enabled
Label.string
脚本序列化字段
内部 Node / Component 引用

通常属于外部 Asset,可以共享 ​

text
Texture
SpriteFrame
AudioClip
AnimationClip
Prefab Asset
字体和其他资源对象

共享 Asset 的好处是避免每个实例复制大纹理和音频数据。

但要注意:

共享资源适合只读使用。如果运行时修改共享 Material、SpriteFrame 或其他 Asset 本身,可能影响所有引用者。

运行时临时状态不会因为模板自动正确重置 ​

例如:

text
事件监听器
正在播放的 Tween
schedule 任务
网络请求
上一次使用留下的缓存

这些属于运行时生命周期,尤其在对象池复用时需要显式清理。


八、instantiate 和 addChild 是两个阶段 ​

ts
const bullet = cc.instantiate(this.bulletPrefab);
this.bulletRoot.addChild(bullet);

第一行主要得到对象实例;第二行把它接入场景树。

接入场景树后可能发生:

text
设置 parent
→ 重新计算层级和 activeInHierarchy
→ 组件进入激活流程
→ onLoad / onEnable
→ Layout / Transform 受影响
→ Renderer 开始处理可见组件

如果只测两行代码整体耗时,就无法知道卡在对象克隆还是接入场景后的生命周期和渲染工作。


九、生命周期到底什么时候执行 ​

不要死记“instantiate 一定立即 onLoad”或“addChild 才 onLoad”。更可靠的是分析状态:

text
实例是否已经接入活动场景树?
父节点 activeInHierarchy 是否为 true?
Prefab 根节点自身 active 是否为 true?
组件 enabled 是否为 true?
当前引擎处于哪个激活阶段?

稳定的业务设计:

ts
const bullet = cc.instantiate(this.bulletPrefab);
const controller = bullet.getComponent(BulletController);

controller.prepare(data);
this.bulletRoot.addChild(bullet);
controller.launch();

把业务输入通过明确接口传递,不依赖某个生命周期回调猜测外部数据何时已经设置。


十、instantiate 的成本从哪里来 ​

1. 对象创建和内存分配 ​

每个 Node、Component、数组和内部结构都需要分配和初始化。

2. 属性复制 ​

Prefab 越复杂,需要恢复的字段越多。

3. 引用恢复 ​

内部对象关系需要重建,外部 Asset 需要连接。

4. 生命周期 ​

onLoad、onEnable、事件注册和业务初始化可能比克隆本身更重。

5. 场景树和 Transform ​

addChild 会改变层级、激活状态和 Transform 传播。

6. UI 和渲染 ​

Label、Layout、Widget、Animation、Particle 和 Renderer 可能在实例进入场景后产生额外工作。

7. GC 和销毁成本 ​

频繁 instantiate/destroy 会产生分配和回收压力。


十一、贯穿案例:300 颗子弹为什么卡 ​

原始写法 ​

ts
spawnWave() {
    for (let i = 0; i < 300; i++) {
        const bullet = cc.instantiate(this.bulletPrefab);
        this.bulletRoot.addChild(bullet);
        bullet.getComponent(BulletController).launch(this.targets[i]);
    }
}

一帧内可能同时发生:

text
300 次对象图克隆
→ 数千个 Node / Component 创建
→ 300 次 active 状态接入
→ 300 组生命周期
→ 事件和 schedule 注册
→ Particle / Animation 初始化
→ Transform 和 RenderData 更新
→ 大量内存分配

方向一:分帧创建 ​

ts
private pendingTargets: cc.Node[] = [];

update() {
    const batchSize = 20;
    for (let i = 0; i < batchSize && this.pendingTargets.length > 0; i++) {
        this.spawnOne(this.pendingTargets.shift());
    }
}

优点:降低单帧尖峰。

代价:全部子弹出现需要更多帧,不适合要求同一逻辑帧完成的行为。

方向二:预热对象池 ​

text
进入战斗前创建一批 Bullet
→ 使用时从池中取出
→ 命中后清理状态并回收

优点:把创建成本前移并复用对象。

代价:占用内存,且必须正确重置事件、Tween、Particle、schedule 和业务字段。

方向三:减少 Prefab 复杂度 ​

text
只保留真正需要的 Node / Component
不可见 Trail 不创建
低端模式关闭复杂 Particle
共享只读资源

三个方向可以组合,但必须用 Profiler 验证真正的最大成本。


十二、编辑器验证实验 ​

通过 Creator 2.4.x 编辑器创建 Prefab 和绑定属性。不要手工修改 .prefab 或 .meta。

实验目标 ​

验证:

  1. 实例的 Node/Component 相互独立。
  2. 内部引用被重映射到各自实例。
  3. 外部 SpriteFrame 可以共享。
  4. instantiate 和 addChild/激活成本可以分开测量。

准备 Prefab ​

创建:

text
Bullet
├── Body/Sprite
├── TargetPoint
└── BulletController

脚本:

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

@ccclass
export default class BulletController extends cc.Component {
    @property(cc.Sprite)
    body: cc.Sprite = null;

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

    onLoad() {
        cc.log('onLoad', this.node.name);
    }

    onEnable() {
        cc.log('onEnable', this.node.name);
    }
}

在 Inspector 绑定 Body/Sprite 和 TargetPoint,然后保存为 Prefab。

实验代码 ​

ts
@property(cc.Prefab)
bulletPrefab: cc.Prefab = null;

start() {
    const beginInstantiate = performance.now();
    const first = cc.instantiate(this.bulletPrefab);
    const second = cc.instantiate(this.bulletPrefab);
    const instantiateCost = performance.now() - beginInstantiate;

    const firstController = first.getComponent(BulletController);
    const secondController = second.getComponent(BulletController);

    cc.log('node independent', first !== second);
    cc.log('component independent', firstController !== secondController);
    cc.log('target independent', firstController.targetPoint !== secondController.targetPoint);
    cc.log('sprite frame shared', firstController.body.spriteFrame === secondController.body.spriteFrame);
    cc.log('instantiate cost', instantiateCost);

    const beginAttach = performance.now();
    this.node.addChild(first);
    this.node.addChild(second);
    const attachCost = performance.now() - beginAttach;

    cc.log('attach cost', attachCost);
}

预期结果 ​

text
node independent true
component independent true
target independent true
sprite frame shared true(两者确实绑定同一资源时)

instantiate cost 和 attach cost 只用于这个实验的相对观察。开发构建、日志和 performance.now 自身都会影响数值,不能直接作为生产性能结论。

扩展实验 ​

分别测试:

text
10 个简单 Prefab
100 个简单 Prefab
100 个带 Label/Layout/Particle 的复杂 Prefab

记录:

text
实例化时间
接入场景时间
首次显示帧时间
内存变化
GC 是否出现

这能证明复杂度和数量如何共同影响成本。


十三、实验失败时怎么排查 ​

内部引用为 null ​

text
Prefab Inspector 是否完成绑定
→ Prefab 是否保存/Apply
→ 实例是否来自正确资源
→ 脚本属性类型和名称是否曾改变
→ 控制台是否有反序列化错误

两个实例修改后互相影响 ​

先判断修改的是:

text
Node/Component 实例字段
还是共享 Asset

修改 Label Component、Transform 应独立;修改共享 Material/Texture Asset 可能影响所有使用者。

onLoad 日志顺序与预期不同 ​

记录:

text
实例创建时父节点状态
addChild 时父节点 activeInHierarchy
Prefab 根节点 active
当前 Creator 版本和运行平台

不要为了匹配猜测而修改序列化文件,应通过状态和官方生命周期语义分析。


十四、真实项目案例:对象池复用后子弹立即爆炸 ​

现象 ​

从对象池取出的子弹刚出现就触发上一次的命中回调。

根因 ​

回收时只做了:

ts
bullet.active = false;
pool.put(bullet);

但没有清理:

text
旧目标引用
碰撞状态
scheduleOnce 的自动回收任务
Tween 完成回调
Particle 播放状态
事件监听器

正确设计 ​

ts
reuse(data: BulletData) {
    this.cancelRuntimeTasks();
    this.resetState();
    this.target = data.target;
    this.node.active = true;
}

unuse() {
    this.cancelRuntimeTasks();
    this.target = null;
    this.resetState();
}

Creator 2.x cc.NodePool 与组件复用回调的具体接口需要以当前版本文档为准。稳定原则是:对象池复用必须建立对称的初始化和清理协议。


十五、常见误区 ​

误区一:instantiate 就是复制根节点 ​

它需要复制整个 Node/Component 对象图并恢复关系。

误区二:所有引用都会被复制成新对象 ​

Prefab 内部对象通常重映射到新实例,外部 Asset 通常共享。

误区三:instantiate 慢一定是引擎克隆算法问题 ​

生命周期、事件、Layout、Label、Particle 和首帧渲染可能占更大成本。

误区四:对象池能消灭所有创建成本 ​

对象池把成本前移并复用对象,但带来内存占用和状态重置问题。

误区五:预创建越多越好 ​

预热数量过大会拉长进入时间并提高内存峰值。

误区六:一个实例改材质不会影响其他实例 ​

如果修改的是共享 Material Asset,就可能影响所有引用者;需要判断是否应使用材质实例。


十六、练习 ​

A. 概念题 ​

  1. 为什么 Prefab 实例化不能使用浅拷贝?
  2. 什么是内部引用重映射?
  3. 哪些对象通常应该独立,哪些 Asset 可以共享?
  4. instantiate 与 addChild 的职责有什么不同?

B. 性能分析题 ​

一帧创建 200 个包含 6 个 Node、4 个 Component、2 个 Label 和 1 个 Particle 的伤害数字 Prefab,帧时间从 12 ms 变成 80 ms。

请设计实验,把成本拆成:

text
instantiate
addChild/激活
生命周期
Label/Layout
Particle/渲染
GC

C. 故障诊断题 ​

对象池复用的 Enemy 出现以下问题:

  • 名字仍是上一只 Enemy。
  • 旧 Tween 继续播放。
  • 一次点击触发两次事件。
  • 重新出现后立即被自动回收。

请给出统一根因和修复协议。


十七、练习参考答案 ​

A. 概念题答案 ​

  1. Prefab 是包含多个 Node、Component 和引用的对象图,浅拷贝会让实例共享运行时对象并串状态。
  2. 把模板中指向内部对象的引用,改为指向当前新实例中对应的新对象。
  3. Node、Component 和实例字段应独立;Texture、SpriteFrame、AudioClip、AnimationClip 等只读 Asset 通常可以共享。
  4. instantiate 创建对象图;addChild 把实例接入场景树并可能触发激活、生命周期、布局和渲染工作。

B. 性能分析答案 ​

推荐用相同 Prefab、相同数量做逐步对照:

text
1. 只 instantiate,不 addChild,记录耗时和分配。
2. 使用已实例化对象,只测 addChild。
3. 临时禁用业务 onLoad/onEnable 内容,比较生命周期差值。
4. 创建去掉 Label/Layout 的 Prefab 版本,比较 UI 差值。
5. 创建去掉 Particle 的版本,比较 CPU/GPU 差值。
6. 记录长帧和 GC,比较分帧创建和对象池方案。

一次只改一个变量,并在目标设备发布构建重复采样。

C. 故障诊断答案 ​

统一根因是回收和复用协议不完整。需要:

text
unuse:取消 Tween、schedule、事件,清空数据引用,停止粒子,重置显示状态。
reuse:先保证旧任务已清空,再注入新数据、注册一次事件、启动新动画和计时。

还要确保事件注册/取消使用同一函数引用,自动回收任务每次都被取消或使用新的请求序号。


十八、本课总结 ​

text
Prefab 是模板 Asset。
instantiate 创建新的 Node/Component 对象图。
内部引用必须重映射到当前实例。
外部 Asset 通常共享,实例运行状态必须独立。
instantiate、接入场景、生命周期和首帧渲染是不同成本阶段。
对象池复用必须有完整的 reset/reuse 协议。

十九、下一课预告 ​

text
Lesson025|Prefab 实例、属性覆盖与组件引用

下一课会回答:

text
Prefab 模板改了,实例为什么有时不跟着变?
实例上的 Override 是什么?
Apply / Revert 应该怎么理解?
为什么 Prefab 不应该偷偷引用外部 Scene Node?