How to Develop a Game
Loose Coupling
PublishDate: 2025-06-01 | CreateDate: 2025-06-01 | LastModify: 2025-06-01 | Creator:ljf12825

松耦合(Loose Coupling)是构建可维护、可扩展的关键原则之一,尤其在多人协作、项目复杂度高、后期需要频繁迭代更新的场景中尤为重要

定义\

常见术语\

优势\

优势说明
可维护性高修改一个模块时,不会影响其他模块
可测试性强可以独立单元测试
可扩展性好可以方便地添加、替换模块
降低耦合风险避免“牵一发而动全身”

Unity中常见的松耦合方式

  1. 事件/委托机制

让监听者注册感兴趣的事件,而不是让对象彼此直接调用

优点:发送者不知道接收者是谁,实现解耦

Event and Callback

  1. 接口与抽象 编程时面向接口,而不是具体类 Interface Oriented Design

  2. ScriptableObject作为配置 & 消息中介

public class GameEvent : ScriptableObject
{
    private event Action listeners;

    public void Raise() => listeners.Invoke();

    public void Register(Action callback) => listeners += callback;

    public void Unregister(Action callback) => listeners -= callback;
}

中介层完全解耦了调用者与接收者,并且支持在Inspector中配置

  1. 事件聚合器(EventBus/EventAggregator) 建立一个中心事件系统用于发布和订阅
public static class EventBus
{
    private static Dictionary<Type, Delegate> events = new();

    public static void Subscribe<T>(Action<T> callback)
    {
        if (events.TryGetValue(typeof(T), out var d))
            events[typeof(T)] = Delegate.Combine(d, callback);
        else events[typeof(T)] = callback;
    }

    public static void Publish<T>(T data)
    {
        if(events.TryGetValue(typeof(T), out var d))
            (d as Action<T>)?.Invoke(data);
    }
}

各模块不直接通信,而是通过EventBus中转

  1. Service Locator / 依赖注入(DI) 控制依赖获取的方式,不直接new出对象
public static class ServiceLocator
{
    private static Dictionary<Type, object> services = new();

    public static void Register<T>(T service) => services[typeof(T)] = service;
    public static T Get<T>() => (T)services[typeof(T)];
}

通过容器获取依赖,模块间没有强依赖

设计建议

松耦合的使用场景

注意事项

避免事件泛滥的建议\

避免 ServiceLocator 滥用\

组合模式

场景示例

通过一个“玩家捡金币”的例子来对比“高度耦合”和“解耦后的设计”

场景描述
当玩家(Player)碰到金币(Coin)时:

  1. 金币消失
  2. 播放金币音效
  3. 更新UI显示当前金币数

高度耦合写法

public class Coin : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            // 增加金币
            other.GetComponent<Player>().AddGold(1);

            // 更新UI(耦合 UIManager)
            FindObjectOfType<UIManager>().UpdateGoldUI();

            // 播放音效(耦合 AudioManager)
            FindObjectOfType<AudioManager>().Play("CoinSound");

            // 销毁自己
            Destroy(gameObject);
        }
    }
}

问题(紧耦合):

解耦后的设计
使用

Player接收金币逻辑

public interface IGoldReceiver => void AddGold(int amount);
public class Player : MonoBehaviour, IGoldReceiver
{
    private int gold = 0;

    public void AddGold(int amount)
    {
        gold += amount;
        EventBus.Publish(new GoldCollectedEvent(gold));
    }
}

Coin逻辑解耦

public class Coin : MonoBehaviour
{
    private void OnTriggerEnter(Collider other)
    {
        var receiver = other.GetComponent<IGoldReceiver>();
        if (receiver != null)
        {
            receiver.AddGold(1);
            Destroy(gameObject)l
        }
    }
}

监听事件更新UI

public class UIManager : MonoBehaviour
{
    private void OnEnable() => EventBus.Subscribe<GoldCollectedEvent>(OnGoldUpdated);

    private void OnDisable() => EventBus.UnSubscribe<GoldCollectedEvent>(OnGoldUpdated);

    void OnGoldUpdated(GoldCollectedEvent evt) => goldText.text = $"Gold: {evt.CurrentGold}";
}

播放音效模块

