一、从一个让人头疼的全局管理器说起

做Unity项目,尤其是做游戏,基本躲不开“全局管理器”这个概念。什么叫全局管理器?就是那些好像谁都想摸一下的东西:游戏分数、当前关卡、音乐音效、玩家状态、UI面板。今天你说:“我要在得分的时候播放一段音效”,明天他说:“我要在关卡结束时弹一个结算面板”。如果每个脚本都自己去找对应的对象,场景里的引用会越拖越乱。你今天从场景里拖了一个AudioSource,明天场景一改,拖的引用就空了,游戏直接报空引用。这种经历,很多Unity开发都体会过。

于是有人想到了“单例模式”。单例模式的思想特别直白:整个游戏里只有一个管理者,然后大家用同一个入口去拿它。就好比整个小区只有一个物业办公室,谁有需求都去那儿办。在Unity里,最经典的实现,就是把GameManager做成一个立刻能拿到静态属性。

1.1 经典单例的写法

先用最常规的方式写一个单例管理器。注意,这里不刻意追求线程安全,因为Unity本身是单线程逻辑,写得太复杂反而不好看懂。

// 技术栈:Unity + C#
public class GameManager : MonoBehaviour
{
    // 用来保存唯一的实例
    private static GameManager _instance;

    // 公开属性,其他脚本都通过这个入口访问
    public static GameManager Instance
    {
        get { return _instance; }
    }

    private void Awake()
    {
        // 如果已经有实例了,而且不是我,说明场景里放了两个,删掉新的
        if (_instance != null && _instance != this)
        {
            Destroy(gameObject);
            return;
        }

        // 记录唯一实例,并在切换场景时保留下来
        _instance = this;
        DontDestroyOnLoad(gameObject);
    }

    // 这里存一点全局数据,比如玩家金币
    public int CoinCount = 0;

    // 外部通过这个方法给金币
    public void AddCoins(int amount)
    {
        CoinCount += amount;
        Debug.Log($"当前金币:{CoinCount}");
    }
}

这段代码想必很多人都写过。用的时候也方便,在任意一个脚本里敲一句 GameManager.Instance.AddCoins(10); 就能给金币。听起来挺好,对不对?但问题也悄悄埋下了。

1.2 单例模式让人皱眉的地方

咱们拿生活中做饭打个比方。单例模式就像家里只有一把菜刀,所有人都去同一个抽屉里拿。菜刀好用,可一旦你想换把刀,或者菜刀坏了,整个厨房都乱了。单例的痛点集中在几个地方。

第一个,依赖被藏在了一个谁都不知道的地方。你看GameManager的代码,你根本看不出Inventory、ScoreUI这些脚本到底有没有用到它,因为它们都直接写GameManager.Instance,也就是全局状态藏得很深。第二个,没法替换实现。比如你想写个测试,不真的播放音效,只记录一下调用情况,但AudioManager是单例,你没法塞一个假的进去。第三个,生命周期难搞。如果某个管理器的初始化特别慢,另一个脚本在Awake里就去访问它,很可能拿到null。最要命的是,多个单例还会互相依赖,谁先Awake完全看运气,今天跑得好好的,明天改个脚本又出问题。第四个,全局状态一多,Bug就特别难查。哪里改了这个管理器,根本找不到源头。

二、单例模式的改良尝试

既然单例写法重复,又到处都有问题,自然有人想:“我能不能把单例的公共部分抽出来,写一个基类?”于是就有了MonoBehaviour单例基类。

2.1 一个通用的单例基类

我们可以做一个抽象类,任何想变成单例的管理器只要继承它就行。这样就不用每次写Instance、Awake那一套模板了。

// 技术栈:Unity + C#
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
{
    private static T _instance;

    public static T Instance
    {
        get
        {
            // 如果还没有实例,就临时创建一个名为“类名”的对象
            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 as T;
            DontDestroyOnLoad(gameObject);
        }
        else if (_instance != this)
        {
            Destroy(gameObject);
        }
    }
}

有了它,AudioManager就可以写得很干净:

// 技术栈:Unity + C#
public class AudioManager : MonoSingleton<AudioManager>
{
    // 播放单首歌曲
    public void PlaySong(string songName)
    {
        Debug.Log("播放音乐:" + songName);
    }
}

