七大设计原则
这些原则是设计模式的指导思想,遵循它们可以让代码更"漂亮"。
| 原则 | 含义 | 游戏开发中的体现 |
|---|---|---|
| 开放-封闭原则 | 对扩展开放,对修改封闭。 | 新增功能时尽量不修改原有代码,而是通过继承或组合来扩展。例如,不同怪物有不同的攻击方式,可以用策略模式实现,而不是不断修改怪物类。 |
| 依赖倒置原则 | 高层模块不应依赖低层模块,二者都应依赖抽象。 | 在 Unity 中,脚本应该依赖于接口或抽象类,而不是具体的类。例如,武器系统依赖于 IWeapon 接口,而不是具体的 Sword 或 Gun 类。 |
| 里氏替换原则 | 子类必须能够替换父类。 | 如果一个方法接受 Enemy 类型,那么传入 BossEnemy(子类)也应该能正常工作。这就要求子类不能改变父类的预期行为。 |
| 单一职责原则 | 一个类只负责一项职责。 | 例如,将玩家输入处理、移动逻辑、动画播放拆分成不同的组件(Component),而不是写在一个巨大的 PlayerController 中。 |
| 接口隔离原则 | 不应强迫类实现它用不到的接口方法。 | 例如,如果有些敌人能飞行,有些不能,那么应该将 IFlyable 接口单独出来,而不是把所有可能的方法都塞进一个 IEnemy 接口。 |
| 合成复用原则 | 尽量使用组合(has-a)而不是继承(is-a)。 | Unity 的组件系统就是组合的典范:一个 GameObject 通过添加不同的组件获得不同功能,而不是通过多层继承。 |
| 迪米特法则 | 一个对象应尽可能少地了解其他对象(最少知识原则)。 | 例如,玩家攻击时,不应该直接操作敌人内部的血量数据,而是通过敌人提供的 TakeDamage() 方法。这样可以减少耦合。 |
建议在实际开发中,先思考设计原则,再选择合适的设计模式。例如,遵循单一职责原则,将不同功能拆分为不同组件;遵循开放-封闭原则,用策略模式扩展攻击方式;遵循依赖倒置原则,用接口解耦。
对于学习者,并不需要背下各个设计模式,我们只需要做到:当实现某些功能时,我们可以联想到使用什么设计模式合适,这时候,我们在细致的学习该设计模式。
注意:本片文章可以让你大致了解常用的设计模式都有什么,有什么用,有什么好处,有一些深层的知识还需要你自己挖掘。
1. 单例模式 (创建型模式)重点!
单例模式基本上在每一个项目中都会使用,也是面试中经常问的点,所以这个会讲的细一些。
定义:保证一个类只有一个实例,并提供一个全局访问点。
在 Unity 中的应用:
- 游戏管理器(GameManager)、音频管理器(AudioManager)、场景加载管理器等,通常只需要一个实例。
简单例子:游戏得分管理器
cs
public class ScoreManager : MonoBehaviour
{
public static ScoreManager Instance { get; private set; }
private int score;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject); // 保证唯一
return;
}
Instance = this;
DontDestroyOnLoad(gameObject); // 场景切换不销毁
}
public void AddScore(int value)
{
score += value;
Debug.Log("当前分数: " + score);
}
}
在其他脚本中可以直接调用:ScoreManager.Instance.AddScore(10);
标准模板(带场景切换不销毁):
cs
using UnityEngine;
public class GameManager : MonoBehaviour
{
// 1. 静态私有字段存储唯一实例
private static GameManager _instance;
// 2. 公共静态属性提供全局访问点
public static GameManager Instance //有时候写小项目会懒得写属性,直接把_instance公开
{ //不过标准写法是这个
get
{
// 如果实例不存在,尝试在场景中查找
if (_instance == null)
{
_instance = FindObjectOfType<GameManager>();
// 如果场景中也没有,则创建一个新的 GameObject 并挂载
if (_instance == null)
{
GameObject go = new GameObject(typeof(GameManager).Name);
_instance = go.AddComponent<GameManager>();
}
}
return _instance;
}
}
// 3. 在 Awake 中确保唯一性
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject); // 已经存在另一个实例,销毁当前对象
return;
}
_instance = this;
DontDestroyOnLoad(gameObject); // 场景切换时不销毁
}
// 4. 可选的初始化方法
private void Start()
{
// 初始化逻辑...
}
// 5. 其他功能方法
public void LoadLevel(string levelName)
{
Debug.Log("加载关卡:" + levelName);
}
}
关键点解释:
-
_instance字段:静态私有,存储唯一实例。 -
Instance属性:公开静态,提供全局访问。内部包含懒加载逻辑:如果实例不存在,先尝试查找场景中已有的,若没有再自动创建。这样可以确保在任何地方首次访问时都能得到有效实例。 -
Awake方法:在对象被创建时执行。这里检查是否已有实例,如果有且不是自己,则销毁自己,保证唯一。DontDestroyOnLoad使该对象在场景切换时不被销毁,维持全局唯一。 -
使用时:
GameManager.Instance.LoadLevel("Level2");
优点:
自动创建,无需手动挂载到场景。
场景切换不销毁,适合全局管理器。
缺点:
过度依赖全局变量,可能导致代码耦合度高。
单元测试困难,因为静态属性难以模拟。
纯 C# 单例(不继承 MonoBehaviour)
用于不需要挂载在 GameObject 上的纯逻辑类,比如数据管理器、网络管理器。实现简单,不需要考虑 Unity 生命周期。
cs
public class DataManager
{
// 1. 静态私有字段
private static DataManager _instance;
// 2. 私有构造函数,防止外部 new 实例
private DataManager() { }
// 3. 静态属性,懒加载
public static DataManager Instance
{
get
{
if (_instance == null)
{
_instance = new DataManager();
}
return _instance;
}
}
// 4. 其他方法
public void SaveData(string key, object value) { }
public object LoadData(string key) { return null; }
}
使用 :
DataManager.Instance.SaveData("score", 100);注意:纯 C# 单例不参与 Unity 的更新循环(Update),如果需要定时更新,需要借助 MonoBehaviour 来驱动。
单例模式的优缺点
| 优点 | 缺点 |
|---|---|
| 控制实例数目:保证一个类只有一个实例,节省系统资源。 | 全局状态:相当于全局变量,可能导致代码耦合,难以追踪依赖。 |
| 灵活性:可以改变实例化过程,比如延迟加载。 | 隐藏依赖 :调用方直接访问 Instance,类之间的依赖关系不明确。 |
| 访问方便:全局访问点,无需传递对象引用。 | 单例的职责可能过重:容易演变成"上帝类",承担过多功能。 |
| 线程安全:在 Unity 主线程中无需考虑(Unity 大部分代码在主线程执行)。 | 单元测试困难:静态实例难以模拟和隔离。 |
单例的使用场景与注意事项
适合使用单例的场景
全局管理器:如 GameManager、AudioManager、UIManager、EventManager。
资源缓存:如 ResourceManager、PoolManager(对象池)。
配置数据:如 SettingsManager、ConfigManager。
外部系统接口:如 IAPManager(内购)、AdManager(广告)。
不适合使用单例的场景
玩家角色、敌人、子弹等动态对象:这些对象可能同时存在多个,不应是单例。
需要频繁创建销毁的对象:单例的生命周期是永久的。
业务逻辑层:如果滥用单例,会导致类之间直接耦合,难以维护和扩展。
进阶:泛型单例基类
为了避免在每个单例类中重复编写相同的代码,可以创建一个泛型基类。
cs
// 泛型单例基类(继承 MonoBehaviour)
public abstract class Singleton<T> : MonoBehaviour where T : Singleton<T>
{
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
_instance = FindObjectOfType<T>();
if (_instance == null)
{
GameObject go = new GameObject(typeof(T).Name);
_instance = go.AddComponent<T>();
}
}
return _instance;
}
}
protected virtual void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = (T)this;
DontDestroyOnLoad(gameObject);
}
}
使用时,任何管理器只需要继承 Singleton<T> 即可:
cs
public class AudioManager : Singleton<AudioManager>
{
public void PlaySound(string clipName) { }
}
常见误区
1.将玩家身上的组件做成单例
问题 :如果玩家全局只有一个,所以把
PlayerMovement、PlayerHealth做成单例,方便其他地方访问。
后果 :如果以后支持多人联机或双人模式,每个玩家都有这些组件,单例会冲突。而且代码耦合严重,所有地方都直接依赖玩家组件的单例。
正确做法 :通过玩家对象的引用来访问,例如使用GameObject.FindWithTag("Player").GetComponent<PlayerHealth>(),或者通过一个PlayerManager单例来持有当前玩家对象的引用。
2.单例的实例在场景切换后还保留
问题 :每个场景都有一个同类型的单例对象,但忘记使用
DontDestroyOnLoad,导致切换场景后原对象被销毁,新对象被创建,可能丢失数据。解决方案 :根据需求决定是否需要跨场景保留。如果需要保留,在
Awake中调用DontDestroyOnLoad;如果不需要,就不要调用,让 Unity 自动销毁。
3.在单例的构造函数或 Awake 中访问其他单例
问题 :两个单例相互依赖,导致空引用异常。
解决方案:避免在 Awake 中访问其他单例,改用 Start 或延迟初始化。或者使用事件机制,在单例初始化完成后发送事件通知。这里了解一下unity生命函数的调用顺序会更好理解
注意:滥用单例会导致全局耦合,不利于单元测试。适合管理类,但不要所有类都做成单例。
2. 工厂模式(创建型模式)
定义:定义一个创建对象的接口,让子类决定实例化哪一个类。工厂方法将对象的创建延迟到子类。
在 Unity 中的应用:
- 创建不同类型的敌人、子弹、道具等。比如根据游戏难度,生成不同属性的敌人。
简单例子:敌人工厂
cs
// 敌人基类
public abstract class Enemy : MonoBehaviour
{
public abstract void Attack();
}
public class Goblin : Enemy
{
public override void Attack() => Debug.Log("哥布林攻击");
}
public class Orc : Enemy
{
public override void Attack() => Debug.Log("兽人攻击");
}
// 工厂类
public static class EnemyFactory
{
public static Enemy CreateEnemy(string type)
{
GameObject enemyObj = new GameObject(type);
Enemy enemy = null;
switch (type)
{
case "Goblin":
enemy = enemyObj.AddComponent<Goblin>();
break;
case "Orc":
enemy = enemyObj.AddComponent<Orc>();
break;
}
return enemy;
}
}
// 使用:EnemyFactory.CreateEnemy("Goblin");
好处:
-
将对象的创建和使用分离,降低耦合。
-
如果需要新增敌人类型,只需增加新的 Enemy 子类和工厂分支,符合开放-封闭原则。
3. 策略模式(行为型模式)
定义:定义一系列算法,将每个算法封装起来,并使它们可以互相替换。策略模式让算法的变化独立于使用算法的客户。
在 Unity 中的应用:
- AI 决策、攻击方式、移动模式等。例如,玩家可以根据装备切换不同的攻击策略。
简单例子:角色攻击策略
cs
public interface IAttackStrategy
{
void Attack();
}
public class MeleeAttack : IAttackStrategy
{
public void Attack() => Debug.Log("近战攻击");
}
public class RangedAttack : IAttackStrategy
{
public void Attack() => Debug.Log("远程射击");
}
public class Player : MonoBehaviour
{
private IAttackStrategy attackStrategy;
public void SetAttackStrategy(IAttackStrategy strategy) => attackStrategy = strategy;
private void Update()
{
if (Input.GetButtonDown("Fire1"))
attackStrategy?.Attack();
}
}
// 使用:player.SetAttackStrategy(new RangedAttack());
好处:
-
避免多重条件判断(if/else 或 switch)。
-
策略可以随时切换,易于扩展新策略。
4. 享元模式(结构型模式)
定义:运用共享技术有效地支持大量细粒度的对象。常用于减少内存占用。
在 Unity 中的应用:
- 游戏中有大量重复对象,如子弹、粒子、草丛、树木等。共享相同的模型、材质、数据,避免重复加载。
简单例子:子弹的共享数据(如速度、伤害、外观)
不享元的写法(每个子弹独立加载材质)
cs
public class Bullet : MonoBehaviour
{
public float speed;
public int damage;
private Material material; // 每个子弹自己的材质
private void Start()
{
// 假设根据子弹类型加载不同材质,但每次新建都重新加载
// 如果生成100颗子弹,就会加载100次材质,内存中有100个相同的材质对象
material = Resources.Load<Material>($"Bullets/{gameObject.tag}");
GetComponent<Renderer>().material = material;
}
private void Update()
{
transform.Translate(Vector3.forward * speed * Time.deltaTime);
}
}
// 生成子弹的代码(每帧可能生成大量子弹)
for (int i = 0; i < 100; i++)
{
GameObject bulletObj = new GameObject("Bullet");
bulletObj.AddComponent<Bullet>(); // 每个子弹都独立加载材质
}
享元模式的写法(共享材质数据)
cs
// 1. 享元类:存储共享的内部状态
public class BulletData
{
public float Speed { get; set; }
public int Damage { get; set; }
public Material Material { get; set; } // 材质只加载一次,被共享
}
// 2. 子弹对象:持有共享数据的引用 + 外部状态
public class Bullet : MonoBehaviour
{
private BulletData data; // 共享数据(享元)
private Vector3 direction; // 外部状态(方向)
// 初始化时传入共享数据和方向
public void Initialize(BulletData data, Vector3 direction)
{
this.data = data;
this.direction = direction;
// 直接使用共享材质,无需重新加载!!!!!
GetComponent<Renderer>().material = data.Material;
}
private void Update()
{
transform.Translate(direction * data.Speed * Time.deltaTime);
}
}
// 3. 工厂:管理享元池,确保每种子弹数据只加载一次
public class BulletFactory
{
private static Dictionary<string, BulletData> cache = new Dictionary<string, BulletData>();
public static Bullet CreateBullet(string type, Vector3 position, Vector3 direction)
{
// 如果缓存中没有该类型的子弹数据,则加载并存入缓存
if (!cache.ContainsKey(type))
{
BulletData data = new BulletData();
data.Speed = 10; // 实际可以从配置表读取
data.Damage = 5;
data.Material = Resources.Load<Material>($"Bullets/{type}"); // 只加载一次
cache[type] = data;
}
// 创建子弹对象,使用缓存的共享数据
GameObject go = new GameObject("Bullet");
go.transform.position = position;
var bullet = go.AddComponent<Bullet>();
bullet.Initialize(cache[type], direction);
return bullet;
}
}
// 生成子弹的代码
for (int i = 0; i < 100; i++)
{
// 所有 "Normal" 类型的子弹都共享同一份 BulletData(包含材质)
BulletFactory.CreateBullet("Normal", Vector3.zero, Vector3.forward);
}
对比总结:
-
不享元:100颗子弹 → 材质加载100次,内存中有100个材质对象。
-
享元:100颗子弹 → 材质加载1次,内存中只有1个材质对象被所有子弹共享。
好处:
- 减少内存开销,提高性能。尤其在移动平台或大规模场景中。
5. 状态模式(行为型模式)
定义:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。
在 Unity 中的应用:
- 玩家状态(行走、跳跃、攻击、受伤)、敌人 AI 状态(巡逻、追逐、攻击)、UI 状态等。
简单例子:
最常见的例子就是动画系统中的状态机
玩家状态机
cs
// 状态接口:定义了所有状态必须实现的方法
public interface IPlayerState
{
// 进入状态时调用
void Enter(Player player);
// 每帧更新(状态对应的行为)
void Update(Player player);
// 离开状态时调用
void Exit(Player player);
}
// 具体状态:站立状态
public class IdleState : IPlayerState
{
public void Enter(Player player)
{
// 可以播放站立动画
Debug.Log("进入站立状态");
}
public void Update(Player player)
{
// 检测水平输入,如果有移动则切换到行走状态
if (Input.GetAxis("Horizontal") != 0 || Input.GetAxis("Vertical") != 0)
{
player.ChangeState(new WalkState()); // 切换状态
}
}
public void Exit(Player player)
{
Debug.Log("退出站立状态");
}
}
// 具体状态:行走状态
public class WalkState : IPlayerState
{
public void Enter(Player player)
{
Debug.Log("进入行走状态");
// 播放行走动画
}
public void Update(Player player)
{
// 处理移动逻辑(这里只是示例,实际移动通常在Player类中处理或由其他组件处理)
float h = Input.GetAxis("Horizontal");
float v = Input.GetAxis("Vertical");
player.transform.Translate(new Vector3(h, 0, v) * player.moveSpeed * Time.deltaTime);
// 如果没有输入了,切换回站立状态
if (h == 0 && v == 0)
{
player.ChangeState(new IdleState());
}
// 检测攻击键,切换到攻击状态(假设有攻击状态)
if (Input.GetButtonDown("Fire1"))
{
player.ChangeState(new AttackState());
}
}
public void Exit(Player player)
{
Debug.Log("退出行走状态");
}
}
// 玩家类:持有当前状态,并提供状态切换方法
public class Player : MonoBehaviour
{
public float moveSpeed = 5f;
private IPlayerState currentState; // 当前状态
private void Start()
{
// 初始状态为站立
ChangeState(new IdleState());
}
private void Update()
{
// 每帧更新当前状态的行为
currentState?.Update(this);
}
// 切换状态的方法
public void ChangeState(IPlayerState newState)
{
// 先退出当前状态(如果有)
currentState?.Exit(this);
// 设置新状态
currentState = newState;
// 进入新状态
currentState?.Enter(this);
}
}
好处:
-
状态独立,易于添加新状态,避免复杂的 if/else 逻辑。
-
状态切换清晰,符合单一职责。
6. 命令模式(行为型模式)
定义:将请求封装成对象,从而允许用不同的请求、队列或日志来参数化其他对象,并支持可撤销操作。
在 Unity 中的应用:
- 玩家操作(移动、攻击)、撤销系统(如编辑器中的撤销)、AI 指令队列。
简单例子:鼠标点击移动队列 vs 单次移动
为了直观展示命令模式的优势,我们设计一个简单的网格移动系统:玩家通过鼠标点击地面,角色移动到点击位置。将对比两种实现:
-
非命令模式 :角色只移动到最后一个鼠标点击的位置(覆盖之前的指令)。
-
命令模式 :角色会依次移动到每一个鼠标点击的位置(形成一个移动队列),体现命令的存储与顺序执行。
非命令模式:每次点击覆盖目标
cs
using UnityEngine;
using UnityEngine.AI;
public class SimpleClickMove : MonoBehaviour
{
private NavMeshAgent agent;
void Start()
{
agent = GetComponent<NavMeshAgent>();
}
void Update()
{
// 鼠标左键点击地面
if (Input.GetMouseButtonDown(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit))
{
// 直接设置代理的目标位置,覆盖之前的目标
agent.SetDestination(hit.point);
}
}
}
}
-
每次点击都直接调用 agent.SetDestination,覆盖之前的移动目标。
-
如果连续点击多个位置,角色只会奔向最后一个点击点。
命令模式:保存每次点击,依次执行
cs
using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;
// 命令接口
public interface ICommand
{
void Execute(); // 执行命令
bool IsFinished { get; } // 是否完成
}
// 具体的移动命令
public class MoveCommand : ICommand
{
private NavMeshAgent agent;
private Vector3 destination;//目标位置
public bool IsFinished { get; private set; }
public MoveCommand(NavMeshAgent agent, Vector3 dest)
{
this.agent = agent;
this.destination = dest;
IsFinished = false;
}
public void Execute()
{
if (IsFinished) return;
// 如果代理还没有路径或者尚未到达,则持续移动
if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance)
{
// 到达目的地
IsFinished = true;
}
else
{
// 第一次执行时设置目标
if (!agent.hasPath)
{
agent.SetDestination(destination);
}
}
}
}
// 命令调用者:玩家输入 + 命令队列管理
public class CommandClickMove : MonoBehaviour
{
private NavMeshAgent agent;
private Queue<ICommand> commandQueue = new Queue<ICommand>();//队列保存
private ICommand currentCommand;
void Start()
{
agent = GetComponent<NavMeshAgent>();
}
void Update()
{
// 鼠标点击创建新命令
if (Input.GetMouseButtonDown(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit))
{
// 将移动命令加入队列
commandQueue.Enqueue(new MoveCommand(agent, hit.point));
}
}
// 命令队列管理
if (currentCommand == null || currentCommand.IsFinished)
{
// 当前命令已完成,取下一条
if (commandQueue.Count > 0)
{
currentCommand = commandQueue.Dequeue();
}
}
// 执行当前命令
currentCommand?.Execute();
}
}
-
每次鼠标点击都会创建一个
MoveCommand并存入队列。 -
角色会先移动到第一个点击点,完成后自动开始移动向第二个点击点,依次类推。
-
可以直观看到命令模式将"移动请求"封装成了对象,实现了请求的排队和顺序执行。
好处:
-
解耦命令发出者和执行者。
-
请求可存储:所有点击目标都被保存,而不是被覆盖。
-
易于扩展:轻松实现撤销、重做、宏命令等功能。
7. 组件模式(并非 GoF 原始模式,但在 Unity 中极为重要)
定义:通过将功能拆分为独立的组件,动态组合对象的行为。Unity 的 GameObject-Component 结构本身就是组件模式的体现。
在 Unity 中的应用:
- 几乎所有 Unity 脚本都是组件。例如,玩家可以拥有移动组件、射击组件、血量组件等。
简单例子:unity中给游戏对象拖入碰撞体组件、刚体组件、自定义脚本组件等,都属于组件模式
好处:
-
高复用性,灵活组合。
-
符合合成复用原则,比继承更灵活。
8. 观察者模式(行为型模式)
定义:定义对象之间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都得到通知并自动更新。
在 Unity 中的应用:
- 事件系统、成就系统、UI 更新(如血量变化时更新血条)、任务系统等。
简单例子:玩家血量变化通知 UI
cs
// 定义委托类型
public delegate void HealthChanged(int currentHealth);
// 被观察者(玩家血量)
public class PlayerHealth : MonoBehaviour
{
// 声明一个事件,类型为 HealthChanged 委托
// 其他类可以注册(+=)或注销(-=)自己的方法来监听这个事件
public event HealthChanged OnHealthChanged;
private int health = 100;
public void TakeDamage(int damage)
{
health -= damage;
Debug.Log($"玩家受伤,剩余血量:{health}");
OnHealthChanged?.Invoke(health);
}
}
// 观察者(UI血条)
public class HealthBar : MonoBehaviour
{
private void Start()
{
// 找到玩家,并订阅玩家的血量变化事件
PlayerHealth playerHealth = FindObjectOfType<PlayerHealth>();
if (playerHealth != null)
{
playerHealth.OnHealthChanged += UpdateHealthBar; // 注册方法
}
}
// 事件处理方法,必须符合 HealthChanged 委托的签名(参数 int)
private void UpdateHealthBar(int currentHealth)
{
// 更新UI显示,比如调整血条长度
Debug.Log($"血条更新:当前血量 {currentHealth}");
// 这里可以写具体的UI更新代码
}
private void OnDestroy()
{
// 当对象销毁时,最好取消订阅,避免内存泄漏
PlayerHealth playerHealth = FindObjectOfType<PlayerHealth>();
if (playerHealth != null)
{
playerHealth.OnHealthChanged -= UpdateHealthBar; // 取消注册
}
}
}
TakeDamage 开始
health 减少
触发 OnHealthChanged
→ 调用 HealthBar.UpdateHealthBar(health)
HealthBar 更新UI
事件触发结束
TakeDamage 结束
好处:
-
降低耦合,被观察者不需要知道观察者的细节。
-
方便扩展新观察者(如添加成就系统监听玩家死亡)。
9.对象池模式 重要
对象池模式是一种性能优化 的设计模式,它通过重用已经创建的对象来减少频繁创建和销毁对象带来的开销。在 Unity 游戏开发中,这个模式尤为重要,因为游戏中经常需要大量生成和销毁对象(如子弹、敌人、特效),如果每次都使用 Instantiate 和 Destroy,会导致内存碎片和频繁的垃圾回收(GC),从而引起游戏卡顿。
对象池的核心思想是:当需要一个对象时,不从零创建,而是从池中取出一个已经存在的空闲对象;当对象不再使用时,不销毁它,而是放回池中等待下次重用。这样大大减少了内存分配和回收的频率。
对象池模式的适用场景
子弹:射击游戏中有大量子弹生成和消失。
敌人:波次生成的敌人,死亡后可以回收入池,下次刷新时直接重用。
粒子特效:爆炸、击中特效等频繁播放。
掉落物:金币、血包等短暂存在的物品。
任何频繁创建和销毁且状态可以重置的对象。
对象池模式是 Unity 性能优化的必备技巧 ,尤其对于频繁生成和销毁对象的游戏(如射击、动作类)但其核心原理很好理解:
无对象池 :调用Instantiate(实例化)和Destroy(销毁)有对象池:调用SetActive(true)和SetActive(false)
对象池的基本实现
一个标准的对象池通常包含以下部分:
池容器 :通常使用
Queue或Stack存储空闲对象,保证获取和归还的效率。创建方法:当池中没有空闲对象时,需要创建新对象的逻辑。
获取对象:从池中取出一个对象,激活并初始化它。
归还对象:对象使用完毕后,将其重置并放回池中,同时隐藏或禁用。
扩容策略:当池不够用时,可以动态创建新对象,也可以限制最大大小。
Unity 中的简单对象池实现
以下是一个通用的对象池脚本,可用于任何 GameObject 对象的复用。
4.1 对象池管理器
cs
using System.Collections.Generic;
using UnityEngine;
public class ObjectPool : MonoBehaviour
{
// 要池化的对象预制体
public GameObject prefab;
// 初始池大小(预热)
public int initialSize = 10;
// 池的最大容量(可选)
public int maxSize = 50;
// 使用队列存储空闲对象(先进先出,保证公平)
private Queue<GameObject> pool = new Queue<GameObject>();
private void Start()
{
// 预先创建指定数量的对象,并放入池中
// 一般在关卡的加载界面时就会做类似的工作
for (int i = 0; i < initialSize; i++)
{
GameObject obj = CreateNewObject();
obj.SetActive(false); // 初始隐藏
pool.Enqueue(obj);
}
}
// 创建一个新对象(不放入池,只创建)
private GameObject CreateNewObject()
{
GameObject obj = Instantiate(prefab);
obj.transform.SetParent(transform); // 将对象设为池的子物体,保持层级整洁
return obj;
}
// 从池中获取一个对象
public GameObject Get()
{
if (pool.Count > 0)
{
// 有可用对象,取出并激活
GameObject obj = pool.Dequeue();
obj.SetActive(true);
return obj;
}
else
{
// 池为空,根据策略处理:可以创建新对象,或者等待(这里简单创建新对象)
// 但要注意不要超过最大容量限制
if (pool.Count + 1 > maxSize)
{
Debug.LogWarning("对象池已达最大容量,请考虑增加 maxSize 或优化使用");
}
return CreateNewObject();
}
}
// 归还对象到池中
public void ReturnToPool(GameObject obj)
{
obj.SetActive(false); // 禁用对象
obj.transform.SetParent(transform); // 重新挂到池下
pool.Enqueue(obj); // 放回队列
}
}
4.2 使用对象池的子弹脚本
cs
public class Bullet : MonoBehaviour
{
private ObjectPool pool; // 归属的对象池,通常在生成时由池赋值
private float lifeTime = 2f; // 子弹最长生存时间
private void OnEnable()
{
// 每次启用时启动自动归还协程
StartCoroutine(AutoReturn());
}
private IEnumerator AutoReturn()
{
yield return new WaitForSeconds(lifeTime);
ReturnToPool();
}
// 子弹碰到物体时归还(例如碰撞时)
private void OnCollisionEnter(Collision collision)
{
// 处理碰撞效果...
ReturnToPool();
}
// 归还自身到池中
private void ReturnToPool()
{
if (pool != null)
{
pool.ReturnToPool(gameObject);
}
else
{
// 如果没有池(降级处理),直接销毁
Destroy(gameObject);
}
}
// 设置归属池(由对象池在生成时调用)
public void SetPool(ObjectPool owner)
{
pool = owner;
}
}
4.3 对象生成器的示例
cs
public class BulletSpawner : MonoBehaviour
{
public ObjectPool bulletPool; // 在 Inspector 中指定子弹对象池
void Update()
{
if (Input.GetButtonDown("Fire1"))
{
// 从池中获取一颗子弹
GameObject bulletObj = bulletPool.Get();
bulletObj.transform.position = transform.position;
bulletObj.transform.rotation = transform.rotation;
// 设置子弹的归属池(可选,用于子弹自动归还)
Bullet bullet = bulletObj.GetComponent<Bullet>();
if (bullet != null)
{
bullet.SetPool(bulletPool);
}
}
}
}
创建子弹预制体,挂载
Bullet脚本。在场景中创建一个空物体作为对象池,挂载
ObjectPool脚本,并将子弹预制体赋值给prefab。创建子弹生成器(如枪口),挂载
BulletSpawner,并在 Inspector 中将 bulletPool 指向对象池对象。
对象池模式的优点与缺点
| 优点 | 缺点 |
|---|---|
| 大幅减少内存分配和垃圾回收:避免频繁 Instantiate/Destroy,提升性能,防止卡顿。 | 增加代码复杂度:需要管理池和对象的状态。 |
| 控制对象数量:可以限制同时存在的对象数,防止资源耗尽。 | 对象状态需要正确重置:如果对象状态没有完全重置,可能导致逻辑错误(如残留的 buff 或位置)。 |
| 预热机制:可以在游戏加载时预先创建对象,避免运行时卡顿。 | 占用内存:池中的对象即使不用也占用内存,需要权衡初始大小和最大容量。 |
| 资源复用:减少资源加载次数,提高响应速度。 | 对象生命周期管理:需要注意对象归还的时机,避免对象被误用或遗漏归还。 |