零基础图解 · 讲清两个容易混的概念

依赖注入 vs 服务定位器 · 到底该用哪个

你现在的重量级状态机里,"AI 把 this 注入状态机、状态类通过接口调 AI"—— 这和服务定位器是同一类思想吗?

读完你能做到:一句话说出两者的区别 · 知道你现在用的是哪一种 · 知道各自要付什么代价 · 知道什么时候才该上服务定位器

〇先讲一个生活类比(钥匙 vs 前台)

概念先别背,先看两个场景。

🔑 依赖注入(DI):构造时把钥匙递进去

你要装修一间房(状态机)。房东在把钥匙交给你的时候,顺便把"水电工的电话"一起给你了。
你手里从第一天起就有这个号码 —— 不需要问任何人。

装修队 = new 装修队(水电工的电话);
装修队.干活();   // 什么时候需要,直接打
🏢 服务定位器:需要时到前台按名字要

房东只给了你"大堂前台的电话"。干活时你需要水电工 → 打电话给前台:"我要水电工"。
前台查表,把水电工派给你。

装修队 = new 装修队(前台电话);
装修队.干活();   // 干活途中:前台.给我("水电工")
核心区别就一句

依赖注入是"我有什么,构造时交给你";服务定位器是"我需要什么,向容器要"。 前者是推(push),后者是拉(pull)。两者都实现了"只认接口、不认具体实现"(依赖倒置),所以确实"差不多" —— 但代价完全不同,选错了要返工。

一两种写法长什么样(你项目里的真实代码)

先用重量级状态机当例子 —— 这就是"依赖注入"那一边。

1.1 你的做法:构造注入 + 泛型宿主接口

你项目里的代码是这样的(Runtime\RevStateMachine\Heavyweight\):

// ① 宿主行为接口:业务自己定义(框架不规定 AI 该有什么)
public interface IBossObj : RevIHeavyFsmOwner
{
    float Health { get; }
    void MoveTowardsPlayer();
    bool IsPlayerInAttackRange();
}

// ② AI 对象(MonoBehaviour)实现它,并在 new 状态机时把自己交进去
public class BossAI : MonoBehaviour, IBossObj
{
    private RevHeavyFsm<BossStateType, IBossObj> _fsm;

    private void Awake()
        => _fsm = new RevHeavyFsm<BossStateType, IBossObj>(this);   // ← 把自己注入进去
}

// ③ 状态类里:通过 Owner 调 AI 的行为(强类型,零转型)
public sealed class BossChaseState : RevHeavyFsmState<BossStateType, IBossObj>
{
    public override void OnUpdate(float dt)
    {
        if (Owner.IsPlayerInAttackRange()) ChangeTo(BossStateType.Attack);
        else Owner.MoveTowardsPlayer();
    }
}

关键代码就两处(可以直接点开看):

文件:行它做了什么
RevHeavyFsm.cs:202public RevHeavyFsm(TOwner owner) —— 宿主从构造函数进来,存成 Owner 属性
RevHeavyFsmState.cs:51protected TOwner Owner => Machine.Owner; —— 状态通过状态机拿到宿主
RevIHeavyFsmOwner.cs:30注释里的用法示例:new RevHeavyFsm<BossStateType, IBossObj>(boss)
这就是标准的「构造注入(Constructor Injection)」

而且你比一般写法更强:宿主是泛型参数TOwner(约束 where TOwner : class, RevIHeavyFsmOwner), 所以状态类拿到的是编译期确定的强类型接口,不用拆箱、不用转型。

1.2 服务定位器的写法(同样的需求,换一种拿法)

// 状态类里:主动向容器要(注意:签名上看不出它要什么)
public sealed class BossChaseState : SomeStateBase
{
    public override void OnUpdate(float dt)
    {
        var ai = Services.GetRequired<IBossObj>();   // ← 到"前台"要一个
        if (ai.IsPlayerInAttackRange()) ChangeTo(BossStateType.Attack);
        else ai.MoveTowardsPlayer();
    }
}
看出问题了吗

光看 BossChaseState 的类型签名,你完全不知道它依赖 IBossObj; 而上面那种写法,依赖是写在类型上的(RevHeavyFsmState<BossStateType, IBossObj>)。 这就是"依赖可见性"的差别 —— 也是为什么参考实现的设计文档里明确写着"显式依赖优于魔法注入"(00_设计哲学.md:89-105)。

二同一个需求,两种实现(并排对比)

需求:Boss 追击状态要"读血量、判断距离、朝玩家移动",跑法要能被单元测试。