public class CoinSoundPlayer : MonoBehaviour
{
    private void OnEnable() => EventBus.Subscribe<GoldCollectedEvent>(_ => PlayCoinSound());

    void PlayCoinSound() => GetComponent<AudioSource>().Play();
}

EventBus实现

public static class EventBus
{
    private static readonly Dictionary<Type, Delegate> eventTable = new();

    public static void Subscribe<T>(Action<T> listener)
    {
        if (eventTable.TryGetValue(typeof(T), out var existingDelegate))
        {
            eventTable[typeof(T)] = Delegate.Combine(existingDelegate, listener);
        }
        else
        {
            eventTable[typeof(T)] = listener;
        }
    }
}

public static void Unsubscribe<T>(Action<T> listener)
{
    if (eventTable.TryGetValue(typeof(T), out var existingDelegate))
    {
        var newDelegate = Delegate.Remove(existingDelegate, listener);

        if (newDelegate == null) eventTable.Remove(typeof(T));
        else eventTable[typeof(T)] = newDelegate;
    }
}

public static void Publish<T>(T eventData)
{
    if (eventTable.TryGetValue(typeof(T), out var d))
        (d as Action<T>)?.Invoke(eventData);
}

public static void ClearAll() => eventTable.Clear();

耦合陷阱(反例)

一、God Object

所有逻辑集中于单一类,模块之间强依赖

所谓God Object是一个职责过多、控制过多模块的类,它知道系统中几乎所有其他对象的细节,承担太多功能、拥有过多依赖
它像“上帝”一样:

典型表现

Gob Object存在的问题

举个例子:Unity中常见的God Object

public class GameManager : MonoBehaviour
{
    public Player player;
    public EnemyManager enemyManager;
    public UIManager uiManager;
    public AudioSource audio;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.P)) PauseGame();
        if (player.health <= 0) GameOver();

        uiManager.UpdateHealthBar(player.health);
        enemyManager.SpawnIfNeeded();
        audio.Play();
        SaveData();
        LoadScene();
        // ...管理一切
    }

    void PauseGame() { ... }
    void GameOver() { ... }
    void SaveData() { ... }
    void LoadScene() { ... }
}

如何避免或重构God Object

示例:拆分成多个专责管理器

public class GameManager : MonoBehaviour
{
    private IGameState state;
    private IEventSystem events;
    private IAudioService audio;
    private IUIServicce ui;

    void Start()
    {
        state = new GameplayState();
        events.Subscribe<PlayerDeadEvent>(OnPlayerDead);
    }

    void OnPlayerDead(PlayerDeadEvent evt)
    {
        state = new GameOverState();
        ui.ShowGameOver();
    }
}

God Object是一种看似方便,实则代价高昂的设计
虽然在项目初期它可能加快开发,但随着功能增长,它将变成维护地狱。好的架构应当遵循SRP、低耦合、高内聚原则,鼓励模块间解耦、职责明确


二、静态工具类滥用

静态工具类是指包含一系列静态方法、没有实例化的类。它们通常提供全局访问,且不依赖对象的状态。

这种工具类通常用于提供一些全局的、独立于对象的功能,比如数学运算、字符串处理、文件操作等

静态工具类滥用指的是将本应该封装在类内部的逻辑功能都放入静态类中,导致多个模块过度依赖静态类的实现细节,进而增加了系统的耦合度和维护难度

静态工具类滥用的典型表现

存在的问题

示例:静态工具类滥用

public static class GameUtils
{
    public static void SaveGameData(GameData data)
    {
        // save data
    }

    public static void LoadGameData(out GameData data)
    {
        // load data
        data = new GameData();
    }

    public static void PlaySound(string soundName)
    {
        // play sfx
    }

    public static void ShowMessage(string message)
    {
        // show
    }
}

GameUtils 静态类包含了与游戏数据、音效和消息展示相关的多个功能。任何地方只需要调用 GameUtils.SaveGameData()GameUtils.PlaySound() 就能执行这些操作,但是这种方式导致了以下问题:

避免方式

避免将所有功能集中在一个静态类中,应该根据职责将功能分离到不同类中

public class GameDataManager
{
    public void Save(GameData data)
    {
        // 保存游戏数据
    }

