【Unity游戏创作】常见的设计模式

七大设计原则

这些原则是设计模式的指导思想,遵循它们可以让代码更"漂亮"。

原则 含义 游戏开发中的体现
开放-封闭原则 对扩展开放,对修改封闭。 新增功能时尽量不修改原有代码,而是通过继承或组合来扩展。例如,不同怪物有不同的攻击方式,可以用策略模式实现,而不是不断修改怪物类。
依赖倒置原则 高层模块不应依赖低层模块,二者都应依赖抽象。 在 Unity 中,脚本应该依赖于接口或抽象类,而不是具体的类。例如,武器系统依赖于 IWeapon 接口,而不是具体的 SwordGun 类。
里氏替换原则 子类必须能够替换父类。 如果一个方法接受 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.将玩家身上的组件做成单例

问题 :如果玩家全局只有一个,所以把 PlayerMovementPlayerHealth 做成单例,方便其他地方访问。
后果 :如果以后支持多人联机或双人模式,每个玩家都有这些组件,单例会冲突。而且代码耦合严重,所有地方都直接依赖玩家组件的单例。
正确做法 :通过玩家对象的引用来访问,例如使用 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 游戏开发中,这个模式尤为重要,因为游戏中经常需要大量生成和销毁对象(如子弹、敌人、特效),如果每次都使用 InstantiateDestroy,会导致内存碎片和频繁的垃圾回收(GC),从而引起游戏卡顿。

对象池的核心思想是:当需要一个对象时,不从零创建,而是从池中取出一个已经存在的空闲对象;当对象不再使用时,不销毁它,而是放回池中等待下次重用。这样大大减少了内存分配和回收的频率。

对象池模式的适用场景

  • 子弹:射击游戏中有大量子弹生成和消失。

  • 敌人:波次生成的敌人,死亡后可以回收入池,下次刷新时直接重用。

  • 粒子特效:爆炸、击中特效等频繁播放。

  • 掉落物:金币、血包等短暂存在的物品。

  • 任何频繁创建和销毁且状态可以重置的对象

对象池模式是 Unity 性能优化的必备技巧 ,尤其对于频繁生成和销毁对象的游戏(如射击、动作类)但其核心原理很好理解:
无对象池 :调用Instantiate(实例化)Destroy(销毁)

有对象池:调用SetActive(true)和SetActive(false)

对象池的基本实现

一个标准的对象池通常包含以下部分:

  • 池容器 :通常使用 QueueStack 存储空闲对象,保证获取和归还的效率。

  • 创建方法:当池中没有空闲对象时,需要创建新对象的逻辑。

  • 获取对象:从池中取出一个对象,激活并初始化它。

  • 归还对象:对象使用完毕后,将其重置并放回池中,同时隐藏或禁用。

  • 扩容策略:当池不够用时,可以动态创建新对象,也可以限制最大大小。

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);
            }
        }
    }
}
  1. 创建子弹预制体,挂载 Bullet 脚本。

  2. 在场景中创建一个空物体作为对象池,挂载 ObjectPool 脚本,并将子弹预制体赋值给 prefab

  3. 创建子弹生成器(如枪口),挂载 BulletSpawner,并在 Inspector 中将 bulletPool 指向对象池对象。

对象池模式的优点与缺点

优点 缺点
大幅减少内存分配和垃圾回收:避免频繁 Instantiate/Destroy,提升性能,防止卡顿。 增加代码复杂度:需要管理池和对象的状态。
控制对象数量:可以限制同时存在的对象数,防止资源耗尽。 对象状态需要正确重置:如果对象状态没有完全重置,可能导致逻辑错误(如残留的 buff 或位置)。
预热机制:可以在游戏加载时预先创建对象,避免运行时卡顿。 占用内存:池中的对象即使不用也占用内存,需要权衡初始大小和最大容量。
资源复用:减少资源加载次数,提高响应速度。 对象生命周期管理:需要注意对象归还的时机,避免对象被误用或遗漏归还。

相关书籍:

游戏编程模式

相关推荐
Yongqiang Cheng2 小时前
设计模式:C++ 模板方法模式 (Template Method in C++)
设计模式·template method·c++ 模板方法模式
专注VB编程开发20年2 小时前
C#,VB.NET如何用GPU进行大量计算,提高效率?
开发语言·c#·.net
我爱cope2 小时前
【从0开始学设计模式-3| 工厂模式】
设计模式
qq_454245032 小时前
开源GraphMindStudio工作流引擎:自动化与AI智能体的理想核心
运维·人工智能·开源·c#·自动化
资深web全栈开发13 小时前
设计模式之空对象模式 (Null Object Pattern)
设计模式
我爱cope17 小时前
【从0开始学设计模式-2| 面向对象设计原则】
设计模式
(initial)17 小时前
B-02. Shared Memory 深度优化:从 Bank Conflict 到 Tensor Core Swizzling
开发语言·c#
资深web全栈开发1 天前
设计模式之访问者模式 (Visitor Pattern)
设计模式·访问者模式
fdc20171 天前
解耦的艺术:用责任链模式构建可插拔的文件处理流水线
c#·.net·责任链模式