A. 依赖注入(你现在的做法)
// 契约写在类型签名上
class ChaseState : RevHeavyFsmState<BossStateType, IBossObj>
{
    public override void OnUpdate(float dt)
    {
        if (Owner.Health < 30f) ChangeTo(BossStateType.Frenzy);
        else if (Owner.IsPlayerInAttackRange()) ChangeTo(BossStateType.Attack);
        else Owner.MoveTowardsPlayer();
    }
}

// 测试:直接 new 一个假 Boss,不需要任何容器
var fsm = new RevHeavyFsm<BossStateType, IBossObj>(new FakeBoss());
fsm.ChangeTo(BossStateType.Chase);
fsm.Update(0.016f);
Assert.AreEqual("MoveTowardsPlayer", ((FakeBoss)fsm.Owner).LastCall);
B. 服务定位器(换一种拿法)
// 契约藏在实现里(签名只说明"它是某个状态")
class ChaseState : SomeStateBase
{
    public override void OnUpdate(float dt)
    {
        var ai = Services.GetRequired<IBossObj>();
        if (ai.Health < 30f) ChangeTo(BossStateType.Frenzy);
        else if (ai.IsPlayerInAttackRange()) ChangeTo(BossStateType.Attack);
        else ai.MoveTowardsPlayer();
    }
}

// 测试:要先搭一个容器,注册假 Boss,再取出来
var services = RevServiceLocator.Create()
    .AddSingleton<IBossObj>(new FakeBoss())
    .Build();
// ……还得想办法让 ChaseState 拿到这个 services 实例
并排一看就明白

右边多出来的两件事就是我们说的"代价":① 依赖不可见(读签名看不出来)、② 测试要先搭容器(脚手架)。 而左边唯一"贵"的地方是:将来要换实现,得改类型签名(泛型参数)。

三七个维度逐条讲(人话版)

不用背,理解每条"为什么"就行。

维度依赖注入(你现在的做法)服务定位器
谁决定
用哪个实现
构造状态机的那个人(new RevHeavyFsm<..., IBossObj>(boss)) 启动期的注册表(AddSingleton<IBossObj, BossAI>())
依赖
可见性
签名即契约:泛型参数 / 构造参数上写着,编译期一眼可见 藏在实现里:只有读状态代码才知道它要什么
绑定时机 构造期一次,之后不变(不可变,好推理) 每次取用时(可延迟创建、可换实现、可按时序变化)
多实例 天生支持:每个 AI 一个状态机,Owner 各不相同 同接口默认全局唯一;要"每个对象一份"得额外开作用域(scope)
可测性 new RevHeavyFsm<..., IBossObj>(fakeBoss) 直接跑,不需要容器 要先搭容器 + 让被测对象能拿到容器(多一层脚手架)
编译期
安全
强:泛型 + 约束,取错类型编译不过 弱:类型键在运行期才查,写错名字要到运行时才报
跨程序集
(底层调上层)
需要"上层能改下层签名"才行;底层不能反向引用上层,这条路走不通 唯一出路:底层只依赖接口,实现由启动期注册进来(参考实现 SysMgr 就是为这个生的)
一句话记住

依赖注入赢在可见、安全、天生多实例;服务定位器赢在跨层、可选能力、运行期可变。

四动画:两种"拿钥匙"的全过程

左边是构造注入,右边是服务定位器 —— 同一步骤,两条路径。

同一步骤的两种路径 纯 CSS 动画
🔑 依赖注入(push)
BossAI(MonoBehaviour,实现了 IBossObj)它自己就是"水电工"
▼
new RevHeavyFsm<BossStateType, IBossObj>(this)构造时把自己交进去
▼
状态里:Owner.MoveTowardsPlayer()强类型、零转型、不用查表
🏢 服务定位器(pull)
状态需要一个 AI 能力但手里没有,也不知道谁能给
▼
Services.GetRequired<IBossObj>()向容器按类型要一个
▼
容器查注册表 → 返回实现全局唯一;要每个 AI 一份就得开 scope

① 左边:钥匙一开始就在手里(依赖写进签名);右边:钥匙要去前台领(依赖藏在实现里)。

② 左边:状态机只知道 IBossObj 这个接口,不认识 BossAI 这个类;右边同样只认接口,但多了一次"查表"。

③ 左边:每个 AI 天然一份(Owner 就是它自己);右边默认全局一份,想"每个 AI 一份"要显式开作用域。

对应你项目的代码:RevHeavyFsmState.cs:51 的 protected TOwner Owner => Machine.Owner; 就是左边那条"钥匙一直在手里"的写法。

五各自要付的代价(这是最该看的)