    public GameData Load()
    {
        // 加载游戏数据
        return new GameData();
    }
}

public class AudioManager
{
    public void PlaySound(string soundName)
    {
        // 播放音效
    }
}

public class MessageManager
{
    public void ShowMessage(string message)
    {
        // 显示消息
    }
}

通过DI,将类之间的依赖关系明确化,避免通过静态方法共享状态

public class GameController
{
    private readonly GameDataManager dataManager;
    private readonly AudioManager audioManager;
    private readonly MessageManager messageManager;

    public GameController(GameDataManager dataManager, AudioManager audioManager, MessageManager messageManager)
    {
        this.dataManager = dataManager;
        this.audioManager = audioManager;
        this.messageManager = messageManager;
    }

    public void SaveGame(GameData data)
    {
        dataManager.Save(data);
    }

    public void PlayGameOverSound()
    {
        audioManager.PlaySound("GameOver");
    }

    public void ShowGameOverMessage()
    {
        messageManager.ShowMessage("Game Over");
    }
}

如果某些功能确实需要全局唯一的实例来管理(如音效、UI管理),可以使用单例模式,但要避免滥用

public class AudioManager
{
    private static AudioManager instance;

    private AudioManager() { }

    public static AudioManager Instance => instance ??= new AudioManager();

    public void PlaySound(string soundName)
    {
        // 播放音效
    }
}

避免在工具类中维护全局状态。如果工具类必须有状态,应考虑将行为分离到独立类中


三、Find滥用

在Unity项目中过度使用GameObject.FindGameObject.FindWithTag等函数进行对象查找,虽然这些方法在开发初期方便使用,但过度使用Find会导致性能问题、模块间耦合和维护困难,特别是在大型项目和动态场景中

GameObject palyer = GameObject.Find("Player");

这种做法意味着不需要直接引用对象,可以在任何地方调用Find来查找目标对象。然而,这种方式带来了一些潜在的问题,特别是当使用不当时

Find滥用导致的问题

  1. GameObject.FindFindWithTag会在每次调用时遍历场景中的所有对象,特别是在大型场景中,每次调用都会造成性能开销。这种操作是O(n),意味着当场景中的对象数量增加时,查找时间也会增加
    例如,如果在Update方法中每帧都调用Find,系统的帧率就会下降,尤其是在包含大量游戏对象时
void Update()
{
    GameObject player = GameObject.Find("Player"); // 每帧调用Find
    if (player != null)
    {
        // do something
    }
}

这种做法显然会带来性能瓶颈,尤其在大型场景或性能较差的设备上更为明显 2. 缺乏明确的依赖关系 GameObject.Find查找对象的方式是通过名字或标签进行的,这使得代码中的依赖关系变得不清晰。调用Find时,代码没有明确显示出依赖的对象或组件,这降低了代码的可读性和可维护性
例如,通过GameObejct.Find("Player")查找玩家对象时,调用者并不明确知道player是如何创建和管理的。这种隐式依赖使得模块之间的关系不明确,增加了后续修改时的难度 3. 难以管理动态场景中的对象 在动态场景中,游戏对象可能会频繁地被创建或销毁。使用Find查找的对象通常是基于名字或标签来定位的,这会带来问题 - 如果在对象销毁后调用Find,可能会导致空引用错误 - 如果场景中的对象在运行时被创建或销毁,Find的返回结果可能会出现不一致,导致错误 4. 难以测试和模拟 由于Find使得模块之间没有显式的依赖,导致在单元测试时很难模拟或替换被查找的对象。测试需要创建环境、准备测试数据,而不能轻松地注入mock或替代对象 5. 难以进行重构 Find使用字符串或标签进行查找,当场景发生变化时,所有依赖这些名字或标签的代码都会受到影响。这使得重构或修改场景时,系统中的多个地方可能需要修改,增加了维护的难度

如何避免Find滥用

  1. 使用直接引用 避免通过Find查找对象,而是通过将对象引用赋给变量。这样可以减少查找时的性能开销,并且使依赖关系更加明确
public class GameController : MonoBehaviour
{
    public GameObject player;

    void Update()
    {
        if (player != null)
        {
            // do something
        }
    }
}