这个基类确实省了不少事,但副作用是:所有管理器的单例身份被固化下来了。因为只要有人调用AudioManager.Instance,即使场景里没有这个对象,它也会自己new一个新对象。这意味着它会偷偷创建对象,进一步掩盖依赖关系。更有意思的是,“全局唯一”这个约束本身,其实是你自己定下来的规矩。很多时候,你并不真的需要唯一,你只是需要“大家能拿到同一个东西”。

2.2 改良了半天,核心问题还在

有人会写“接口单例”,比如把AudioManager的访问入口放到接口上,用它来解耦。但静态入口还是只有一个,测试时还得去改静态字段的赋值逻辑。这就像是换了扇门,房子还是那座房子,漏水的地方还是没修。原因很简单:只要依赖还是从“全局”拿,隐藏依赖的毛病就治不好。

三、依赖注入带来的转机

如果你听说过“依赖注入”,可能会被它绕口的概念吓到。其实它解决的就是刚才说的问题。什么是依赖注入?翻译成人话就是:“你需要什么,我从外面给你,而不是你自己到处找。”

没有依赖注入的传统写法是AudioManager.Instance.PlaySong("coin"),有依赖注入的写法是:在创建GameManager的时候,把一个AudioManager对象传给它的构造函数。GameManager根本不知道AudioManager是怎么来的,它只管收下这个“耳朵”。这就是“注入”的意思——不是我伸手拿,是你递给我。

3.1 构造函数注入示例

既然说到了,就举个简单的例子。我们让GameManager不再依赖AudioManager.Instance,而是通过构造函数接收一个AudioManager。

// 技术栈:Unity + C#
public class GameManager
{
    private AudioManager _audio;

    // 构造函数注入:调用方负责把AudioManager传进来
    public GameManager(AudioManager audio)
    {
        _audio = audio;
    }

    public void LevelUp()
    {
        _audio.PlaySong("level_up");
        Debug.Log("升级了!");
    }
}

代码确实更清爽,但问题来了:在Unity里,AudioManager继承MonoBehaviour,没法用new AudioManager()去创建。这不像普通类,随手就能new。所以依赖注入在Unity里不能完全照搬常规C#的写法。我们需要把“管理器”尽量改造成普通C#类,然后交给一个统一的地方去管理和创建。这个地方,就叫“依赖注入容器”。

3.2 自己写一个迷你容器

容器是什么?你可以把它理解成一通信录。你想找哪个人帮忙,不用自己满世界打听,打开通信录一查就行。但这个通信录更聪明,它还能在你需要的时候把人叫过来,甚至告诉那个人该做什么。我们先写一个极简版本,只具备“注册”和“解析”两个能力。

// 技术栈:Unity + C#
using System;
using System.Collections.Generic;

public class SimpleContainer
{
    // 用字典存放接口和实例之间的关系
    private Dictionary<Type, object> _map = new Dictionary<Type, object>();

    // 注册:把实现和接口绑定
    public void Register<TInterface>(TInterface instance)
    {
        _map[typeof(TInterface)] = instance;
    }

    // 解析:通过接口获取实现
    public TInterface Resolve<TInterface>()
    {
        if (_map.TryGetValue(typeof(TInterface), out object obj))
        {
            return (TInterface)obj;
        }
        throw new Exception($"没有找到 {typeof(TInterface).Name} 的注册");
    }
}

这个容器在Unity里怎么用?我们把它放在一个引导脚本中,在游戏启动时把所有全局管理器都注册进去。注意,这里我们选择让AudioManager不再是MonoBehaviour,而是普通C#类。这样它才能被随意new。

3.3 依赖注入的几种常见形态

依赖注入不只有构造函数注入这一种。常见的有三种:构造函数注入、属性注入和方法参数注入。构造函数注入是最推荐的方式,因为一个类缺少什么,编译器就在构造函数里如实告诉你。属性注入适合那些“可有可无”的依赖,比如你可以给它设置一个默认值,方便在某些特殊场景下跳过。方法参数注入则适合依赖用完就走的情况,比如一个方法需要一个临时工具对象。这三种方式,在Unity里都能用,但构造函数注入最容易测试,代码也最老实。

四、用依赖注入重构全局管理器

下面来一个完整的重构示例。为了让你看得清楚,分三步走:定义接口、改造GameManager、引导容器。