两种都不是免费的,知道代价才能选对。

5.1 依赖注入的代价:生命周期问题转移给了注入方

你把 this(自己)交进状态机了,那"我死了以后状态机还在用我"这件事,得你自己负责。Unity 里有个特别阴的坑:

// 接口引用不走 Unity 的 == 重载(Unity 的"假 null"只对 UnityEngine.Object 生效)
GameObject.Destroy(ownerGo);

if (Owner != null)        // ✗ 仍然为 true!(接口变量是纯引用比较)
    Owner.MoveTowardsPlayer();   // ✗ MissingReferenceException
✗ 危险:直接判空就调
if (Owner == null) return;
Owner.MoveTowardsPlayer();
✓ 两种补法(可以都做)
// ① 根治:宿主销毁时解除引用(你项目里已经提供了这个能力)
private void OnDestroy() => _fsm.Dispose();
// RevHeavyFsm.cs:398 → Clear() + IsEnabled=false + Owner=null

// ② 防御:状态里判空时把"假 null"也算进去
if (Owner is not UnityEngine.Object u || u == null) return;
好消息:你的状态机已经把"解绑"做进去了

RevHeavyFsm.Dispose()(RevHeavyFsm.cs:398)做三件事:Clear()(取消在途事务 + 退出当前状态 + 清空注册表与历史)、 IsEnabled = false(禁用该状态机)、Owner = null(解除宿主引用)。
所以只要宿主在 OnDestroy 里调一次 _fsm.Dispose(),这条坑就是干净的。 这跟服务定位器里 Dispose() 是同一个思路:释放之后再使用要能被发现,而不是默默给你一个坏对象。
反过来说也成立:谁注入,谁就得保证解绑 —— 显式注入把这件事交回给你(框架只能提供 Dispose 这个动作,调不调是你的事)。

5.2 服务定位器的代价:依赖不可见 + 默认全局唯一

参考实现的取舍可以当参考

那套参考实现里,SysMgr(202 个静态委托字段)只用来做"底层调上层"这一件事 —— 业务内部互相取用仍然是显式的(world.GetService<T>()、构造传入、instance 直取)。 它的设计文档把这条写成了原则:"显式依赖优于魔法注入"(00_设计哲学.md:89-105)。

六三个判断场景(该用哪个)

照这三条对号入座,不用纠结。

你的情况该用什么为什么
宿主是每个对象一份(每个 AI 一个状态机 / 每个怪一个控制器) 依赖注入(你现在的做法) 天然多实例;依赖可见;单测直接 new 假的就能跑
状态机在底层程序集,AI 实现在上层,底层不能反向引用 服务定位器 签名改不了、引用又指不过去 —— 这是定位器唯一的、也是不可替代的场景
状态需要的能力很多、且随状态不同(有的要技能系统、有的要寻路) 先把能力聚合成一个接口(首选);真的爆炸了再考虑容器 聚合接口仍然是显式注入,只是把 5 个参数变成 1 个;容器会让依赖彻底隐形
需要一局 / 一个场景一份的边界 服务定位器 + 作用域(CreateScope()) 这是定位器的强项:共享全局单例、隔离局内对象、退出即释放
判断口诀

"这个依赖属于谁?" —— 属于某个对象(这个 Boss 自己)→ 显式注入;属于整个游戏/某一局(音频、配置、协议、战斗上下文)→ 服务定位器。

七推荐的混合用法(完整代码)

两者不是二选一 —— 它们是干不同活的。这一节是"落地版"。

7.1 分清两种依赖

依赖类型谁给例子
per-object(每个对象自己的能力/数据)显式注入(构造参数 / Owner)这个 Boss 的血量、它的移动方法、它的技能组件
per-app / per-scope(全局系统)服务定位器配置、音频、协议、资源、战斗上下文

7.2 代码长这样(状态里两种都用到)

// ① 启动期:只装配"全局系统"(per-app 的东西)
public static class GameBoot
{
    public static readonly RevServiceLocator Services = RevServiceLocator.Create()
        .AddSingleton<IAIConfigService, AIConfigService>()    // Boss 愤怒阈值等配置
        .AddSingleton<ISoundService, SoundService>()
        .Build();
}

