从单例到依赖注入

封面图

一次由 TheApp.Instance.GetSystem<T>() 引发的架构思考。

一、缘起:一行让我"不舒服"的代码

这事儿的起因特别简单,就是项目里有这么一段实现:

public StaffHireState GetHireState(StaffItem item)
{
    int level = TheApp.Instance.GetSystem<RestaurantSystem>().Level;
    return item.GetHireState(level);
}

你说它逻辑上有毛病吗?没有,跑起来也一切正常。可我心里就是有股说不清的"不干净"感,一直在。后来总算想明白了:这个方法签名,压根没透露它依赖 RestaurantSystem。你光看字段列表、光看构造函数,永远猜不到它背后还牵着一个全局单例——依赖被悄悄藏进方法体里了。

这种不适感,说到底,就是在跟 Service Locator(服务定位器)模式打交道时,身体给出的最诚实的反应。

二、问题本质:Service Locator 的三宗罪

TheApp.Instance.GetSystem<T>(),这就是教科书式的 Service Locator:一个全局的、按类型查表的服务容器,谁需要谁自己去捞。

它有它的优点,这我不否认——简单、不用管线、随处可用。但它有三个结构性的代价:

  1. 依赖不可见。依赖关系散落在成千上万行方法体里,你读一个类,压根不知道它的协作者到底有谁。
  2. 违反依赖倒置(DIP)。GetSystem<RestaurantSystem>() 把具体类写死了,你换不了实现,也单测不了。
  3. 缺失依赖暴露得太晚。某个系统要是忘了注册,那得等到玩家某次触发这段逻辑,才在运行时抛 KeyNotFoundException——雷埋在深处,炸在线上。

顺便澄清一个特别常见的混淆:DI(依赖注入)是一种技术手段,DIP(依赖倒置)是一条设计原则。 用了 DI 可不等于满足了 DIP——这是后面聊"滥用"时的关键伏笔。

三、为什么不直接用构造函数注入?

最理想的那种"初始化时注入",当然是构造函数注入。但在这个框架里,它走不通。

子系统在 FirstInit 里是这么注册的:

_lSubSystemGroup.RegisterSystem(new RestaurantSystem());
_lSubSystemGroup.RegisterSystem(new StaffSystem());
// ...
_lSubSystemGroup.Init();           // 阶段一:各系统初始化自身
_lSubSystemGroup.OInitAllSystem(); // 阶段二:跨系统依赖

你想想,new StaffSystem() 执行的那一刻,RestaurantSystem 未必就已经构造出来了。构造函数注入会逼着你手工维护一张依赖拓扑序,稍微一调整就死锁,脆得不行。

但框架其实早就把正解给你留好了:LSubSystem 有两阶段初始化,Init() 管自己,OInit() 的注释白纸黑字写着"跨系统依赖"。也就是说,等到 OInitAllSystem() 执行的时候,所有系统都注册完了,彼此拿引用是安全的。你想在初始化时注入依赖的能力,框架已经给好位置了,只是没人用而已。

四、三个层次的解决方案

方案 A:字段缓存(最小改动)

把依赖提前缓存成字段,在 OInit 里赋值:

public class StaffSystem : LSubSystem
{
    private RestaurantSystem _restaurant;

    public override void OInit()
    {
        _restaurant = TheApp.Instance.GetSystem<RestaurantSystem>();
        RegisterRedDots();
    }

    public StaffHireState GetHireState(StaffItem item)
        => item.GetHireState(_restaurant.Level);
}

你看,依赖被挪到类顶部,一目了然。缺点嘛:仍然耦合着具体类型,DIP 只走了一半。项目里 MissionManager 已经是这个写法了,风格倒是可以统一。

方案 B:依赖抽象接口(真正满足 DIP + ISP)

StaffSystem 其实就只需要 RestaurantSystem 的一个 Level。那就只暴露这一个能力好了:

public interface IRestaurantLevel { int Level { get; } }

public class RestaurantSystem : LSubSystem, IRestaurantLevel { /* ... */ }