4.1 定义接口和实现

我们给AudioManager定义一个接口,让GameManager只面向接口编程,不依赖具体类。

// 技术栈:Unity + C#
public interface IAudioManager
{
    void PlaySong(string songName);
}

// 实现类,注意继承的是接口,不是MonoBehaviour
public class AudioManager : IAudioManager
{
    public void PlaySong(string songName)
    {
        Debug.Log("播放音乐:" + songName);
    }
}

为啥要加一个接口?因为依赖注入最大的礼物就是“替换”。今天你想用Unity自带的AudioSource播,明天你不用Unity了,想换成一个自研的音频引擎,只要实现同一个接口,GameManager完全不用改。

4.2 改造GameManager

GameManager只依赖接口,同时通过构造函数注入。

// 技术栈:Unity + C#
public class GameManager
{
    private IAudioManager _audio;
    private int _coins;

    // 依赖被清清楚楚地写在这
    public GameManager(IAudioManager audio)
    {
        _audio = audio;
    }

    public void EarnCoins(int count)
    {
        _coins += count;
        _audio.PlaySong("coin");
        Debug.Log($"当前金币:{_coins}");
    }
}

看,现在的GameManager不再有“Instance”,它只是一个普普通通的类。任何人拿到它的构造函数,都知道它需要什么。这个类不再碰任何全局状态。

4.3 测试时替换实现

依赖注入带来的直接好处是可以轻松造假。比如在编辑器测试中,我们不想真的播放音乐,只想确认某个方法有没有被调用。这时候可以写一个FakeAudioManager:

// 技术栈:Unity + C#
public class FakeAudioManager : IAudioManager
{
    public void PlaySong(string songName)
    {
        Debug.Log("[Fake] 假装播放:" + songName);
    }
}

测试代码里直接 new FakeAudioManager(),然后塞给GameManager。这就是以前单例模式下很难做到的操作。

4.4 启动时组装一切

我们需要一个入口来组装容器,并把依赖注入到每个管理器里。建议在游戏启动场景里挂一个GameBootstrap脚本。

// 技术栈:Unity + C#
using UnityEngine;

public class GameBootstrap : MonoBehaviour
{
    // 让其他脚本能拿到容器。这里仍然有静态字段,但它只是“工具”,不是业务单例
    public static SimpleContainer Container;

    private void Awake()
    {
        // 创建容器
        Container = new SimpleContainer();

        // 注册AudioManager
        IAudioManager audio = new AudioManager();
        Container.Register<IAudioManager>(audio);

        // 创建GameManager,并把audio传进去
        GameManager gameManager = new GameManager(audio);

        // 再把GameManager也注册进去,方便其他脚本通过容器解析
        Container.Register<GameManager>(gameManager);
    }
}

这样一来,剩下的脚本就可以从容器中取“GameManager”,而不是从静态属性里拿唯一实例。比如PlayerController可以这么做:

// 技术栈:Unity + C#
public class PlayerController : MonoBehaviour
{
    private GameManager _gameManager;

    private void Start()
    {
        // 从容器中获得依赖,所有依赖都在启动时有了
        _gameManager = GameBootstrap.Container.Resolve<GameManager>();
    }

    public void OnCoinCollected()
    {
        _gameManager.EarnCoins(1);
    }
}

你可能会说:“这不还是有一个全局的Container吗?”对,这个批评很中肯。但请注意两点:第一,全局入口从无数个管理器缩减到只有一个容器;第二,容器负责的对象生命周期和依赖关系,我们可以集中管理。如果说单例像满大街的小广告,那容器就是唯一的信息中心,已经收敛很多了。

4.5 再进一步:用成熟的DI框架

如果项目继续变大,自己写的容器会逐渐不够用,因为它不能自动解析构造函数参数。这时可以考虑Zenject(现在叫Extenject)。在Zenject中,你只需要在安装器里做一次绑定,其他脚本里的字段就会被自动注入。比如:

// 技术栈:Unity + C#(Zenject / Extenject)
using Zenject;

public class UIInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<IAudioManager>().To<AudioManager>().AsSingle();
        Container.Bind<GameManager>().ToSelf().AsSingle();
    }
}

然后在PlayerController的字段上加一个[Inject]特性,或者直接在构造函数里等注入。不过这又会引入另一套系统,所以不是所有项目都值得用。我们了解原理之后,自然能判断什么时候用,什么时候不用。