通过这种方式,GameController直接引用player对象,避免了每帧查找的性能问题,并且player的依赖关系更加清晰 2. 使用单例模式(如果适用) 对于全局唯一的对象(例如音频管理器、UI管理器),可以使用单例模式。这些对象只需在项目中实例化一次,可以避免每次使用时都调用Find

public class Player : MonoBehaviour
{
    private static Player instance;

    public static Player Instance => instance ??= FindObjectOfType<Player>();

    void Awake()
    {
        if (instance != null && instance != this) Destroy(gameObject);
        else instance = this;
    }
}

通过单例模式,其他类可以通过Player.Instance获取Player对象,而不是通过Find来查找 3. 使用事件和消息机制 在一些情况下,使用事件和消息机制可以替代Find的需求。模块之间可以通过发布-订阅模式进行通信,而不需要直接引用对方的对象
例如,可以使用ScriptableObject作为事件总线,发布和订阅消息

public class ScoreManager : MonoBehaviour
{
    public EventBus eventBus;

    void OnEnable() => eventBus.Subscribe<ScoreUpdateEvent>(OnScoreUpdated);

    void OnDisable() => eventBus.UnSubscribe<ScoreUpdateEvent>(OnScoreUpdated);

    void OnScoreUpdated(ScoreUpdatedEvnet e) => Debug.Log("New Score: " + e.score);
}

这样,ScoreManager不需要直接查找其他对象,而是通过事件来获取更新 4. 缓存查找结果 如果必须使用Find,避免频繁查找,可以在对象首次查找时将结果缓存,这样后续就不需要每次都进行查找

public class GameController : MonoBehaviour
{
    private GameObject player;

    void Start() => player = GameObject.Find("Player");

    void Update()
    {
        if (player != null)
        {
            // do something
        }
    }
}

通过这种方式,只在StartAwake中调用Find,避免每帧都调用 5. 对象池(Object Pool) 如果需要频繁创建和销毁对象(例如敌人或子弹),考虑使用对象池来管理这些对象。通过对象池管理,可以避免频繁使用Find查找对象


四、单例滥用

在开发过程中,过度或不当使用单例模式,导致系统中的多个模块或对象都依赖于全局唯一的实例,进而导致系统高度耦合、不可测试性和维护困难等问题。单例模式本身是一种常用的设计模式,但如果使用不当,它会成为一个反模式

单例滥用出现的问题

  1. 系统耦合度过高 单例模式同全局共享实例使得多个类之间的依赖关系变得隐式。当多个模块都依赖同一个单例时,这些模块之间的关系并不清晰,很难理解、调试和维护。例如,假如每个模块都访问GameMnanger,但它们并没有显式声明对GameManager的依赖,这使得代码变得更加难以理解
    示例:多个系统共享一个单例,难以看出它们时如何协作的
public class AudioManager : MonoBehaviour
{
    void PlaySound(string soundName) => GameManager.Instance.PlaySound(soundName); // 隐式依赖,难以追踪
}

public class UIManager : MonoBehaviour
{
    void UpdateUI() => GameManager.Instance.UpdateUI(); // 隐式依赖
}
  1. 违反单一职责原则(SRP) 单例类往往承担着过多的职责。通常,为了满足各种需求,单例类被设计得非常庞大,涵盖多个功能,从而导致类得职责不单一
    示例:GameManager既负责游戏逻辑,又负责管理音频、UI和配置,这违反了单一职责原则
public class GameManager : MonoBehaviour
{
    public void StartGame() { /* 启动游戏逻辑 */ }
    public void PlaySound(string soundName) { /* 播放音效 */ }
    public void UpdateUI() { /* 更新UI */ }
    public void LoadConfiguration() { /* 加载配置 */ }
}

