当前位置: 首页 > news >正文

Unity设计模式实战:用单例和观察者模式优化你的游戏架构 (Day 36)

Langchain系列文章目录

01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘

PyTorch系列文章目录

Python系列文章目录

C#系列文章目录

01-C#与游戏开发的初次见面:从零开始的Unity之旅
02-C#入门:从变量与数据类型开始你的游戏开发之旅
03-C#运算符与表达式:从入门到游戏伤害计算实践
04-从零开始学C#:用if-else和switch打造智能游戏逻辑
05-掌握C#循环:for、while、break与continue详解及游戏案例
06-玩转C#函数:参数、返回值与游戏中的攻击逻辑封装
07-Unity游戏开发入门:用C#控制游戏对象移动
08-C#面向对象编程基础:类的定义、属性与字段详解
09-C#封装与访问修饰符:保护数据安全的利器
10-如何用C#继承提升游戏开发效率?Enemy与Boss案例解析
11-C#多态性入门:从零到游戏开发实战
12-C#接口王者之路:从入门到Unity游戏开发实战 (IAttackable案例详解)
13-C#静态成员揭秘:共享数据与方法的利器
14-Unity 面向对象实战:掌握组件化设计与脚本通信,构建玩家敌人交互
15-C#入门 Day15:彻底搞懂数组!从基础到游戏子弹管理实战
16-C# List 从入门到实战:掌握动态数组,轻松管理游戏敌人列表 (含代码示例)
17-C# 字典 (Dictionary) 完全指南:从入门到游戏属性表实战 (Day 17)
18-C#游戏开发【第18天】 | 深入理解队列(Queue)与栈(Stack):从基础到任务队列实战
19-【C# 进阶】深入理解枚举 Flags 属性:游戏开发中多状态组合的利器
20-C#结构体(Struct)深度解析:轻量数据容器与游戏开发应用 (Day 20)
21-Unity数据持久化进阶:告别硬编码,用ScriptableObject优雅管理游戏配置!(Day 21)
22-Unity C# 健壮性编程:告别崩溃!掌握异常处理与调试的 4 大核心技巧 (Day 22)
23-C#代码解耦利器:委托与事件(Delegate & Event)从入门到实践 (Day 23)
24-Unity脚本通信终极指南:从0到1精通UnityEvent与事件解耦(Day 24)
25-精通C# Lambda与LINQ:Unity数据处理效率提升10倍的秘诀! (Day 25)
26-# Unity C#进阶:掌握泛型编程,告别重复代码,编写优雅复用的通用组件!(Day26)
27-Unity协程从入门到精通:告别卡顿,用Coroutine优雅处理异步与时序任务 (Day 27)
28-搞定玩家控制!Unity输入系统、物理引擎、碰撞检测实战指南 (Day 28)
29-# Unity动画控制核心:Animator状态机与C#脚本实战指南 (Day 29)
30-Unity UI 从零到精通 (第30天): Canvas、布局与C#交互实战 (Day 30)
31-Unity性能优化利器:彻底搞懂对象池技术(附C#实现与源码解析)
32-Unity C#进阶:用状态模式与FSM优雅管理复杂敌人AI,告别Spaghetti Code!(Day32)
33-Unity游戏开发实战:从PlayerPrefs到JSON,精通游戏存档与加载机制(Day 33)
34-Unity C# 实战:从零开始为游戏添加背景音乐与音效 (AudioSource/AudioClip/AudioMixer 详解)(Day 34)
35-Unity 场景管理核心教程:从 LoadScene 到 Loading Screen 实战 (Day 35)
36-Unity设计模式实战:用单例和观察者模式优化你的游戏架构 (Day 36)


文章目录

  • Langchain系列文章目录
  • PyTorch系列文章目录
  • Python系列文章目录
  • C#系列文章目录
  • 前言
  • 一、设计模式概述
    • 1.1 什么是设计模式?
      • 1.1.1 定义与目的
      • 1.1.2 设计模式的分类(简介)
    • 1.2 为何在Unity中使用设计模式?
  • 二、单例模式 (Singleton): 全局访问点
    • 2.1 单例模式的核心思想
      • 2.1.1 定义
      • 2.1.2 工作机制
    • 2.2 Unity中实现单例模式
      • 2.2.1 基础非MonoBehaviour单例(了解即可)
      • 2.2.2 基于MonoBehaviour的单例(常用)
      • 2.2.3 线程安全考量
    • 2.3 单例模式的应用场景
    • 2.4 单例模式的优缺点与注意事项
      • 2.4.1 优点
      • 2.4.2 缺点
      • 2.4.3 注意事项
  • 三、观察者模式 (Observer): 事件驱动解耦
    • 3.1 观察者模式的核心思想
      • 3.1.1 定义
      • 3.1.2 工作机制
    • 3.2 Unity中实现观察者模式
      • 3.2.1 手动实现(理解原理)
      • 3.2.2 利用C#事件(推荐,简洁高效)
      • 3.2.3 利用UnityEvent(Inspector友好)
    • 3.3 观察者模式 vs C#事件 vs UnityEvent
    • 3.4 观察者模式的应用场景
    • 3.5 观察者模式的优缺点与注意事项
      • 3.5.1 优点
      • 3.5.2 缺点
      • 3.5.3 注意事项
  • 四、其他常用设计模式简介
    • 4.1 工厂模式 (Factory Pattern)
      • 4.1.1 核心思想
      • 4.1.2 Unity应用场景
    • 4.2 状态模式 (State Pattern)
      • 4.2.1 核心思想
      • 4.2.2 Unity应用场景
    • 4.3 对象池模式 (Object Pooling)
      • 4.3.1 核心思想
      • 4.3.2 Unity应用场景
  • 五、实战演练:构建游戏管理器与成就系统
    • 5.1 实现GameManager单例
    • 5.2 实现简单的成就系统(观察者模式)
    • 5.3 创建触发事件源(示例)
  • 六、总结


前言

欢迎来到《C# for Unity游戏开发学习之旅》的第36天!经过前几周对C#基础、面向对象、数据结构以及Unity核心机制的学习,今天我们将进入一个提升代码质量和项目可维护性的关键领域——设计模式。设计模式是软件开发中经过验证的、解决特定问题的可复用方案。在复杂的游戏项目中,合理运用设计模式能够显著优化游戏架构,降低模块间的耦合度,提高代码的可读性、扩展性和健壮性。

本篇将聚焦于Unity开发中最常用、也最基础的两个设计模式:单例(Singleton)模式观察者(Observer)模式。我们将深入探讨它们的原理、实现方式、在Unity中的具体应用场景(如全局管理器、事件驱动系统),并进行利弊分析。最后,我们会通过一个实战演练,亲手实现一个GameManager单例,并运用观察者模式构建一个简单的成就解锁通知系统。让我们一起学习如何运用这些经典模式,让我们的Unity项目架构更加优雅和高效!

一、设计模式概述

1.1 什么是设计模式?

1.1.1 定义与目的

设计模式(Design Pattern)是在软件工程领域中,针对特定问题、环境下反复出现的、经过实践验证的、可复用的解决方案。它不是一段可以直接复制粘贴的代码,而是一种思想、一种最佳实践、一种通用词汇

目的:

  • 代码复用性: 提供可重用的设计方案。
  • 可读性: 使用公认的模式名称,方便团队成员理解代码意图。
  • 可扩展性: 使系统更容易适应未来的变化和需求。
  • 可靠性: 基于经过验证的解决方案,减少潜在错误。

类比: 就像建筑师使用标准化的蓝图元素(如承重墙、窗户类型)来设计建筑一样,软件开发者使用设计模式来构建健壮、灵活的软件系统。

1.1.2 设计模式的分类(简介)

设计模式通常分为三大类:

  • 创建型模式(Creational Patterns): 关注对象的创建过程,旨在将对象的创建与使用分离。例如:单例模式、工厂模式、抽象工厂模式、建造者模式、原型模式。
  • 结构型模式(Structural Patterns): 关注类和对象的组合,形成更大的结构。例如:适配器模式、桥接模式、组合模式、装饰器模式、外观模式、享元模式、代理模式。
  • 行为型模式(Behavioral Patterns): 关注对象之间的职责分配和算法封装。例如:观察者模式、策略模式、模板方法模式、命令模式、迭代器模式、状态模式、职责链模式、访问者模式、中介者模式、备忘录模式。

1.2 为何在Unity中使用设计模式?

Unity本身基于**组件化(Component-Based)**的设计哲学,这本身就是一种架构模式。然而,随着项目规模的增长,单纯的组件化可能会导致以下问题:

  • 组件间通信混乱: 脚本之间互相引用、调用,形成复杂的依赖关系网(“意大利面条”代码)。
  • 全局状态管理困难: 需要在不同场景、不同对象间共享的数据和功能难以统一管理。
  • 逻辑分散: 特定功能的逻辑可能散落在多个脚本中,不易维护和修改。
  • 代码重复: 相似的逻辑或创建过程在多处重复出现。

设计模式可以帮助我们解决这些问题:

  • 解耦: 降低模块间的依赖,如观察者模式。
  • 集中管理: 提供全局访问点,如单例模式。
  • 封装变化: 将易变的部分隔离起来,如策略模式、状态模式。
  • 简化创建: 统一对象的创建逻辑,如工厂模式。
  • 提升性能: 优化资源使用,如对象池模式。

因此,在Unity项目中理解并恰当运用设计模式,对于构建可维护、可扩展、高性能的游戏至关重要。

二、单例模式 (Singleton): 全局访问点

2.1 单例模式的核心思想

2.1.1 定义

单例模式确保一个类只有一个实例,并提供一个全局访问点来获取这个唯一的实例。

2.1.2 工作机制

通常通过以下方式实现:

  1. 将类的构造函数设为私有(private),防止外部直接通过 new 创建实例。
  2. 在类的内部创建一个静态的(static)私有实例变量。
  3. 提供一个公共的(public)静态方法或属性,用于获取这个唯一的实例。如果实例不存在,则创建它;如果已存在,则直接返回。

类比: 就像一所学校只有一个校长办公室,所有需要找校长处理事务的人都必须通过这个唯一的入口。

2.2 Unity中实现单例模式

在Unity中,单例通常用于管理全局性的游戏状态或服务,并且这些管理器往往需要是MonoBehaviour以便能挂载到游戏对象上,并利用Unity的生命周期函数(如Awake, Start)。

2.2.1 基础非MonoBehaviour单例(了解即可)

using UnityEngine;// 适用于不需要挂载到GameObject上的纯C#类
public class SettingsManager
{// 1. 私有静态实例变量private static SettingsManager _instance;// 3. 公共静态属性,用于获取实例 (懒汉式加载)public static SettingsManager Instance{get{// 如果实例不存在,则创建if (_instance == null){_instance = new SettingsManager();Debug.Log("SettingsManager instance created.");// 在这里可以进行初始化设置加载等}return _instance;}}// 2. 私有构造函数private SettingsManager(){// 防止外部 newLoadSettings(); // 示例:构造时加载设置}// 示例:成员变量和方法public float MasterVolume { get; private set; } = 1.0f;private void LoadSettings(){// 实际中可能从PlayerPrefs或文件加载MasterVolume = PlayerPrefs.GetFloat("MasterVolume", 1.0f);Debug.Log("Settings loaded. Master Volume: " + MasterVolume);}public void SetMasterVolume(float volume){MasterVolume = Mathf.Clamp01(volume);PlayerPrefs.SetFloat("MasterVolume", MasterVolume);Debug.Log("Master Volume set to: " + MasterVolume);// 可能还需要触发音量更新事件}
}// 如何使用:
// SettingsManager.Instance.SetMasterVolume(0.8f);
// float currentVolume = SettingsManager.Instance.MasterVolume;

注意: 这种纯C#单例在Unity中用途有限,因为它无法利用MonoBehaviour的特性(如协程、生命周期函数、Inspector面板)。

2.2.2 基于MonoBehaviour的单例(常用)

这是Unity中最常见的单例实现方式,通常在Awake方法中进行初始化和实例检查。

using UnityEngine;// 泛型 MonoBehaviour 单例基类,方便复用
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{private static T _instance;private static readonly object _lock = new object(); // 用于线程安全(虽然Unity主线程操作通常不需要,但作为良好实践)private static bool _applicationIsQuitting = false;public static T Instance{get{if (_applicationIsQuitting){Debug.LogWarning($"[Singleton] Instance '{typeof(T)}' already destroyed on application quit. Won't create again - returning null.");return null;}lock (_lock) // 锁定以确保线程安全{if (_instance == null){// 尝试在场景中查找实例_instance = FindObjectOfType<T>();// 如果场景中找到多个实例if (FindObjectsOfType<T>().Length > 1){Debug.LogError($"[Singleton] Something went really wrong - there should never be more than 1 singleton! Reopening the scene might fix it. Type: {typeof(T)}");return _instance; // 返回找到的第一个,但发出严重错误警告}// 如果场景中没有找到实例if (_instance == null){// 尝试动态创建一个新的GameObject挂载单例脚本GameObject singletonObject = new GameObject();_instance = singletonObject.AddComponent<T>();singletonObject.name = typeof(T).ToString() + " (Singleton)";// 使单例在场景切换时不被销毁 (根据需要选择是否保留)// DontDestroyOnLoad(singletonObject); // 按需取消注释Debug.Log($"[Singleton] An instance of {typeof(T)} is needed in the scene, so '{singletonObject.name}' was created.");}else{Debug.Log($"[Singleton] Using instance already created: {_instance.gameObject.name}");// 如果需要跨场景保留,确保找到的实例也被标记// DontDestroyOnLoad(_instance.gameObject); // 按需取消注释}}return _instance;}}}// 可选:在Awake中进行实例检查,防止手动放置多个实例protected virtual void Awake(){if (_instance == null){_instance = this as T;// 根据需要决定是否跨场景保留// DontDestroyOnLoad(gameObject);}else if (_instance != this){Debug.LogWarning($"[Singleton] Another instance of {typeof(T)} detected on {gameObject.name}. Destroying this duplicate.");Destroy(gameObject); // 销毁重复的实例}}// 防止在程序退出后,其他脚本调用Instance时重新创建幽灵对象protected virtual void OnDestroy(){if (_instance == this){_applicationIsQuitting = true; // 标记程序正在退出}}// 可选:处理程序退出事件,更可靠地设置退出标记protected virtual void OnApplicationQuit(){_applicationIsQuitting = true;}
}// 如何使用:
// 1. 创建你的管理器脚本,继承自 Singleton<T>
public class GameManager : Singleton<GameManager>
{// 在这里添加GameManager的特定逻辑和数据public int Score { get; private set; }// Awake可以被重写,但记得调用 base.Awake() 如果基类有逻辑protected override void Awake(){base.Awake(); // 调用基类的Awake逻辑Debug.Log("GameManager Awake called.");// GameManager 特有的初始化Score = 0;}public void AddScore(int amount){Score += amount;Debug.Log($"Score: {Score}");// 这里可以触发一个得分更新事件(后面会讲观察者模式)}
}// 2. 在其他任何脚本中访问:
// GameManager.Instance.AddScore(10);
// int currentScore = GameManager.Instance.Score;

关键点:

  • where T : MonoBehaviour: 泛型约束,确保T必须是MonoBehaviour或其子类。
  • Awake(): Unity的生命周期函数,在对象加载时调用,适合进行初始化和实例检查。
  • FindObjectOfType<T>(): 如果实例未被赋值(例如场景刚加载),尝试在场景中查找。
  • DontDestroyOnLoad(gameObject): (可选) 使挂载此脚本的GameObject在加载新场景时不会被销毁,常用于需要跨场景存在的管理器(如AudioManager, GameManager)。
  • 重复实例处理: Awake中的检查可以防止开发者不小心在场景中放置多个单例对象。
  • 退出标记 (_applicationIsQuitting): 防止在应用程序关闭过程中,某个OnDestroy里的代码又去访问即将销毁的单例,导致创建“幽灵”对象。

2.2.3 线程安全考量

在Unity中,大部分脚本逻辑运行在主线程上,因此基础的MonoBehaviour单例通常不需要复杂的线程锁。然而,如果你在Unity中使用了多线程(例如进行网络通信、后台数据处理),并且需要在其他线程访问单例,那么上面泛型示例中的lock (_lock)就变得必要,以防止竞态条件。但在常规游戏逻辑中,过度使用锁可能带来不必要的性能开销。

2.3 单例模式的应用场景

单例模式非常适合用于表示那些在概念上全局唯一且需要方便访问的组件或服务:

  • GameManager: 管理游戏整体状态(如游戏暂停、结束)、流程控制、全局设置。
  • AudioManager: 控制背景音乐、音效的播放与音量。
  • InputManager: 统一处理玩家输入。
  • UIManager: 管理UI元素的显示、隐藏和交互(有时也可以用其他模式)。
  • SceneController: 管理场景加载与切换。
  • ObjectPoolManager: 管理对象池。
  • PlayerDataManager: 管理玩家数据的加载与保存。

2.4 单例模式的优缺点与注意事项

2.4.1 优点

  • 全局访问: 提供了一个方便的全局访问点,任何地方都可以轻松获取实例。
  • 确保唯一性: 保证了某个类只有一个实例,避免了因多个实例导致的状态不一致或资源冲突。
  • 懒加载 (Lazy Initialization): 实例可以在第一次被请求时才创建,节省了启动时间(虽然在Unity的Awake中实现更像是预加载)。

2.4.2 缺点

  • 全局状态: 单例引入了全局状态,这可能使得代码难以理解和测试,因为对象的状态可能在代码库的任何地方被修改。
  • 紧耦合: 大量代码直接依赖于单例实例,使得这些代码与单例紧密耦合。如果单例的实现发生变化,可能会影响很多地方。这违反了依赖倒置原则。
  • 隐藏依赖: 代码对单例的依赖是隐式的,不像通过构造函数或方法参数传递那样明确。
  • 测试困难: 依赖全局单例的代码单元测试变得困难,因为难以模拟或替换单例的行为。
  • 可能被滥用: 由于其方便性,开发者可能倾向于过度使用单例,将不适合做成单例的类也设计成单例,导致架构僵化。
  • 场景依赖问题: 基于MonoBehaviour的单例需要挂载在场景中的GameObject上。如果忘记放置或意外删除,可能会在运行时出错(尽管上面的泛型实现尝试动态创建来缓解)。

2.4.3 注意事项

  • 谨慎使用: 仅在确实需要全局唯一实例且需要方便全局访问的情况下使用。问问自己:这个对象真的必须是全局唯一的吗?有没有其他方式(如依赖注入、服务定位器)可以替代?
  • 职责单一: 单例类应遵循单一职责原则,不要让一个GameManager承担过多不相关的任务。
  • 考虑生命周期: 明确单例是否需要跨场景存在(DontDestroyOnLoad)。不必要的常驻对象会占用内存。
  • 避免在构造函数或Awake中访问其他单例: 这可能导致初始化顺序问题。如果单例间有依赖,考虑使用Start或提供显式的初始化方法。

三、观察者模式 (Observer): 事件驱动解耦

3.1 观察者模式的核心思想

3.1.1 定义

观察者模式定义了一种一对多的依赖关系,让多个观察者(Observer)对象同时监听某一个主题(Subject)对象。当主题对象的状态发生变化时,它会自动通知所有依赖于它的观察者对象,使它们能够自动更新自己。

3.1.2 工作机制

  1. Subject(主题/发布者):
    • 维护一个观察者列表。
    • 提供添加(Attach/Subscribe)和移除(Detach/Unsubscribe)观察者的方法。
    • 当自身状态改变时,调用一个通知(Notify)方法,遍历观察者列表,调用每个观察者的更新(Update)方法。
  2. Observer(观察者/订阅者):
    • 定义一个更新接口(或方法),供主题在状态改变时调用。
    • 在需要时向主题注册自己。
    • 在不再需要时(如对象销毁时)从主题注销自己,防止内存泄漏。

类比: 就像订阅报纸或YouTube频道。报社/UP主(Subject)发布新内容(状态变化)时,所有订阅者(Observer)都会收到通知(新报纸/视频推送),并可以自行决定如何处理(阅读/观看)。

3.2 Unity中实现观察者模式

在Unity C#中,有多种方式可以实现观察者模式,各有优劣。

3.2.1 手动实现(理解原理)

通过定义接口 ISubjectIObserver 来实现。

using System.Collections.Generic;
using UnityEngine;// 观察者接口
public interface IObserver
{void OnNotify(object subject, string eventType); // 参数可以更具体,例如传递事件数据
}// 主题接口
public interface ISubject
{void AddObserver(IObserver observer);void RemoveObserver(IObserver observer);void NotifyObservers(string eventType);
}// 示例:玩家状态作为主题
public class PlayerStatus : MonoBehaviour, ISubject
{private List<IObserver> _observers = new List<IObserver>();private int _health = 100;public int Health{get => _health;set{if (_health != value){_health = value;NotifyObservers("HealthChanged"); // 血量变化时通知}}}public void AddObserver(IObserver observer){if (!_observers.Contains(observer)){_observers.Add(observer);}}public void RemoveObserver(IObserver observer){if (_observers.Contains(observer)){_observers.Remove(observer);}}public void NotifyObservers(string eventType){// 创建副本以防在通知过程中列表被修改List<IObserver> observersSnapshot = new List<IObserver>(_observers);foreach (var observer in observersSnapshot){observer.OnNotify(this, eventType);}}// 模拟受到伤害public void TakeDamage(int amount){Health -= amount;}
}// 示例:UI血条作为观察者
public class HealthBarUI : MonoBehaviour, IObserver
{public PlayerStatus playerStatus; // 引用主题public UnityEngine.UI.Slider healthSlider; // 引用UI控件void Start(){if (playerStatus != null){playerStatus.AddObserver(this); // 注册自己UpdateHealthBar(playerStatus.Health); // 初始化显示}}void OnDestroy(){if (playerStatus != null){playerStatus.RemoveObserver(this); // 对象销毁时注销,非常重要!}}public void OnNotify(object subject, string eventType){if (subject is PlayerStatus status && eventType == "HealthChanged"){UpdateHealthBar(status.Health);}}private void UpdateHealthBar(int currentHealth){if (healthSlider != null){// 假设血量最大值为100healthSlider.value = (float)currentHealth / 100.0f;Debug.Log($"HealthBar UI updated: {currentHealth}%");}}
}

优点: 完全控制实现细节,最符合经典定义。
缺点: 代码量较多,需要手动管理订阅和取消订阅,容易忘记取消订阅导致内存泄漏。

3.2.2 利用C#事件(推荐,简洁高效)

C#内置的 event 关键字和委托(delegate)是实现观察者模式的更简洁、类型安全的方式。这在第23天我们已经学习过。

using System;
using UnityEngine;
using UnityEngine.UI; // For Slider example// 主题类 (发布者)
public class PlayerNotifier : MonoBehaviour
{// 定义委托类型 (事件的签名)public delegate void HealthChangedHandler(int newHealth);// 定义事件 (基于委托)public event HealthChangedHandler OnHealthChanged;// 定义另一个事件public event Action<int> OnScoreChanged; // 使用泛型Action委托更简洁private int _health = 100;private int _score = 0;public int Health{get => _health;set{if (_health != value){_health = Mathf.Clamp(value, 0, 100);// 触发事件,通知所有订阅者OnHealthChanged?.Invoke(_health); // ?. 安全调用,如果没订阅者则不执行Debug.Log($"Player health changed to: {_health}. Event invoked.");}}}public int Score{get => _score;set{if (_score != value){_score = value;OnScoreChanged?.Invoke(_score);Debug.Log($"Player score changed to: {_score}. Event invoked.");}}}// 模拟操作void Update(){if (Input.GetKeyDown(KeyCode.DownArrow)){Health -= 10;}if (Input.GetKeyDown(KeyCode.UpArrow)){Score += 10;}}
}// 观察者类 (订阅者)
public class UIManager : MonoBehaviour
{public PlayerNotifier playerNotifier; // 引用主题public Slider healthSlider;public Text scoreText;void Start(){if (playerNotifier != null){// 订阅事件 (使用 +=)playerNotifier.OnHealthChanged += UpdateHealthUI;playerNotifier.OnScoreChanged += UpdateScoreUI;// 初始化UIUpdateHealthUI(playerNotifier.Health);UpdateScoreUI(playerNotifier.Score);Debug.Log("UIManager subscribed to PlayerNotifier events.");}else{Debug.LogError("PlayerNotifier reference not set in UIManager.");}}void OnDestroy(){if (playerNotifier != null){// 取消订阅 (使用 -=),非常重要!playerNotifier.OnHealthChanged -= UpdateHealthUI;playerNotifier.OnScoreChanged -= UpdateScoreUI;Debug.Log("UIManager unsubscribed from PlayerNotifier events.");}}// 事件处理方法 (签名需匹配委托)private void UpdateHealthUI(int newHealth){if (healthSlider != null){healthSlider.value = (float)newHealth / 100.0f;Debug.Log($"Health UI updated via event: {newHealth}");}}private void UpdateScoreUI(int newScore){if (scoreText != null){scoreText.text = $"Score: {newScore}";Debug.Log($"Score UI updated via event: {newScore}");}}
}

优点: 语法简洁,类型安全,由.NET运行时管理订阅列表,不易出错。是纯C#代码间解耦的首选。
缺点: 订阅和取消订阅仍需手动编写代码,在Inspector面板中不可见或配置。

3.2.3 利用UnityEvent(Inspector友好)

UnityEvent 是Unity引擎提供的事件系统,优点是可以在Inspector面板中可视化地配置事件的触发和监听,非常适合设计师或非程序员使用,也方便在运行时动态添加/移除监听。这在第24天我们已经学习过。

using UnityEngine;
using UnityEngine.Events; // 引入UnityEvent命名空间
using UnityEngine.UI;// 定义一个可以传递int参数的UnityEvent子类
[System.Serializable]
public class HealthChangedEvent : UnityEvent<int> { }
[System.Serializable]
public class ScoreChangedEvent : UnityEvent<int> { }// 主题类 (发布者)
public class PlayerEvents : MonoBehaviour
{// 在Inspector中可见并可配置的事件public HealthChangedEvent OnHealthChanged = new HealthChangedEvent();public ScoreChangedEvent OnScoreChanged = new ScoreChangedEvent();private int _health = 100;private int _score = 0;public int Health{get => _health;set{if (_health != value){_health = Mathf.Clamp(value, 0, 100);// 触发UnityEventOnHealthChanged.Invoke(_health);Debug.Log($"Player health changed to: {_health}. UnityEvent invoked.");}}}public int Score{get => _score;set{if (_score != value){_score = value;OnScoreChanged.Invoke(_score);Debug.Log($"Player score changed to: {_score}. UnityEvent invoked.");}}}// 模拟操作void Update(){if (Input.GetKeyDown(KeyCode.PageDown)) // 使用不同按键避免冲突{Health -= 10;}if (Input.GetKeyDown(KeyCode.PageUp)){Score += 10;}}
}// 观察者类 (监听者) - 注意:这里的监听方法需要是public
public class GameUIController : MonoBehaviour
{public Slider healthSlider;public Text scoreText;// 这个方法需要是 public 才能在 Inspector 中被 UnityEvent 选中public void UpdateHealthDisplay(int newHealth){if (healthSlider != null){healthSlider.value = (float)newHealth / 100.0f;Debug.Log($"Health display updated via UnityEvent: {newHealth}");}}// 这个方法也需要是 publicpublic void UpdateScoreDisplay(int newScore){if (scoreText != null){scoreText.text = $"Score: {newScore}";Debug.Log($"Score display updated via UnityEvent: {newScore}");}}// 注意:使用UnityEvent时,通常不需要在代码中显式 Start/OnDestroy 中订阅/取消订阅// 监听关系主要在 Inspector 中配置。// 如果需要代码动态添加监听,可以使用:// playerEventsInstance.OnHealthChanged.AddListener(UpdateHealthDisplay);// playerEventsInstance.OnHealthChanged.RemoveListener(UpdateHealthDisplay);
}

配置步骤:

  1. PlayerEvents脚本挂载到一个GameObject上(例如 “Player”)。
  2. GameUIController脚本挂载到另一个GameObject上(例如 “UIManager”),并将对应的Slider和Text组件拖拽到脚本的公共字段上。
  3. 在挂载了PlayerEvents的GameObject的Inspector面板中,找到On Health ChangedOn Score Changed事件。
  4. 点击 + 号添加监听器。
  5. 将挂载了GameUIController的GameObject拖拽到事件监听器列表的 Object 字段上。
  6. 在右侧的下拉菜单中,选择 GameUIController -> UpdateHealthDisplay (int)UpdateScoreDisplay (int)

优点: Inspector可视化配置,方便非程序员协作;运行时动态添加/移除监听相对容易;Unity自动处理了部分生命周期管理(但在某些复杂情况下仍需手动移除监听)。
缺点: 相比C#事件可能有微小的性能开销(通常不显著);对于纯粹的代码逻辑耦合,不如C#事件直接。

3.3 观察者模式 vs C#事件 vs UnityEvent

特性手动实现观察者模式C# 事件 (event)UnityEvent
实现复杂度中等 (语法糖)低 (主要靠Inspector)
类型安全取决于实现强类型强类型 (支持泛型)
性能取决于实现通常最高略有开销 (反射调用等)
Inspector集成 (可视化配置)
代码耦合中等 (接口依赖) (委托/事件依赖)低 (Inspector配置/代码监听)
适用场景学习原理、特殊需求纯C#类间、脚本核心逻辑解耦UI交互、编辑器配置、跨脚本通信
主要缺点易出错、代码量大无法Inspector配置、需手动管理性能略低、过度配置可能混乱

结论:

  • 对于脚本内部或纯C#模块间的解耦,优先考虑使用 C#事件
  • 对于需要美术、策划在编辑器中配置的交互,或者连接UI元素与脚本逻辑,优先考虑使用 UnityEvent
  • 手动实现主要用于理解底层原理或在不支持后两者的特殊场景。

3.4 观察者模式的应用场景

观察者模式非常适合实现事件驱动的系统,当一个状态变化需要通知多个不相关的模块时:

  • UI更新: 玩家血量、得分、弹药、任务状态变化时,通知UI元素更新显示。
  • 成就系统: 监听玩家行为(如击败特定敌人、收集物品、达到某等级),当满足条件时触发成就解锁通知。
  • 声音系统: 监听游戏事件(如玩家跳跃、射击、受到伤害),播放对应的音效。
  • 动画系统: 监听角色状态变化(如开始移动、停止、攻击),触发相应的动画状态切换(虽然Animator本身也是状态机)。
  • 教程系统: 监听玩家的特定操作或游戏进展,触发相应的教程提示。
  • 多人游戏中状态同步: 服务器状态变化(Subject),通知所有客户端(Observers)更新本地状态。

3.5 观察者模式的优缺点与注意事项

3.5.1 优点

  • 解耦: 主题和观察者之间是松耦合的。主题只知道观察者实现了某个接口或签名,不需要知道观察者的具体类。观察者可以独立变化,不影响主题。
  • 广播通信: 支持一对多的通知机制,一个事件可以轻松通知任意数量的订阅者。
  • 扩展性: 可以随时增加新的观察者,无需修改主题的代码。

3.5.2 缺点

  • 通知顺序: 观察者收到通知的顺序通常是不确定的(尤其在使用C#事件和UnityEvent时),如果观察者之间有依赖,可能会产生问题。
  • 性能开销: 如果观察者数量非常多,或者更新逻辑复杂,频繁的通知可能会带来性能问题。
  • 内存泄漏: 最常见的问题! 如果观察者在销毁前没有从主题那里取消订阅(RemoveObserver, -=, RemoveListener),主题会一直持有对观察者的引用,导致观察者对象无法被垃圾回收器回收,造成内存泄漏。
  • 调试复杂性: 事件链可能变得复杂,追踪一个事件的触发源头和所有响应可能比较困难(“更新风暴”)。

3.5.3 注意事项

  • 必须取消订阅: 在观察者对象不再需要监听时(通常是在OnDestroyOnDisable中),一定要记得取消订阅。这是使用观察者模式最需要注意的地方。
  • 避免循环引用: 注意主题与观察者之间可能产生的循环引用问题。
  • 考虑事件数据: 设计通知机制时,考虑是否需要传递额外的数据给观察者。可以使用自定义委托、Action<T>UnityEvent<T> 或自定义事件参数类。
  • 谨慎使用重量级更新: 观察者的更新方法(OnNotify, 事件处理函数)应尽可能轻量,避免执行耗时操作,否则可能阻塞主题或其他观察者。如果需要执行复杂逻辑,可以考虑在更新方法中启动协程或放入任务队列。

四、其他常用设计模式简介

除了单例和观察者,Unity开发中还有一些其他常用的设计模式值得了解:

4.1 工厂模式 (Factory Pattern)

4.1.1 核心思想

定义一个用于创建对象的接口(或基类),让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。

4.1.2 Unity应用场景

  • 创建不同类型的敌人: 定义一个EnemyFactory,根据传入的类型(如"Goblin", “Orc”)或难度级别,创建并返回相应的敌人预制体实例。
  • 创建子弹或特效: 根据武器类型创建不同的子弹对象。
  • 结合对象池: 工厂可以负责从对象池获取对象或创建新对象。

优点: 将对象的创建逻辑集中管理,客户端代码与具体产品类解耦。易于扩展,增加新产品时只需增加新的工厂子类或修改工厂方法。

4.2 状态模式 (State Pattern)

4.2.1 核心思想

允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。

4.2.2 Unity应用场景

  • AI行为管理: 实现复杂的AI状态机(如巡逻、警戒、追击、攻击、逃跑)。每个状态是一个类,负责处理该状态下的行为和状态转换逻辑。这在第32天我们已经讨论过。
  • 玩家角色状态: 管理玩家的不同状态(如站立、行走、跑步、跳跃、游泳、死亡),不同状态下输入响应和行为不同。
  • UI菜单流程: 管理多层级菜单的切换逻辑。

优点: 将与特定状态相关的行为局部化到状态对象中,避免了巨大的条件语句(if/else 或 switch)。状态转换逻辑清晰。易于增加新状态。

4.3 对象池模式 (Object Pooling)

4.3.1 核心思想

预先创建并存储一组对象(“池”),当需要对象时,从池中获取一个,使用完毕后不销毁,而是归还到池中,以便后续复用。

4.3.2 Unity应用场景

  • 子弹、特效、敌人等频繁创建和销毁的对象: 避免频繁的InstantiateDestroy操作带来的性能开销和内存碎片。这在第31天我们已经讨论过。

优点: 显著提升性能,减少GC(垃圾回收)压力。

五、实战演练:构建游戏管理器与成就系统

现在,我们将结合单例模式和观察者模式(使用C#事件)来实现一个简单的示例。

目标:

  1. 创建一个全局唯一的GameManager单例,负责跟踪分数。
  2. 创建一个AchievementManager,监听GameManager的分数变化事件。
  3. 当分数达到特定值时,AchievementManager触发一个“获得高分”成就的通知。

5.1 实现GameManager单例

  1. 创建GameManager.cs脚本:
using System;
using UnityEngine;// 继承自之前定义的泛型 Singleton 基类
public class GameManager : Singleton<GameManager>
{// 使用 C# 事件来实现分数变化的通知 (观察者模式)public event Action<int> OnScoreChanged; // Action<int> 是一个委托,表示接受一个int参数且无返回值的方法private int _score = 0;public int Score{get => _score;private set // 外部只能读取,修改通过方法进行{if (_score != value){_score = value;Debug.Log($"[GameManager] Score updated to: {_score}");// 触发分数变化事件,通知所有订阅者OnScoreChanged?.Invoke(_score);}}}// Awake由基类处理实例保证,这里可以添加GameManager特定的初始化protected override void Awake(){base.Awake(); // 必须调用基类的Awake来确保单例逻辑执行// 确保GameManager跨场景存在 (如果需要)DontDestroyOnLoad(gameObject); // 取消注释则跨场景保留Debug.Log("[GameManager] Instance initialized.");// 初始化分数 (虽然默认为0,显式写一下更清晰)Score = 0;}// 公共方法用于增加分数public void AddScore(int amount){if (amount > 0){Score += amount;}}// 可以在这里添加其他全局管理功能,例如游戏状态控制public enum GameState { MainMenu, Playing, Paused, GameOver }public GameState CurrentState { get; private set; } = GameState.MainMenu;public void StartGame(){if (CurrentState == GameState.MainMenu || CurrentState == GameState.GameOver){CurrentState = GameState.Playing;Score = 0; // 开始新游戏时重置分数Debug.Log("[GameManager] Game Started!");// 可能还需要加载游戏场景等操作}}public void PauseGame(bool isPaused){if (CurrentState == GameState.Playing || CurrentState == GameState.Paused){CurrentState = isPaused ? GameState.Paused : GameState.Playing;Time.timeScale = isPaused ? 0f : 1f; // 控制游戏时间流速实现暂停/恢复Debug.Log($"[GameManager] Game {(isPaused ? "Paused" : "Resumed")}");}}// ... 其他方法如 GameOver(), GoToMainMenu() 等
}
  1. 在场景中使用:
    • 创建一个空的GameObject,命名为 “GameManager”。
    • GameManager.cs 脚本挂载到这个GameObject上。
    • 由于GameManager继承自Singleton<T>,它会自动处理实例的唯一性。

5.2 实现简单的成就系统(观察者模式)

  1. 创建AchievementManager.cs脚本:
using UnityEngine;public class AchievementManager : MonoBehaviour
{public int highScoreThreshold = 100; // 高分成就阈值,可在Inspector设置private bool highScoreAchieved = false;void Start(){// 检查GameManager实例是否存在并订阅事件if (GameManager.Instance != null){GameManager.Instance.OnScoreChanged += HandleScoreChanged;Debug.Log("[AchievementManager] Subscribed to GameManager.OnScoreChanged event.");// 初始化时检查一下当前分数是否已满足条件 (例如加载游戏存档后)HandleScoreChanged(GameManager.Instance.Score);}else{Debug.LogError("[AchievementManager] GameManager instance not found on Start! Cannot subscribe to score changes.");}}void OnDestroy(){// 在对象销毁时务必取消订阅,防止内存泄漏!if (GameManager.Instance != null){GameManager.Instance.OnScoreChanged -= HandleScoreChanged;Debug.Log("[AchievementManager] Unsubscribed from GameManager.OnScoreChanged event.");}}// 事件处理方法,当分数变化时由GameManager调用private void HandleScoreChanged(int newScore){// 检查是否达到高分阈值且尚未获得成就if (!highScoreAchieved && newScore >= highScoreThreshold){UnlockHighScoreAchievement();}}private void UnlockHighScoreAchievement(){highScoreAchieved = true;Debug.LogWarning($"[AchievementManager] Achievement Unlocked: High Score! (Reached {GameManager.Instance.Score} points, threshold was {highScoreThreshold})");// 在这里可以触发UI提示、播放音效、保存成就状态等// 例如:UIManager.Instance.ShowAchievementPopup("High Score!");}// 可选: 添加重置成就状态的方法,用于测试或新游戏public void ResetAchievements(){highScoreAchieved = false;Debug.Log("[AchievementManager] Achievements reset.");}
}
  1. 在场景中使用:
    • 创建一个空的GameObject,命名为 “AchievementManager”。
    • AchievementManager.cs 脚本挂载到这个GameObject上。
    • 可以在Inspector面板中设置 High Score Threshold 的值。

5.3 创建触发事件源(示例)

为了测试,我们可以创建一个简单的脚本来模拟得分。

  1. 创建ScoreAdder.cs脚本:
using UnityEngine;public class ScoreAdder : MonoBehaviour
{public int scoreToAdd = 10;void Update(){// 按下空格键时,通过GameManager单例增加分数if (Input.GetKeyDown(KeyCode.Space)){if (GameManager.Instance != null){GameManager.Instance.AddScore(scoreToAdd);Debug.Log($"[ScoreAdder] Added {scoreToAdd} score via Spacebar.");}else{Debug.LogError("[ScoreAdder] GameManager instance not found! Cannot add score.");}}// 按下 R 键重置成就 (用于测试)if (Input.GetKeyDown(KeyCode.R)){AchievementManager am = FindObjectOfType<AchievementManager>();if (am != null){am.ResetAchievements();if(GameManager.Instance != null){GameManager.Instance.AddScore(0); // 触发一次分数更新以防万一初始分数就超过阈值}}}// 按下 S 键开始游戏 (用于测试GameManager状态)if (Input.GetKeyDown(KeyCode.S)){if (GameManager.Instance != null){GameManager.Instance.StartGame();}}}
}
  1. 在场景中使用:
    • ScoreAdder.cs 脚本挂载到任意一个活动的GameObject上(例如主摄像机)。

测试流程:

  1. 运行游戏。
  2. 查看Console窗口的日志,确认GameManagerAchievementManager已初始化并成功订阅事件。
  3. 按下空格键。Console会显示分数增加,并且GameManager触发了OnScoreChanged事件。
  4. 持续按空格键,直到分数达到或超过你在AchievementManager中设置的highScoreThreshold
  5. 当分数首次达到阈值时,Console会显示AchievementManager解锁成就的日志。
  6. 之后再按空格键增加分数,成就解锁日志不会重复出现(因为highScoreAchieved标志位的作用)。
  7. 可以按 R 键重置成就状态,然后再次尝试触发。
  8. 可以按 S 键调用GameManager.StartGame()测试状态切换和分数重置。

这个简单的实战演练展示了如何结合使用单例模式(GameManager作为全局访问点)和观察者模式(AchievementManager监听GameManager的分数变化事件)来构建一个解耦且可扩展的游戏系统。

六、总结

今天我们深入探讨了Unity开发中两种至关重要的设计模式:单例模式和观察者模式。掌握它们对于构建结构清晰、易于维护和扩展的游戏项目非常有帮助。

以下是本次学习的核心要点:

  1. 设计模式价值: 设计模式是解决常见软件设计问题的成熟方案,能提高代码复用性、可读性和可维护性。在Unity中,它们有助于管理复杂性、解耦组件、集中控制全局状态。
  2. 单例模式 (Singleton):
    • 核心: 保证类只有一个实例,并提供全局访问点。
    • Unity实现: 常用基于MonoBehaviour的泛型单例,利用Awake()保证唯一性,可选DontDestroyOnLoad实现跨场景。
    • 应用: 全局管理器(GameManager, AudioManager, InputManager等)。
    • 注意: 优点是方便访问,缺点是易引入全局状态和紧耦合,需谨慎使用,防止滥用。
  3. 观察者模式 (Observer):
    • 核心: 定义一对多依赖,当主题状态改变时自动通知所有观察者。实现事件驱动系统,解耦发布者与订阅者。
    • Unity实现: 可手动实现(接口),更常用C#事件(event)或Unity内置的UnityEvent。C#事件简洁高效,适用于代码逻辑;UnityEvent可在Inspector配置,适合UI交互和策划配置。
    • 应用: UI更新、成就系统、声音触发、教程提示等。
    • 注意: 优点是解耦、易扩展;缺点是需管理订阅生命周期(必须取消订阅以防内存泄漏),复杂通知链可能影响性能或调试。
  4. 实践应用: 我们通过实战演练,创建了GameManager单例来管理分数,并使用C#事件实现了AchievementManager作为观察者监听分数变化,从而解锁成就。这展示了模式结合使用的威力。
  5. 其他模式简介: 简要介绍了工厂模式(对象创建)、状态模式(行为管理)和对象池模式(性能优化),它们也是Unity开发中常用的重要模式。

设计模式是工具,不是银弹。理解其原理、优缺点和适用场景,并根据项目实际需求灵活选用、组合,才能真正发挥它们的价值。希望通过今天的学习,你能更有信心地在你的Unity项目中运用设计模式,编写出更优雅、更健壮的代码!继续加油!


相关文章:

  • 【Dv3Admin】从零搭建Git项目安装·配置·初始化
  • 数据结构:栈
  • notepad++技巧:查找和替换:扩展 or 正则表达式
  • 《Android系统应用部署暗礁:OAT文件缺失引发的连锁崩溃与防御体系构建》
  • 数据库基础——事务
  • AES-128、AES-192、AES-256 简介
  • 缓存,内存,本地缓存等辨析
  • Spark-Streaming(1)
  • 【Git】Git的远程分支已删除,为何本地还能显示?
  • oracle将表字段逗号分隔的值进行拆分,并替换值
  • ​CTGCache ​CTG-Cache TeleDB
  • 【MySQL数据库】表的约束
  • 工程投标k值分析系统(需求和功能说明)
  • 使用Multipart Form-Data一次请求获取多张图片
  • 真我推出首款 AI 翻译耳机,支持 32 种语言翻译
  • 2.5 函数的拓展
  • LangGraph(二)——QuickStart样例中的第二步
  • C++ std::forward 详解
  • 【源码】【Java并发】【ThreadLocal】适合中学者体质的ThreadLocal源码阅读
  • 在 40 亿整数中捕获“恰好出现两次”的数字
  • 对话地铁读书人|超市营业员朱先生:通勤时间自学心理学
  • 印控克什米尔发生恐袭事件,外交部:中方反对一切形式的恐怖主义
  • 一场12年的马拉松,他用声音陪伴中国路跑成长
  • 中国英国商会政府事务主席陶克瑞:重庆经济成就瞩目,中英合作机遇无限
  • ESG领跑者|每一步都向前,李宁要让可持续发展成为可持续之事
  • 澎湃思想周报|哈佛与特朗普政府之争;学习适应“混乱世”