外观
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 展开成完整对象创建链。
二、本课目标
完成本课后,你应该能够:
- 区分 Prefab Asset、Prefab 数据和实例 Node。
- 解释 instantiate 为什么不是简单的浅拷贝。
- 区分实例内部对象、外部 Asset 和运行时临时状态。
- 解释内部组件引用为什么要重新映射到新实例。
- 分析 instantiate、addChild、生命周期和首帧渲染的不同成本。
- 设计一个可验证的 Prefab 实例化性能实验。
- 判断什么时候应直接创建、分帧创建或使用对象池。
三、先复习: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。
实验目标
验证:
- 实例的 Node/Component 相互独立。
- 内部引用被重映射到各自实例。
- 外部 SpriteFrame 可以共享。
- 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. 概念题
- 为什么 Prefab 实例化不能使用浅拷贝?
- 什么是内部引用重映射?
- 哪些对象通常应该独立,哪些 Asset 可以共享?
- instantiate 与 addChild 的职责有什么不同?
B. 性能分析题
一帧创建 200 个包含 6 个 Node、4 个 Component、2 个 Label 和 1 个 Particle 的伤害数字 Prefab,帧时间从 12 ms 变成 80 ms。
请设计实验,把成本拆成:
text
instantiate
addChild/激活
生命周期
Label/Layout
Particle/渲染
GCC. 故障诊断题
对象池复用的 Enemy 出现以下问题:
- 名字仍是上一只 Enemy。
- 旧 Tween 继续播放。
- 一次点击触发两次事件。
- 重新出现后立即被自动回收。
请给出统一根因和修复协议。
十七、练习参考答案
A. 概念题答案
- Prefab 是包含多个 Node、Component 和引用的对象图,浅拷贝会让实例共享运行时对象并串状态。
- 把模板中指向内部对象的引用,改为指向当前新实例中对应的新对象。
- Node、Component 和实例字段应独立;Texture、SpriteFrame、AudioClip、AnimationClip 等只读 Asset 通常可以共享。
- 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?