这样得设计使得类变得庞大且难以维护,添加新功能时,往往需要修改单例类,增加了修改的复杂性
3. 减低可测试性 由于单例模式会在整个应用中共享一个实例,它使得依赖于单例的类变得紧耦合且难以进行单元测试。测试时很难替换单例实例,从而导致测试变得复杂和不灵活
假如,假设需要测试UIManager,它直接依赖GameManger的单例,但无法在测试中轻松地替换GameManger,或者模拟它的行为 4. 隐藏依赖关系 单例模式使得类之间的依赖关系变得隐式。其他类直接通过单例访问共享资源,没有显式声明对它的依赖,这导致了系统中的隐式依赖链,这些依赖关系难以追踪和修改
例如,PlayerGameManager之间的依赖关系可能看不出来,但它们都依赖于GameManager单例。修改单例时可能影响到多个模块

  1. 全局状态污染 单例类通常维护全局状态,且这个状态对所有模块共享。当多个模块都修改单例的状态时,可能导致全局状态的不一致,在多线程或多场景的情况下尤为显著。全局状态带来了不可预期的副作用,尤其在大型项目中,管理全局状态变得非常复杂
    例如,如果GameManager维护了玩家的分数和进度,多个场景的模块都可能修改这些数据,导致数据冲突或覆盖

如何避免单例滥用

  1. 仅在需要时使用单例模式 单例模式应该仅用于全局唯一且需要共享的资源。例如,音频管理器、配置管理器、日志记录器等时典型的单例应用场景。如果一个类有多个实例或者能通过依赖注入获取到合适的对象,就不应该使用单例模式

  2. 避免单例类承担过多的责任 单例类应该有明确且单一的职责。如果一个单例类涉及过多领域,考虑将其拆分成多个单例,或将其拆分为多个类,通过依赖注入来管理它们之间的关系
    例如,GameManager不应该同时负责音频、UI和游戏逻辑。应该考虑将音频管理和UI更新拆分为不同的管理类,避免单一类承担过多责任

  3. 使用依赖注入(DI) 如果模块需要共享某些资源,可以考虑使用依赖注入(DI)来显式声明类的依赖关系,而不是使用单例。通过DI,可以显式地管理依赖,避免隐式共享实例,从而减少系统的耦合度

例如,使用Zenject或其他DI框架,确保类的依赖通过构造函数传入,而不是直接从实例中获取

public class GameController
{
    private readonly IAudioManager audioManager;

    public GameController(IAudioManager audioManager)
    {
        this.audioManager = audioManager;
    }
}
  1. 使用事件和消息机制 当多个模块需要共享状态时,考虑使用事件和消息机制来进行解耦。通过事件总线(如ScriptableObjectEventBus),模块可以发布和订阅消息,而不需要通过单例直接访问全局
  2. 为单例提供生命周期管理 当使用单例时,确保管理其生命周期。如果一个单例涉及较为复杂的操作或状态时,考虑使用延迟初始化或生命周期控制,避免单例在不需要时长时间占用资源

五、事件滥用

事件滥用是指在系统中过度或不恰当地使用事件机制,导致代码变得复杂、难以调试和维护。事件机制本身是一个强大的解耦工具,可以让对象之间进行异步通信,而无需直接引用对方。然而,如果过度使用或不合理涉及事件系统,会带来一些严重的反模式问题

会导致以下问题:

  1. 过度解耦导致逻辑分散 事件的一个重要优点是解耦,它能减少模块之间的直接依赖。但如果系统中使用过多的事件来处理逻辑,逻辑会变得分散,模块之间的执行流程不再直观。过多的事件使代码的控制流变得不明确,导致系统的行为变得难以追踪和调试

示例:系统的多个地方都通过事件通知其他模块,导致事件触发的流程复杂且难以调试。事件从哪里发布、在哪里订阅、如何触发,变得非常难以理解

public class Player : MonoBehaviour
{
    public static event Action OnPlayerDied;

    void Die() => OnPlayerDied?.Invoke();
}

多个模块订阅OnPlayerDied,但没有明确知道死因或响应的具体流程
2. 事件泛滥导致维护困难 事件滥用的一个常见问题是系统中有大量的事件。每个事件都可能由多个订阅者监听,而每个订阅者可能对事件做出不同的响应。随着项目的发展,事件的数量和复杂性也会增加,难以管理和维护

示例:项目中有大量的事件来处理各种细节,比如UI更新、敌人死亡、玩家得分、音效播放等。事件增多后,系统难以追踪哪些模块订阅了哪些事件,以及它们的执行顺序