// ② 状态类:per-object 走 Owner(显式注入),全局系统走 Services
public sealed class BossChaseState : RevHeavyFsmState<BossStateType, IBossObj>
{
    public override void OnUpdate(float dt)
    {
        // ✅ per-object:每个 Boss 自己的行为(显式注入,强类型)
        if (Owner.IsPlayerInAttackRange()) { ChangeTo(BossStateType.Attack); return; }

        // ✅ per-app:全局配置(定位器取,跨状态共享)
        var config = GameBoot.Services.GetRequired<IAIConfigService>();

        if (Owner.Health < config.FrenzyThreshold) ChangeTo(BossStateType.Frenzy);
        else Owner.MoveTowardsPlayer();
    }
}
AI 对象(per-object):Owner 注入+ 全局系统(per-app):Services 取用= 职责清楚、可测、可复用
这样分工的好处

① 每个 AI 的具体能力依然一眼可见(写在泛型/Owner 上);
② 全局系统不用一层层往下传(从状态机构造函数传到状态再到子对象,那种"参数接力"很痛苦);
③ 单测里只需替换少量全局系统(AddSingleton<IX>(fake)),per-object 的假 Boss 直接 new。

7.3 一个诚实说明(当前定位器的能力边界)

现在的 RevServiceLocator 不支持"运行期注册实例"

装配在 Build() 时就冻结了,所以"给每个 AI 开一个 scope、把它的能力组件塞进去"这种用法暂时做不到。 而这恰好印证了上面的分工:per-object 的东西,显式注入更合适;容器留给 per-app 的系统。 如果确实需要"每个 AI 一个作用域装它自己的组件",给定位器加一个运行期注册口子(约 20 行)即可 —— 需要就说。

八三个常见误区

这三条最容易让人做错决定。

误区为什么不对
"两者差不多,随便选" 思想同源(都靠接口解耦),但代价完全不同:注入把生命周期责任交给你,定位器把依赖可见性交出去。选错了,要么排查困难,要么返工改签名。
"定位器更高级,项目里都该用它" 定位器解决的是一类具体问题(跨层 + 不能改签名 + 全局系统 + 作用域)。在没有这类问题的场景里用它,只会换来"依赖看不见 + 单测要搭容器"。
"我直接传对象就行,不需要接口" 传具体类就把状态和那个类绑死了:换实现要改、单测造不出假的、上层实现改了底层要重编译。关键不是"传什么",而是"传的是接口还是实现"。

九术语小词典(小白必备)

只解释这几个词,够用了。

术语人话解释在本项目里的样子
依赖"我干活需要用到的东西"状态需要 AI 的血量与移动方法
依赖倒置
(DIP)
不直接依赖具体实现,而是依赖接口;谁来实现由外部决定状态只认 IBossObj(行为接口),不认识 BossAI 这个类
控制反转
(IoC)
"怎么拿到依赖"这件事不再由自己决定,而是交给外部状态不会去 new BossAI(),是别人把 Boss 交给它
依赖注入
(DI)
IoC 的一种做法:外部把依赖递给你(构造参数 / 属性 / 方法)new RevHeavyFsm<BossStateType, IBossObj>(this)
服务定位器IoC 的另一种做法:你自己去容器里按类型要Services.GetRequired<ISoundService>()
生命周期这个对象"活多久"Singleton = 整个游戏一份;Scoped = 一局/一个场景一份
作用域
(Scope)
一块有明确边界的"存活区域",退出即释放using (var battle = Root.CreateScope()) { ... }
组合根
(Composition Root)
整个游戏里唯一装配依赖的地方GameBoot.Services = RevServiceLocator.Create()...Build()

十一页决策速查卡(可打印)

贴在显示器旁边,纠结的时候看一眼。

一句话区别注入 = 构造时递钥匙;定位器 = 需要时到前台要
属于某个对象(这个 Boss 自己)→ 显式注入
属于整个游戏 / 某一局(配置、音频、协议、战斗上下文)→ 定位器
底层要调上层(不能反向引用)→ 只能定位器
需要多实例(每个 AI 一份)→ 显式注入最省事
需要跑单测→ 显式注入(直接 new 假的);定位器要先搭容器
依赖可见性:注入写在签名上;定位器藏在实现里
跨层/可变:定位器的强项(可延迟创建、可换实现)
注入的代价:谁注入谁管解绑(Unity 假 null 坑:接口引用不走 == 重载)
定位器的代价:依赖隐形 + 默认全局唯一(多实例要 scope)
混合用法:per-object 注入 + per-app 定位器(推荐)
判断口诀"这个依赖属于谁?"—— 属于对象就注入,属于全局就定位器
结论(回到你的问题)

你悟到的"思想差不多"是对的:两者都实现了依赖倒置 + 运行期装配。
但精确地说,你用的是构造注入,而且在你这个场景(宿主是每个 AI 对象、要跑单测)它是更优解。 服务定位器不该替代它,而是补它的短板:跨层调用 + 全局系统 + 生命周期边界。