Appearance
从对象池开始
为什么用对象池?
标准答案
用对象池是为了减少运行时频繁 Instantiate / Destroy 带来的 CPU 峰值、GC Alloc 和卡顿。
在 Unity 里,像子弹、特效、飘字、怪物、UI Item 这种对象,可能短时间大量创建和销毁。如果每次都 Instantiate,每次用完都 Destroy,就容易造成:
- 创建对象开销高。
- 销毁对象不是立刻完全释放。
- 托管对象分配增加 GC 压力。
- 某一帧突然创建很多对象,会出现卡顿尖峰。
所以对象池的思路是:提前创建一批对象,用的时候取出来,不用的时候重置状态放回去,下次继续复用。
底层原理
对象池本质是用内存换性能稳定性。
不用对象池:
创建 -> 使用 -> 销毁 -> 再创建 -> 再销毁使用对象池:
预热 -> 取出 -> 使用 -> 重置 -> 放回 -> 再取出这样做不是让对象数量凭空减少,而是减少运行时反复分配和销毁。
Unity 项目里常见场景
子弹池:大量发射和回收。
特效池:爆炸、受击、技能特效频繁播放。
飘字池:伤害数字大量出现。
UI Item 池:背包、排行榜、邮件列表。
怪物池:刷怪、回收、再刷。
代码例子
c
using System.Collections.Generic; // 引入 Queue 和 HashSet,用来管理池中对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、MonoBehaviour。
public class SimpleBulletPool : MonoBehaviour // 定义一个简单子弹对象池。
{ // 类开始。
[SerializeField] private GameObject bulletPrefab; // 子弹预制体,由 Inspector 绑定。
[SerializeField] private int preloadCount = 30; // 预热数量,避免战斗中第一次创建卡顿。
private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存可复用的空闲子弹。
private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录哪些对象已经在池中,防止重复回收。
private void Awake() // Unity 初始化时调用。
{ // 方法开始。
for (int i = 0; i < preloadCount; i++) // 按预热数量提前创建子弹。
{ // 循环开始。
GameObject bullet = CreateBullet(); // 创建一个新的子弹对象。
Return(bullet); // 创建后立刻放回池中等待复用。
} // 循环结束。
} // 方法结束。
public GameObject Get(Vector3 position, Quaternion rotation) // 从池中取出一个子弹。
{ // 方法开始。
GameObject bullet = _pool.Count > 0 ? _pool.Dequeue() : CreateBullet(); // 有空闲对象就复用,没有就扩容创建。
_inPool.Remove(bullet); // 从池中记录移除,表示它现在正在使用。
bullet.transform.SetPositionAndRotation(position, rotation); // 设置子弹出生位置和方向。
bullet.SetActive(true); // 激活子弹,让它开始显示和运行逻辑。
return bullet; // 返回取出的子弹对象。
} // 方法结束。
public void Return(GameObject bullet) // 把子弹归还到对象池。
{ // 方法开始。
if (bullet == null) // 如果传进来的对象为空。
{ // 判断开始。
return; // 直接返回,避免空引用。
} // 判断结束。
if (_inPool.Contains(bullet)) // 如果这个子弹已经在池中。
{ // 判断开始。
return; // 直接返回,避免重复回收导致一个对象被取出多次。
} // 判断结束。
bullet.SetActive(false); // 关闭对象,停止显示和大部分 Unity 回调。
bullet.transform.SetParent(transform); // 放到池节点下面,方便层级管理。
_pool.Enqueue(bullet); // 加入空闲队列,等待下次复用。
_inPool.Add(bullet); // 标记这个对象已经在池中。
} // 方法结束。
private GameObject CreateBullet() // 创建一个新的子弹对象。
{ // 方法开始。
GameObject bullet = Instantiate(bulletPrefab, transform); // 实例化子弹,并挂到池节点下。
bullet.SetActive(false); // 新创建的对象先关闭,避免马上参与逻辑。
return bullet; // 返回新创建的对象。
} // 方法结束。
} // 类结束。面试加分点
对象池不是万能优化。它适合频繁创建、频繁销毁、生命周期短、数量多的对象。
它的代价是:对象会常驻一部分内存。如果池子过大,反而会浪费内存。所以项目里要考虑预热数量、最大容量、扩容策略、切场景时是否清空。
一句话总结:对象池是用内存换 CPU 和 GC 稳定性,重点不是“缓存对象”,而是“取出初始化、归还重置、防止重复回收、合适时机释放”。
如何预热?
标准答案
对象池预热就是:在战斗开始前、Loading 阶段、进入场景时,提前创建一批对象并放进池里,这样运行时需要子弹、特效、飘字、UI Item 时,就可以直接取,不用在关键帧里突然 Instantiate。
简单说:
预热前:战斗中突然创建 -> 卡顿风险
预热后:Loading 时提前创建 -> 战斗中直接复用预热怎么做
- 先估算数量 比如子弹同屏最多 80 发,就可以预热 80 个左右。特效、飘字、怪物也是看峰值并发数量。
- 选择安全时机 一般放在 Loading、进入战斗前、波次开始前,不要放在玩家释放技能或怪物生成的瞬间。
- 分帧创建 如果一次预热 500 个对象,也可能把 Loading 卡住,所以可以每帧创建 10 个、20 个。
- 创建后关闭并入池
Instantiate出来后SetActive(false),重置状态,然后放到队列或栈里。 - 运行时不够要兜底 池子不够时可以扩容,也可以丢弃或等待,取决于对象类型。子弹可以扩容,飘字可以丢弃低优先级。
代码例子
c
using System.Collections; // 引入 IEnumerator,用来写分帧预热协程。
using System.Collections.Generic; // 引入 Queue,用来保存空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。
public class BulletPoolPrewarmer : MonoBehaviour // 定义一个子弹池预热脚本。
{ // 类开始。
[SerializeField] private GameObject bulletPrefab; // 子弹预制体,由 Inspector 绑定。
[SerializeField] private int prewarmCount = 80; // 预热数量,根据同屏峰值估算。
[SerializeField] private int createPerFrame = 10; // 每帧创建数量,避免预热阶段卡顿。
private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存空闲子弹对象。
private int _createdCount; // 记录已经创建过的对象数量。
public IEnumerator Prewarm() // 定义分帧预热协程。
{ // 协程开始。
int needCreate = prewarmCount - _createdCount; // 计算还需要创建多少个对象。
for (int i = 0; i < needCreate; i++) // 循环创建缺少的对象。
{ // 循环开始。
GameObject bullet = CreateOne(); // 创建一个新的子弹对象。
ReturnToPool(bullet); // 创建后立即放回池中等待复用。
if ((i + 1) % createPerFrame == 0) // 每创建一批对象就暂停一帧。
{ // 判断开始。
yield return null; // 把控制权还给 Unity,下一帧继续预热。
} // 判断结束。
} // 循环结束。
} // 协程结束。
public GameObject Get(Vector3 position, Quaternion rotation) // 从池里取出一个子弹。
{ // 方法开始。
GameObject bullet = _pool.Count > 0 ? _pool.Dequeue() : CreateOne(); // 池里有就取,没有就扩容创建。
bullet.transform.SetPositionAndRotation(position, rotation); // 设置子弹出生位置和方向。
bullet.SetActive(true); // 激活子弹,让它开始参与逻辑。
return bullet; // 返回可用子弹。
} // 方法结束。
public void ReturnToPool(GameObject bullet) // 把子弹放回池中。
{ // 方法开始。
if (bullet == null) // 如果传入对象为空。
{ // 判断开始。
return; // 直接返回,避免空引用。
} // 判断结束。
bullet.SetActive(false); // 关闭对象,避免继续显示和执行逻辑。
bullet.transform.SetParent(transform); // 挂到对象池节点下面,方便层级管理。
_pool.Enqueue(bullet); // 放入空闲队列,等待下次复用。
} // 方法结束。
private GameObject CreateOne() // 创建一个新子弹对象。
{ // 方法开始。
GameObject bullet = Instantiate(bulletPrefab, transform); // 实例化子弹并挂到池节点下。
_createdCount++; // 增加已创建数量。
return bullet; // 返回新创建的子弹对象。
} // 方法结束。
} // 类结束。面试加分点
预热不是越多越好。预热太少,运行时还是会临时扩容卡顿;预热太多,会浪费内存。比较好的做法是:先按峰值估算,再用 Profiler 或日志记录池子的最大使用量,之后调整预热数量。
一句话总结:预热就是把运行时创建对象的尖峰,提前搬到 Loading 或进场阶段,并且最好分帧完成。
如何扩容?
标准答案
对象池扩容就是:当池子里的空闲对象不够用时,按规则补充新对象。但不能池空就无限 Instantiate,否则对象池会变成“内存持续上涨”的问题来源。
我一般会这样设计:
- 池空时才触发扩容
- 每次扩一批,而不是永远只扩 1 个
- 设置最大容量
maxCount - 超过上限时做兜底:丢弃、等待、复用旧对象
- 记录最大活跃数量,反过来调整预热数量
底层思路
对象池扩容本质是在处理一个问题:预热数量低于运行时峰值。
比如预热了 50 个子弹,但某一瞬间需要 80 个,那池子就会空。这个时候可以临时创建 10 个或 20 个补进去。
但是扩容有代价:Instantiate 仍然会产生 CPU 开销,GameObject、Transform、Renderer、Collider 等组件也会占内存。所以扩容一定要有上限。
代码例子
c
using System.Collections.Generic; // 引入 Stack 和 HashSet,用来管理对象池。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、Mathf。
public class ExpandableObjectPool : MonoBehaviour // 定义一个支持扩容的对象池。
{ // 类开始。
[SerializeField] private GameObject prefab; // 池子要创建的预制体。
[SerializeField] private int preloadCount = 30; // 初始预热数量。
[SerializeField] private int expandStep = 10; // 每次扩容数量。
[SerializeField] private int maxCount = 100; // 池子允许创建的最大对象数量。
private readonly Stack<GameObject> _pool = new Stack<GameObject>(); // 保存空闲对象。
private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象,防止重复回收。
private int _totalCount; // 记录池子总共创建过多少对象。
private int _activeCount; // 记录当前正在使用的对象数量。
private int _maxActiveCount; // 记录历史最大活跃数量,方便后续调参。
private void Awake() // Unity 初始化时调用。
{ // 方法开始。
Expand(preloadCount); // 启动时先预热一批对象。
} // 方法结束。
public GameObject Get(Vector3 position, Quaternion rotation) // 从池子里取出对象。
{ // 方法开始。
if (_pool.Count == 0) // 如果当前没有空闲对象。
{ // 判断开始。
Expand(expandStep); // 按固定步长扩容一批对象。
} // 判断结束。
if (_pool.Count == 0) // 如果扩容后还是没有对象,说明达到上限。
{ // 判断开始。
return null; // 返回空,让上层决定丢弃、等待或降级。
} // 判断结束。
GameObject obj = _pool.Pop(); // 从空闲栈中取出一个对象。
_inPool.Remove(obj); // 标记这个对象已经不在池中。
_activeCount++; // 活跃对象数量加一。
_maxActiveCount = Mathf.Max(_maxActiveCount, _activeCount); // 更新历史最大活跃数量。
obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转。
obj.SetActive(true); // 激活对象,让它参与显示和逻辑。
return obj; // 返回可用对象。
} // 方法结束。
public void Return(GameObject obj) // 把对象归还到池子。
{ // 方法开始。
if (obj == null) // 如果传入对象为空。
{ // 判断开始。
return; // 直接返回,避免空引用。
} // 判断结束。
if (_inPool.Contains(obj)) // 如果对象已经在池中。
{ // 判断开始。
return; // 直接返回,避免重复回收。
} // 判断结束。
ResetObject(obj); // 重置对象状态,避免下次取出时残留旧数据。
obj.SetActive(false); // 关闭对象。
obj.transform.SetParent(transform); // 放回对象池节点下面。
_pool.Push(obj); // 放入空闲栈。
_inPool.Add(obj); // 标记对象已经在池中。
_activeCount = Mathf.Max(0, _activeCount - 1); // 活跃数量减一,并避免变成负数。
} // 方法结束。
private void Expand(int count) // 执行扩容。
{ // 方法开始。
int canCreate = Mathf.Min(count, maxCount - _totalCount); // 计算本次最多还能创建多少个。
for (int i = 0; i < canCreate; i++) // 按允许数量创建对象。
{ // 循环开始。
GameObject obj = Instantiate(prefab, transform); // 创建新对象,Unity 对象创建通常要在主线程。
_totalCount++; // 总创建数量加一。
ResetObject(obj); // 初始化对象状态。
obj.SetActive(false); // 创建后先关闭,等待取出使用。
_pool.Push(obj); // 放入空闲栈。
_inPool.Add(obj); // 标记对象已经在池中。
} // 循环结束。
} // 方法结束。
private void ResetObject(GameObject obj) // 重置对象状态。
{ // 方法开始。
obj.transform.SetParent(transform); // 重置父节点,避免留在旧逻辑节点下。
obj.transform.localScale = Vector3.one; // 重置缩放,避免上次使用残留。
} // 方法结束。
} // 类结束。工程建议
低频对象可以每次扩 1 个,高频对象建议一次扩一批,比如子弹、飘字、特效。扩容最好不要无限制,maxCount 很重要。
超过上限时要看对象类型处理:普通飘字、低优先级特效可以丢弃;关键子弹或怪物可以等待、复用最老对象,或者提前把预热数量调大。
面试加分点
我会说:扩容次数、最大活跃数量、池空次数都应该打点。比如发现某个特效池经常扩容,说明预热数量偏小;如果创建了很多但长期不用,说明池子过大,需要回收或降低上限。
如何回收?
标准答案
对象池扩容就是:当池子里的空闲对象不够用时,按规则补充新对象。但不能池空就无限 Instantiate,否则对象池会变成“内存持续上涨”的问题来源。
我一般会这样设计:
- 池空时才触发扩容
- 每次扩一批,而不是永远只扩 1 个
- 设置最大容量
maxCount - 超过上限时做兜底:丢弃、等待、复用旧对象
- 记录最大活跃数量,反过来调整预热数量
底层思路
对象池扩容本质是在处理一个问题:预热数量低于运行时峰值。
比如预热了 50 个子弹,但某一瞬间需要 80 个,那池子就会空。这个时候可以临时创建 10 个或 20 个补进去。
但是扩容有代价:Instantiate 仍然会产生 CPU 开销,GameObject、Transform、Renderer、Collider 等组件也会占内存。所以扩容一定要有上限。
代码例子
c
using System.Collections.Generic; // 引入 Stack 和 HashSet,用来管理对象池。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、Mathf。
public class ExpandableObjectPool : MonoBehaviour // 定义一个支持扩容的对象池。
{ // 类开始。
[SerializeField] private GameObject prefab; // 池子要创建的预制体。
[SerializeField] private int preloadCount = 30; // 初始预热数量。
[SerializeField] private int expandStep = 10; // 每次扩容数量。
[SerializeField] private int maxCount = 100; // 池子允许创建的最大对象数量。
private readonly Stack<GameObject> _pool = new Stack<GameObject>(); // 保存空闲对象。
private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象,防止重复回收。
private int _totalCount; // 记录池子总共创建过多少对象。
private int _activeCount; // 记录当前正在使用的对象数量。
private int _maxActiveCount; // 记录历史最大活跃数量,方便后续调参。
private void Awake() // Unity 初始化时调用。
{ // 方法开始。
Expand(preloadCount); // 启动时先预热一批对象。
} // 方法结束。
public GameObject Get(Vector3 position, Quaternion rotation) // 从池子里取出对象。
{ // 方法开始。
if (_pool.Count == 0) // 如果当前没有空闲对象。
{ // 判断开始。
Expand(expandStep); // 按固定步长扩容一批对象。
} // 判断结束。
if (_pool.Count == 0) // 如果扩容后还是没有对象,说明达到上限。
{ // 判断开始。
return null; // 返回空,让上层决定丢弃、等待或降级。
} // 判断结束。
GameObject obj = _pool.Pop(); // 从空闲栈中取出一个对象。
_inPool.Remove(obj); // 标记这个对象已经不在池中。
_activeCount++; // 活跃对象数量加一。
_maxActiveCount = Mathf.Max(_maxActiveCount, _activeCount); // 更新历史最大活跃数量。
obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转。
obj.SetActive(true); // 激活对象,让它参与显示和逻辑。
return obj; // 返回可用对象。
} // 方法结束。
public void Return(GameObject obj) // 把对象归还到池子。
{ // 方法开始。
if (obj == null) // 如果传入对象为空。
{ // 判断开始。
return; // 直接返回,避免空引用。
} // 判断结束。
if (_inPool.Contains(obj)) // 如果对象已经在池中。
{ // 判断开始。
return; // 直接返回,避免重复回收。
} // 判断结束。
ResetObject(obj); // 重置对象状态,避免下次取出时残留旧数据。
obj.SetActive(false); // 关闭对象。
obj.transform.SetParent(transform); // 放回对象池节点下面。
_pool.Push(obj); // 放入空闲栈。
_inPool.Add(obj); // 标记对象已经在池中。
_activeCount = Mathf.Max(0, _activeCount - 1); // 活跃数量减一,并避免变成负数。
} // 方法结束。
private void Expand(int count) // 执行扩容。
{ // 方法开始。
int canCreate = Mathf.Min(count, maxCount - _totalCount); // 计算本次最多还能创建多少个。
for (int i = 0; i < canCreate; i++) // 按允许数量创建对象。
{ // 循环开始。
GameObject obj = Instantiate(prefab, transform); // 创建新对象,Unity 对象创建通常要在主线程。
_totalCount++; // 总创建数量加一。
ResetObject(obj); // 初始化对象状态。
obj.SetActive(false); // 创建后先关闭,等待取出使用。
_pool.Push(obj); // 放入空闲栈。
_inPool.Add(obj); // 标记对象已经在池中。
} // 循环结束。
} // 方法结束。
private void ResetObject(GameObject obj) // 重置对象状态。
{ // 方法开始。
obj.transform.SetParent(transform); // 重置父节点,避免留在旧逻辑节点下。
obj.transform.localScale = Vector3.one; // 重置缩放,避免上次使用残留。
} // 方法结束。
} // 类结束。工程建议
低频对象可以每次扩 1 个,高频对象建议一次扩一批,比如子弹、飘字、特效。扩容最好不要无限制,maxCount 很重要。
超过上限时要看对象类型处理:普通飘字、低优先级特效可以丢弃;关键子弹或怪物可以等待、复用最老对象,或者提前把预热数量调大。
面试加分点
我会说:扩容次数、最大活跃数量、池空次数都应该打点。比如发现某个特效池经常扩容,说明预热数量偏小;如果创建了很多但长期不用,说明池子过大,需要回收或降低上限。
如何防重复回收?
标准答案
防重复回收的核心是:让 Return 方法变成幂等操作。也就是同一个对象就算被回收多次,也最多只会真正放回池里一次。
重复回收很常见,比如子弹命中敌人时回收,同时生命周期超时也回收;或者 UI 关闭时统一回收,Item 自己也触发了一次回收。如果不防,池里可能出现两个相同对象,后面两个系统会同时取到同一个实例,Bug 会非常难查。
常用做法
- 用
HashSet记录哪些对象已经在池里。 - 或者给对象加一个
isInPool状态位。 Return开头先判断,已经在池里就直接返回。- 入池时立刻标记
inPool = true。 - 取出时立刻标记
inPool = false。 - 开发期重复回收可以打 Warning,方便定位来源。
代码例子
c
using System.Collections.Generic; // 引入 Queue 和 HashSet,用来管理对象池。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。
public class SafeObjectPool : MonoBehaviour // 定义一个防重复回收的对象池。
{ // 类开始。
[SerializeField] private GameObject prefab; // 需要池化的预制体。
private readonly Queue<GameObject> _pool = new Queue<GameObject>(); // 保存空闲对象。
private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象。
private readonly HashSet<GameObject> _createdByThisPool = new HashSet<GameObject>(); // 记录属于这个池创建的对象。
public GameObject Get(Vector3 position, Quaternion rotation) // 从池中取出对象。
{ // 方法开始。
GameObject obj = _pool.Count > 0 ? _pool.Dequeue() : CreateOne(); // 池中有对象就取出,没有就创建。
_inPool.Remove(obj); // 标记对象不在池中,表示正在使用。
obj.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转。
obj.SetActive(true); // 激活对象。
return obj; // 返回对象给调用方。
} // 方法结束。
public void Return(GameObject obj) // 把对象归还到池中。
{ // 方法开始。
if (obj == null) // 如果对象为空。
{ // 判断开始。
return; // 直接返回,避免空引用。
} // 判断结束。
if (!_createdByThisPool.Contains(obj)) // 如果对象不是这个池创建的。
{ // 判断开始。
Debug.LogWarning("非法对象不能回收到这个池"); // 开发期提示对象归属错误。
return; // 直接返回,避免把外部对象塞进池里。
} // 判断结束。
if (_inPool.Contains(obj)) // 如果对象已经在池中。
{ // 判断开始。
Debug.LogWarning("对象被重复回收,已忽略"); // 开发期提示重复回收问题。
return; // 直接返回,保证同一个对象不会入池两次。
} // 判断结束。
ResetObject(obj); // 回收前重置对象状态。
obj.SetActive(false); // 关闭对象。
obj.transform.SetParent(transform); // 把对象挂回池节点下面。
_pool.Enqueue(obj); // 把对象放回空闲队列。
_inPool.Add(obj); // 标记对象已经在池中。
} // 方法结束。
private GameObject CreateOne() // 创建一个新对象。
{ // 方法开始。
GameObject obj = Instantiate(prefab, transform); // 实例化预制体。
_createdByThisPool.Add(obj); // 记录这个对象属于当前池。
obj.SetActive(false); // 新对象先关闭。
return obj; // 返回新对象。
} // 方法结束。
private void ResetObject(GameObject obj) // 重置对象状态。
{ // 方法开始。
obj.transform.localScale = Vector3.one; // 重置缩放。
} // 方法结束。
} // 类结束。更高级的坑:旧回调
有时候对象已经回收并再次取出,但上一次使用留下的延迟协程、计时器、网络回调又触发了 Return。这时只靠 HashSet 还不够,最好给对象加一个 version 或 token。
思路是:每次取出对象时 version 加一,延迟回调触发时先检查 version 是否还是当时那一版。不一致就说明这是旧回调,直接丢弃。
面试加分点
不要只用 obj.activeSelf 判断是否在池中,因为对象可能只是被临时隐藏,但仍然属于业务使用中。SetActive 状态不是池状态。池状态应该由对象池自己维护。
一句话总结:防重复回收就是让对象池自己兜底,不能完全相信调用方;核心手段是 HashSet、状态位、owner 校验和 version。
如何重置对象?
标准答案
对象池重置对象的核心是:把上一次使用留下的所有状态清干净,让下次取出来时像新对象一样可靠。
不能只写一句 SetActive(false)。因为对象虽然隐藏了,但它身上的速度、目标引用、事件监听、粒子残留、拖尾、动画状态、计时器都可能还在。
一般要重置哪些东西
- 通用状态:父节点、位置、旋转、缩放、Layer、Tag。
- 物理状态:
Rigidbody.velocity、angularVelocity、碰撞开关。 - 表现状态:
TrailRenderer.Clear()、粒子停止、Animator 回到初始状态。 - 业务状态:目标引用、伤害值、生命周期计时、是否命中、事件监听。
- 异步状态:协程、计时器、延迟回调、旧 version。
推荐设计
通用对象池只负责生命周期:取出、归还、队列管理。
具体对象自己实现重置接口,比如 IPoolable。因为子弹、特效、UI Item 要清理的东西不一样,不能全部写死在对象池里。
代码例子
c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Rigidbody、TrailRenderer 等类型。
public interface IPoolable // 定义一个池化对象接口。
{ // 接口开始。
void OnSpawnFromPool(); // 对象从池中取出时调用,用来初始化本次使用状态。
void OnReturnToPool(); // 对象归还到池中时调用,用来清理上次使用状态。
} // 接口结束。
public class PooledBullet : MonoBehaviour, IPoolable // 定义一个可池化子弹。
{ // 类开始。
private Rigidbody _rigidbody; // 缓存 Rigidbody 组件。
private TrailRenderer _trail; // 缓存 TrailRenderer 组件。
private Transform _target; // 保存当前追踪目标。
private float _lifeTimer; // 保存生命周期计时。
private bool _hasHit; // 保存是否已经命中过目标。
private void Awake() // Unity 初始化时调用。
{ // 方法开始。
_rigidbody = GetComponent<Rigidbody>(); // 获取 Rigidbody 组件。
_trail = GetComponent<TrailRenderer>(); // 获取 TrailRenderer 组件。
} // 方法结束。
public void Init(Transform target) // 子弹取出后设置本次使用参数。
{ // 方法开始。
_target = target; // 设置本次追踪目标。
_lifeTimer = 0f; // 重置生命周期计时。
_hasHit = false; // 重置命中状态。
} // 方法结束。
public void OnSpawnFromPool() // 从对象池取出时调用。
{ // 方法开始。
gameObject.SetActive(true); // 激活对象。
_lifeTimer = 0f; // 重置生命周期计时。
_hasHit = false; // 重置命中状态。
if (_trail != null) // 如果存在拖尾组件。
{ // 判断开始。
_trail.Clear(); // 清空旧拖尾。
} // 判断结束。
} // 方法结束。
public void OnReturnToPool() // 归还到对象池时调用。
{ // 方法开始。
_target = null; // 清空目标引用,避免引用旧敌人。
_lifeTimer = 0f; // 清空生命周期计时。
_hasHit = false; // 清空命中状态。
if (_rigidbody != null) // 如果存在 Rigidbody。
{ // 判断开始。
_rigidbody.velocity = Vector3.zero; // 清空线速度。
_rigidbody.angularVelocity = Vector3.zero; // 清空角速度。
} // 判断结束。
if (_trail != null) // 如果存在拖尾组件。
{ // 判断开始。
_trail.Clear(); // 清空旧拖尾。
} // 判断结束。
transform.SetParent(null); // 根据项目需求清理父节点,也可以改成挂回池节点。
gameObject.SetActive(false); // 最后关闭对象。
} // 方法结束。
} // 类结束。面试加分点
我会强调:取出初始化和归还清理要分开。
OnSpawnFromPool 负责设置本次使用需要的数据,比如位置、目标、伤害。
OnReturnToPool 负责清理上次使用留下的数据,比如速度、特效、事件、协程、引用。
一句话总结:通用池只管理生命周期,具体对象自己实现 ResetForPool;谁有状态,谁负责清理。
如何处理不同 Prefab?
标准答案
不同 Prefab 不能混在同一个对象池里。正确做法是:一个 Prefab 对应一个子池,外层用 PoolManager 根据 key 找到对应的子池。
比如:
c
bullet_basic -> BulletPool
vfx_explode -> VfxPool
damage_text -> TextPool取对象时按 key 取,归还时按对象身上记录的 PoolKey 或 OwnerPool 回到原来的池。
为什么不能混池
因为不同 Prefab 的组件、重置逻辑、生命周期都不一样。子弹要重置速度、目标、碰撞;特效要重置粒子和拖尾;UI 飘字要重置文本、颜色、动画。如果混在一个池里,很容易拿到错误类型,或者旧状态残留。
代码例子
c
using System.Collections.Generic; // 引入 Dictionary、Queue、HashSet。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、Vector3、Quaternion。
public class PoolIdentity : MonoBehaviour // 给池中对象挂一个身份组件。
{ // 类开始。
public string PoolKey { get; set; } // 记录这个对象属于哪个 Prefab 池。
} // 类结束。
public class MultiPrefabPoolManager : MonoBehaviour // 多 Prefab 对象池管理器。
{ // 类开始。
private readonly Dictionary<string, GameObject> _prefabs = new Dictionary<string, GameObject>(); // key 到 Prefab 的映射。
private readonly Dictionary<string, Queue<GameObject>> _pools = new Dictionary<string, Queue<GameObject>>(); // key 到空闲队列的映射。
private readonly HashSet<GameObject> _inPool = new HashSet<GameObject>(); // 记录已经在池中的对象,防止重复回收。
public void Register(string key, GameObject prefab, int prewarmCount) // 注册一种 Prefab。
{ // 方法开始。
_prefabs[key] = prefab; // 保存 key 和 Prefab 的对应关系。
_pools[key] = new Queue<GameObject>(); // 为这个 Prefab 创建一个独立队列。
for (int i = 0; i < prewarmCount; i++) // 按预热数量创建对象。
{ // 循环开始。
GameObject obj = CreateObject(key); // 创建属于这个 key 的对象。
Return(obj); // 创建后立刻放回对应池中。
} // 循环结束。
} // 方法结束。
public GameObject Get(string key, Vector3 position, Quaternion rotation) // 根据 key 取出对象。
{ // 方法开始。
if (!_prefabs.ContainsKey(key)) // 如果没有注册这个 key。
{ // 判断开始。
return null; // 返回空,避免取到错误对象。
} // 判断结束。
Queue<GameObject> pool = _pools[key]; // 找到这个 key 对应的子池。
GameObject obj = pool.Count > 0 ? pool.Dequeue() : CreateObject(key); // 有空闲对象就取,没有就扩容创建。
_inPool.Remove(obj); // 标记对象已经离开池子。
obj.transform.SetPositionAndRotation(position, rotation); // 设置本次使用的位置和旋转。
obj.SetActive(true); // 激活对象。
return obj; // 返回对象。
} // 方法结束。
public void Return(GameObject obj) // 归还对象。
{ // 方法开始。
if (obj == null) // 如果对象为空。
{ // 判断开始。
return; // 直接返回。
} // 判断结束。
PoolIdentity identity = obj.GetComponent<PoolIdentity>(); // 获取对象身份组件。
if (identity == null || !_pools.ContainsKey(identity.PoolKey)) // 如果对象不属于任何已知池。
{ // 判断开始。
Destroy(obj); // 非法对象不入池,直接销毁或报警。
return; // 结束归还流程。
} // 判断结束。
if (_inPool.Contains(obj)) // 如果对象已经在池中。
{ // 判断开始。
return; // 防止重复回收。
} // 判断结束。
obj.SetActive(false); // 关闭对象。
obj.transform.SetParent(transform); // 放回池管理节点下面。
_pools[identity.PoolKey].Enqueue(obj); // 按 PoolKey 回到自己的子池。
_inPool.Add(obj); // 标记对象已经在池中。
} // 方法结束。
private GameObject CreateObject(string key) // 创建指定 key 的对象。
{ // 方法开始。
GameObject obj = Instantiate(_prefabs[key], transform); // 根据 key 找到 Prefab 并实例化。
PoolIdentity identity = obj.GetComponent<PoolIdentity>(); // 尝试获取身份组件。
if (identity == null) // 如果对象没有身份组件。
{ // 判断开始。
identity = obj.AddComponent<PoolIdentity>(); // 动态添加身份组件。
} // 判断结束。
identity.PoolKey = key; // 记录对象属于哪个子池。
obj.SetActive(false); // 新对象默认关闭。
return obj; // 返回创建好的对象。
} // 方法结束。
} // 类结束。工程建议
key 不建议只用 Prefab 名字,因为可能重名。项目里更推荐用配置表 ID、Addressables key、资源路径或枚举 ID。
如果是 Prefab Variant,也建议作为不同 key 管理,除非它们组件结构、重置逻辑、生命周期完全一样。
面试加分点
我会补一句:多 Prefab 对象池本质是 PoolManager 加多个 SubPool。PoolManager 负责根据 key 路由,SubPool 负责具体实例的预热、取出、归还、扩容和清空。归还时必须校验 owner,避免对象回错池。
如何切场景清理?
标准答案
切场景清理对象池,我会按这个顺序做:
- 先封入口:设置
isCleaning = true,切场景期间不允许继续Get新对象。 - 回收活跃对象:把正在飞的子弹、播放中的特效、飘字、怪物临时对象统一停止和重置。
- 区分池类型:场景级池直接销毁清空;全局池只回收活跃对象,并裁剪多余空闲对象。
- 清引用:清掉对旧场景
Transform、怪物、UI、特效目标的引用。 - 释放资源:如果 Prefab 来自 Addressables 或 AssetBundle,要在实例销毁后释放资源句柄。
代码例子
c
using System.Collections.Generic; // 引入集合类型,用来保存活跃对象和空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、MonoBehaviour、Vector3、Quaternion。
using UnityEngine.SceneManagement; // 引入场景管理,用来监听场景卸载。
public class PoolObject : MonoBehaviour // 定义池对象基类,让具体对象自己清理状态。
{ // 类开始。
public virtual void OnReturnToPool() // 对象回收前调用。
{ // 方法开始。
} // 方法结束。
} // 类结束。
public class SceneBoundPool : MonoBehaviour // 定义一个场景级对象池。
{ // 类开始。
private readonly List<GameObject> _active = new List<GameObject>(); // 保存正在使用的对象。
private readonly Queue<GameObject> _idle = new Queue<GameObject>(); // 保存空闲可复用的对象。
private bool _isCleaning; // 标记当前是否正在切场景清理。
private void OnEnable() // 对象启用时调用。
{ // 方法开始。
SceneManager.sceneUnloaded += OnSceneUnloaded; // 监听场景卸载事件。
} // 方法结束。
private void OnDisable() // 对象禁用时调用。
{ // 方法开始。
SceneManager.sceneUnloaded -= OnSceneUnloaded; // 取消监听,避免事件残留。
} // 方法结束。
public GameObject Get(GameObject prefab, Vector3 position, Quaternion rotation) // 从池中取对象。
{ // 方法开始。
if (_isCleaning) // 如果正在清理。
{ // 判断开始。
return null; // 直接拒绝生成新对象。
} // 判断结束。
GameObject obj = _idle.Count > 0 ? _idle.Dequeue() : Instantiate(prefab); // 优先复用空闲对象,没有就创建。
obj.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转。
obj.SetActive(true); // 激活对象。
_active.Add(obj); // 记录为活跃对象。
return obj; // 返回对象。
} // 方法结束。
public void Return(GameObject obj) // 正常回收单个对象。
{ // 方法开始。
if (obj == null) // 如果对象为空。
{ // 判断开始。
return; // 直接返回。
} // 判断结束。
_active.Remove(obj); // 从活跃列表移除。
if (obj.TryGetComponent<PoolObject>(out PoolObject poolObject)) // 如果对象有池化组件。
{ // 判断开始。
poolObject.OnReturnToPool(); // 让对象自己清理速度、事件、特效、引用。
} // 判断结束。
obj.SetActive(false); // 关闭对象。
obj.transform.SetParent(transform); // 挂回池节点下面。
_idle.Enqueue(obj); // 放回空闲队列。
} // 方法结束。
private void OnSceneUnloaded(Scene scene) // 场景卸载时触发。
{ // 方法开始。
ClearForSceneSwitch(); // 执行切场景清理。
} // 方法结束。
public void ClearForSceneSwitch() // 清理场景级对象池。
{ // 方法开始。
_isCleaning = true; // 封住入口,避免清理时继续生成对象。
for (int i = _active.Count - 1; i >= 0; i--) // 倒序遍历所有活跃对象。
{ // 循环开始。
GameObject obj = _active[i]; // 取出当前活跃对象。
if (obj == null) // 如果对象已经被销毁。
{ // 判断开始。
continue; // 跳过这个对象。
} // 判断结束。
if (obj.TryGetComponent<PoolObject>(out PoolObject poolObject)) // 如果对象有池化组件。
{ // 判断开始。
poolObject.OnReturnToPool(); // 清理对象内部状态和引用。
} // 判断结束。
Destroy(obj); // 场景级池切场景时直接销毁活跃对象。
} // 循环结束。
_active.Clear(); // 清空活跃列表。
while (_idle.Count > 0) // 遍历所有空闲对象。
{ // 循环开始。
GameObject obj = _idle.Dequeue(); // 取出一个空闲对象。
if (obj != null) // 如果对象还存在。
{ // 判断开始。
Destroy(obj); // 销毁空闲对象。
} // 判断结束。
} // 循环结束。
_isCleaning = false; // 清理完成后恢复状态。
} // 方法结束。
} // 类结束。面试加分点
我会特别强调:如果对象池管理器用了 DontDestroyOnLoad,它不会自动随着场景销毁,所以必须主动清掉旧场景引用。否则池里可能还拿着旧敌人、旧 UI、旧 Transform,导致切场景后内存不下降,甚至出现 Missing Reference。
UnloadUnusedAssets() 可以辅助释放不用资源,但它本身很重,最好放在 Loading 阶段做,不要在战斗中突然调用。
如何统计命中率?
标准答案
这里的命中率指对象池的命中率:调用 Get 时,如果能直接从空闲池取到对象,就是命中;如果池空了,需要扩容、创建或失败兜底,就是未命中。
公式是:
命中率 = 命中次数 / Get 请求总次数 * 100%但只看命中率还不够,最好一起统计:
请求次数 requestCount
命中次数 hitCount
未命中次数 missCount
扩容次数 expandCount
创建次数 createCount
失败次数 failCount
最大活跃数 maxActiveCount
最大空闲数 maxIdleCount代码例子
c
using System.Collections.Generic; // 引入 Queue,用来保存对象池空闲对象。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。
public class PoolStats // 定义对象池统计数据类。
{ // 类开始。
public int RequestCount; // Get 请求总次数。
public int HitCount; // 直接从空闲池取到对象的次数。
public int MissCount; // 池空导致未命中的次数。
public int ExpandCount; // 触发扩容的次数。
public int CreateCount; // 实际创建对象的次数。
public int FailCount; // 达到上限后无法返回对象的次数。
public int ActiveCount; // 当前活跃对象数量。
public int MaxActiveCount; // 历史最大活跃对象数量。
public float HitRate // 计算命中率。
{ // 属性开始。
get // 获取命中率。
{ // get 开始。
if (RequestCount == 0) // 如果没有请求。
{ // 判断开始。
return 1f; // 没有请求时可以认为没有压力,返回 100%。
} // 判断结束。
return (float)HitCount / RequestCount; // 返回命中次数除以请求总数。
} // get 结束。
} // 属性结束。
} // 类结束。
public class StatObjectPool : MonoBehaviour // 定义一个带统计的对象池。
{ // 类开始。
[SerializeField] private GameObject prefab; // 需要池化的预制体。
[SerializeField] private int maxCount = 100; // 池子最大创建数量。
private readonly Queue<GameObject> _idle = new Queue<GameObject>(); // 保存空闲对象。
private readonly PoolStats _stats = new PoolStats(); // 保存当前池的统计数据。
private int _totalCount; // 保存总共创建过的对象数量。
public GameObject Get() // 从池里取对象。
{ // 方法开始。
_stats.RequestCount++; // 每次 Get 都记录一次请求。
if (_idle.Count > 0) // 如果空闲池里有对象。
{ // 判断开始。
_stats.HitCount++; // 记录一次命中。
GameObject obj = _idle.Dequeue(); // 从空闲队列取出对象。
Activate(obj); // 激活对象并更新活跃数量。
return obj; // 返回对象。
} // 判断结束。
_stats.MissCount++; // 空闲池为空,记录一次未命中。
if (_totalCount < maxCount) // 如果还没有达到最大容量。
{ // 判断开始。
_stats.ExpandCount++; // 记录一次扩容。
GameObject obj = Instantiate(prefab, transform); // 创建新对象。
_totalCount++; // 总创建数量加一。
_stats.CreateCount++; // 记录创建次数。
Activate(obj); // 激活对象并更新活跃数量。
return obj; // 返回新对象。
} // 判断结束。
_stats.FailCount++; // 达到上限后无法提供对象,记录失败次数。
return null; // 返回空,让上层选择丢弃、等待或降级。
} // 方法结束。
public void Return(GameObject obj) // 归还对象。
{ // 方法开始。
if (obj == null) // 如果对象为空。
{ // 判断开始。
return; // 直接返回。
} // 判断结束。
obj.SetActive(false); // 关闭对象。
obj.transform.SetParent(transform); // 挂回池节点下面。
_idle.Enqueue(obj); // 放回空闲队列。
_stats.ActiveCount = Mathf.Max(0, _stats.ActiveCount - 1); // 活跃数量减一,并防止小于零。
} // 方法结束。
private void Activate(GameObject obj) // 激活对象。
{ // 方法开始。
obj.SetActive(true); // 打开对象。
_stats.ActiveCount++; // 活跃数量加一。
_stats.MaxActiveCount = Mathf.Max(_stats.MaxActiveCount, _stats.ActiveCount); // 更新历史最大活跃数量。
} // 方法结束。
public void PrintStats() // 打印统计信息。
{ // 方法开始。
Debug.Log($"命中率:{_stats.HitRate:P1}, 请求:{_stats.RequestCount}, 未命中:{_stats.MissCount}, 扩容:{_stats.ExpandCount}, 最大活跃:{_stats.MaxActiveCount}"); // 输出关键指标。
} // 方法结束。
} // 类结束。工程判断
如果命中率低,说明预热数量可能太少,或者对象回收太慢。
如果命中率很高,但空闲对象长期很多,说明池子可能过大,常驻内存浪费。
如果扩容次数很多,说明预热策略不合理。
如果失败次数很多,说明最大容量太小,或者玩法瞬时峰值超出预期。
面试加分点
不要只看全局命中率,要按 Prefab key 分开统计。比如全局命中率 95%,但某个技能特效池只有 60%,这个池仍然会造成卡顿尖峰。
一句话总结:命中率看池子是否够用,最大活跃数看预热该调多少,空闲峰值看是否浪费内存。
如何证明优化有效?
标准答案
证明优化有效,不能只说“感觉不卡了”,要拿同条件 Before / After 数据说话。
我一般按这个流程:
- 先确认问题:是 CPU 卡、GPU 卡、GC、内存、加载慢,还是网络慢。
- 建立优化前基线:用 Profiler、Memory Profiler、Frame Debugger、日志记录优化前数据。
- 控制测试条件:同一设备、同一场景、同一账号、同一操作路径、同一画质设置。
- 只改一个主要变量:避免多个改动混在一起,不知道是哪一个生效。
- 复测并对比数据:看平均值、峰值、p95、p99,而不是只看 FPS。
- 防止回退:把关键指标接入日志、调试面板或自动化测试。
例子
如果我优化的是对象池,我会这样证明:
c
优化前:
Main Thread p95:24 ms
GC Alloc / frame:3 KB
Instantiate 次数:1000
卡顿尖峰:12 次 / 分钟
优化后:
Main Thread p95:13 ms
GC Alloc / frame:0 B
Instantiate 次数:30
卡顿尖峰:1 次 / 分钟这样面试官会觉得你的优化是有证据的,不是凭感觉。
代码例子
c
using UnityEngine; // 引入 UnityEngine,用来使用 MonoBehaviour 和 Time。
using UnityEngine.Profiling; // 引入 Profiler,用来添加自定义采样点。
public class OptimizationMeasureDemo : MonoBehaviour // 定义一个简单的优化测量脚本。
{ // 类开始。
private int _frameCount; // 记录累计帧数。
private float _totalFrameMs; // 记录累计帧耗时,单位毫秒。
private float _maxFrameMs; // 记录最大单帧耗时,单位毫秒。
private void Update() // Unity 每帧调用 Update。
{ // 方法开始。
Profiler.BeginSample("MySystem.UpdateCost"); // 在 Profiler 里标记当前系统的耗时范围。
SimulateWork(); // 执行需要观察的逻辑。
Profiler.EndSample(); // 结束 Profiler 标记。
float frameMs = Time.deltaTime * 1000f; // 把当前帧耗时从秒转换为毫秒。
_frameCount++; // 累计帧数加一。
_totalFrameMs += frameMs; // 累加帧耗时。
_maxFrameMs = Mathf.Max(_maxFrameMs, frameMs); // 更新最大帧耗时。
if (_frameCount == 300) // 每 300 帧输出一次统计。
{ // 判断开始。
Debug.Log($"平均帧耗时:{_totalFrameMs / _frameCount:F2}ms, 最大帧耗时:{_maxFrameMs:F2}ms"); // 输出统计结果。
_frameCount = 0; // 重置帧数。
_totalFrameMs = 0f; // 重置累计耗时。
_maxFrameMs = 0f; // 重置最大耗时。
} // 判断结束。
} // 方法结束。
private void SimulateWork() // 模拟需要测量的业务逻辑。
{ // 方法开始。
} // 方法结束。
} // 类结束。面试加分点
我会补一句:FPS 是结果,不是原因。真正证明优化有效,要看 Profiler 里的具体指标,比如 Main Thread、GC Alloc、Canvas.BuildBatch、Camera.Render、DrawCall、SetPass、峰值内存、加载耗时。
一句话总结:优化要能定位、能量化、能复现、能对比、能防回退。