public class UIManager : MonoBehaviour
{
    void OnEnable()
    {
        EventManager.Subscribe<PlayerDiedEvent>(OnPlayerDied);
        EventManager.Subscribe<ScoreUpdatedEvent>(OnScoreUpdated);
        // 事件过多,管理变得复杂
    }

    void OnDisable()
    {
        EventManager.Unsubscribe<PlayerDiedEvent>(OnPlayerDied);
        EventManager.Unsubscribe<ScoreUpdatedEvent>(OnScoreUpdated);
    }
}
  1. 性能问题 当系统中有大量事件发生时,尤其是事件需要频繁触发时,性能会受影响。每次事件触发时,都需要遍历所有的订阅者并调用它们的处理函数,这可能导致额外的开销。如果事件过于频繁地触发,特别是在帧更新中,会导致显著的性能下降 示例:每帧触发的事件会让系统的响应时间增加,导致游戏卡顿或帧率下降
public class Enemy : MonoBehaviour
{
    void Update()
    {
        // 每帧都触发,可能导致性能问题
        if (isDead)
            OnEnemyDeath?.Invoke();
    }
}
  1. 难以追踪和调试 事件是异步的,并且通常是解耦的,这使得它们很难追踪。在出现问题时,事件的触发和响应往往分布在不同的模块中,导致调试时难以找到问题的根源

示例:当一个游戏中的角色死亡时,多个事件订阅者可能会执行各自的任务,如播放音效、更新UI、保存状态等。出问题时,无法清晰地看到这些操作的执行顺序和相互关系

public class AudioManageer : MonoBehaviour
{
    public void PlaySound(string soundName)
    {
        // 播放音效
    }
}

public class UIManager : MonoBehaviour
{
    public void UpdateScore(int score)
    {
        // 更新UI
    }
}

当玩家死亡时,多个事件会被触发,而调试时很难看到这些事件之间的相互关系 5. 订阅和取消订阅的复杂性 在事件系统中,订阅和取消订阅需要手动管理。订阅者忘记取消订阅或重复订阅相同事件会导致内存泄露或逻辑错误。尤其是在复杂的项目中,忘记取消订阅的情况可能会导致订阅者接收到不再需要的事件,进而产生不必要的副作用

如何避免

  1. 限制事件的数量 应根据项目的需求合理设计事件。避免事件泛滥,应避免为了每个小变化或每个模块之间的微小交互都引入事件。将事件仅限于哪些真正需要解耦的场景或行为,比如跨系统的消息传递、系统状态变化等
    例如只为游戏的核心事件(如玩家死亡、场景加载等)创建事件,而不是每次UI元素更新、物体状态改变等都发布事件
  2. 事件命名清晰,职责明确 确保每个事件的命名和用途都非常清晰。在定义事件时,保持事件的语义明确,并确保它们仅用于特定的场景或业务逻辑。避免使用模糊不清的事件名称,使得订阅者能够清楚知道何时该响应
    例如OnPlayerDeathOnStateChanged更加明确,后者可能意味着多种状态的改变,而前者明确指明了时玩家死亡事件
  3. 避免频繁触发事件 避免在每帧或过于频繁的情况下触发事件。尤其是在性能敏感的地方(如Update方法中),事件触发的频率要控制好,避免产生额外的性能负担
    确保只有在真正需要的时候触发事件,而不是每帧都触发
  4. 事件处理应该简洁 事件订阅者的响应函数应该保持简洁,不要在事件响应中执行复杂或耗时的操作。可以通过将复杂的逻辑分离成独立的模块来避免事件处理函数过于臃肿
    将音效播放、UI更新、游戏进度保存等操作分离成独立的模块,而不是在事件响应函数中直接处理所有逻辑
  5. 使用事件总线
  6. 清理不再需要的事件订阅 确保在适当的时候取消事件的订阅。比如在场景切换或对象销毁时,及时取消订阅事件,避免事件响应函数仍然被触发,从而造成内存泄露或逻辑错误

总结

松耦合是一种重要的架构思想,在Unity开发中具有实际意义

通过事件机制、接口抽象、ScriptableObject和事件聚合器等手段,可以构建出更加灵活、可维护、可扩展的系统

解耦不是目的,而是为应对复杂性而采取的策略