4.6 控制反转到底反转了什么

依赖注入背后站着一个更大的概念,叫“控制反转”。以前,自己控制了所有事情:你要创建AudioManager,你就new;你要获取GameManager,你就调静态属性。现在,你把这个控制权交出去,容器来决定什么时候创建、什么时候注入。这就是“控制反转”的本质。控制反转不是一种具体技术,而是一种思想。依赖注入是它最常见的实现方式。很多Unity开发听到“控制反转”就头大,其实你就想一句话:以前自己找饭吃,现在有人喂到嘴边。

五、应用场景、优缺点与注意事项

5.1 应用场景

先说单例模式。它依然有生存空间:如果你在写一个产品原型、一个小型音游、或者一个只在一两个场景里跑的小玩具,单例是最高效的选择。它不需要额外代码,能让你把注意力集中在核心玩法上。另一个常见场景是“游戏模式唯一且不会变化”,比如一个单人对战游戏,GameManager永远只有一个,单例完全OK。此时强行上DI,反而像是在给送牛奶的小三轮安装航空发动机。

依赖注入则适合中大型项目,尤其是团队协作项目。当多个系统互相交织、互相调用时,显式的依赖关系能避免互相猜忌。同时,如果你打算为重要逻辑写单元测试,那DI几乎是必须的。面对“要换一个音频库”这种需求,只要实现了接口,替换成本很低。再比如说,你想让同一个GameManager在Android和iOS上使用不同实现,依赖注入就是最好的答案。

5.2 技术优缺点对比

单例模式的优点可以总结为“简单直接”。不用引入任何框架,写完就用。尤其配合MonoBehaviour的 DontDestroyOnLoad,可以很方便地在场景切换之间保持数据。缺点也很突出:全局状态污染、依赖隐藏、生命周期不确定。这些缺点会随着项目规模增大而指数级放大。

依赖注入的优点,是让依赖关系变得透明。一个类缺什么,一眼就能看到。同时,替换实现、写单元测试,都会顺畅很多。但它也不是没有代价:首先需要额外的容器或框架来支撑,学习成本不低;其次,如果过度设计,你会发现代码里全是接口、容器、绑定,真正干活的逻辑反而被淹没;最后,在Unity场景中处理MonoBehaviour注入并不完美,需要不少粘合代码。

5.3 注意事项

如果你决定从单例切换成DI,有几个坑要特别小心。第一,容器里没有注册就解析,会直接抛异常。一定要确保所有依赖都在启动阶段被注册好。第二,容器中的对象默认不会自动销毁,切换场景后需要你自己决定哪些对象要保留,哪些要清理。第三,不要把所有东西都塞进容器。有些东西本来就应该活在场景里,比如当前摄像机、玩家角色,这些东西用场景引用反而更直观。第四,注意Zenject的版本差异,不同版本的绑定语法有变化,查文档时要留意当前项目用的版本。最后,团队里最好有一个人先把架构讲清楚,否则其他人看了半天还是不知道依赖从哪来。

5.4 技术演进的关键节点

回到咱们的主题。从单例到DI,最关键的一步不是“把静态属性去掉”,而是改变思考方式:不再问“全局谁有这个东西”,而问“谁需要这个东西”。当每个类都只关心自己需要什么,而不是去哪里拿,整个项目的耦合度就会降下来。这也是为什么我们说“演进”而不是“革命”,因为代码组织的思路变了,只是表面上的结构变化看起来不大。

六、文章总结

我们平时写的GameManager.Instance,开发起来确实很快,但它让代码像一锅浆糊,谁动了哪里都说不清楚。后来尝试用单例基类去除重复,发现只是给浆糊换了个碗。直到依赖注入出现,我们才学会把“需要的东西”从外部递进来,让每个类都变成一个健康的小模块。现在再问我,单例模式和依赖注入哪种好?我的回答是:看情况。小项目用单例,大项目用依赖注入,关键在于你愿不愿意为后续的维护付出成本。但有一点是确定的——一个好的代码组织方式,应该让每个类都清楚地知道自己在依赖什么,而不是所有人挤在一个巨大的全局抽屉里瞎翻。希望这篇文章能帮你找到属于自己的平衡点。