public class StaffSystem : LSubSystem
{
    private IRestaurantLevel _level;
    public override void OInit()
        => _level = TheApp.Instance.GetSystem<RestaurantSystem>();
}

这么一改,StaffSystem 就只依赖它真正用到的能力(接口隔离 ISP),依赖指向抽象(依赖倒置 DIP),而且你想塞个假的 IRestaurantLevel 进去做单测,也完全可以。代价是接口会变多——这里得克制,只对真正需要解耦或者需要测试的边界抽。

方案 C:框架级自动注入(一劳永逸,但工程量大)

仿照项目里已有的 [InjectElement] 特性,给子系统也做一个 [InjectSystem],让 LSubSystemGroup 在两阶段之间反射赋值:

public class StaffSystem : LSubSystem
{
    [InjectSystem] private RestaurantSystem _restaurant;
}

好处是心智模型跟 ElementKit 统一了,连 OInit 里那行手写赋值都省了;代价呢,得改框架层,而且存量调用不可能一次迁完。

五、关键追问:[InjectSystem] 和单例直调,到底差在哪?

很多人以为 DI 的价值在"自动赋值、少写代码"。不是的。这两者运行时干的是同一件事——都从同一个字典里按类型捞实例,性能一样,对象也一样。真正的区别在四个非运行时维度上:

维度 单例直调 注入
依赖可见性 藏在方法体里 声明在字段上,一眼看全
能否面向抽象 只能写具体类 可注入接口,支持 DIP
缺失依赖暴露时机 运行时某次调用才崩 启动统一校验即报错
控制方向 我主动去全局找(拉) 框架推给我(控制反转)

最后那条,正是 IoC 的字面含义:注入之后,这个类甚至不知道 TheApp 的存在,可以脱离全局单例复用。

六、滥用之后:注入退化成"披着外衣的单例"

那现在来回答最尖锐的那个问题:如果滥用 [InjectSystem],它跟单例还有区别吗?

几乎没有了,甚至更糟。

所谓滥用,就是每个类都无脑注入自己能拿到的所有系统:

[InjectSystem] private RestaurantSystem _r;
[InjectSystem] private DishSystem _d;
[InjectSystem] private AudioSystem _a;
[InjectSystem] private DecorationSystem _dec;
[InjectSystem] private ShopSystem _s;
// ...注入了 10 个,实际只用 2 个

这时候就热闹了:

  • 可见性优势反转为噪音:字段列了 10 个,你根本看不出哪些是真在用的。它"谎报"了依赖,比诚实的单例直调更误导人。
  • DIP 依然没做到:注入的仍然是具体类,只是换了个地方写死而已。注入语法本身不提供 DIP。
  • 耦合面不降反升:单例直调好歹是"用到才耦合",滥用注入是"能拿到就全耦合",把系统连接图硬生生变成了全连接网。
  • 还多了一层魔法:字段没人显式赋值却偏偏有值,新人读代码一脸问号,还白搭上反射的心智成本。

所以结论是:注入机制本身不产生解耦。它只是给了你一个机会,去做"最小化 + 面向抽象";这两件事不做,注入跟单例就是等价的。 判断该不该注入,别去问"能不能注入",要问的是"这个依赖需要被显性化和抽象化吗"。

七、落地建议:分层对待

存量有数百处 GetSystem 调用,别想着一次全歼,不现实。按用途分两类就行:

  • 系统间的稳定依赖(比如 StaffSystem → RestaurantSystem):这是真正的架构依赖,值得用方案 A 打底、方案 B 升级,把它显性化成字段/接口。
  • UI / Controller 的一次性取值(点一下取个状态那种):UI 本身就是应用边界,本来就依赖一堆系统,为它全套注入,只会得到"披着注入皮的单例"。保持 GetSystem 反而更诚实。

最后一句话记忆:

注入不产生解耦,它只是让你有机会做最小化与面向抽象。满足"最小化 + 面向抽象",注入与单例天差地别;否则,注入只是多了一层魔法的单例。

posted @ 2026-09-16 23:02  鑫鑫哥Adam  阅读(8)  评论(0)    收藏  举报