〇先讲一个生活类比(钥匙 vs 前台)
概念先别背,先看两个场景。
你要装修一间房(状态机)。房东在把钥匙交给你的时候,顺便把"水电工的电话"一起给你了。
你手里从第一天起就有这个号码 —— 不需要问任何人。
装修队 = 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:202 | public RevHeavyFsm(TOwner owner) —— 宿主从构造函数进来,存成 Owner 属性 |
RevHeavyFsmState.cs:51 | protected TOwner Owner => Machine.Owner; —— 状态通过状态机拿到宿主 |
RevIHeavyFsmOwner.cs:30 | 注释里的用法示例:new RevHeavyFsm<BossStateType, IBossObj>(boss) |
而且你比一般写法更强:宿主是泛型参数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 追击状态要"读血量、判断距离、朝玩家移动",跑法要能被单元测试。
// 契约写在类型签名上 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);
// 契约藏在实现里(签名只说明"它是某个状态") 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 就是为这个生的) |
依赖注入赢在可见、安全、天生多实例;服务定位器赢在跨层、可选能力、运行期可变。
四动画:两种"拿钥匙"的全过程
左边是构造注入,右边是服务定位器 —— 同一步骤,两条路径。
① 左边:钥匙一开始就在手里(依赖写进签名);右边:钥匙要去前台领(依赖藏在实现里)。
② 左边:状态机只知道 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 服务定位器的代价:依赖不可见 + 默认全局唯一
- 依赖不可见:读签名看不出状态依赖谁 → 排查"这个值是哪儿来的"要多跳几层;
- 默认全局唯一:
GetRequired<IBossObj>()只能拿到"那一个" —— 而 AI 天生是"每个对象一份",逼着框架引入"作用域"来解决; - 运行期才报错:写错类型名不会编译失败,要到跑起来那一刻才知道(所以我们在错误信息里塞了"已注册的服务清单");
- 容易被滥用:任何地方都能随手
Get一下 → 依赖关系烂成一团(这就是"Service Locator 反模式"的来源)。
那套参考实现里,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 的具体能力依然一眼可见(写在泛型/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() |
十一页决策速查卡(可打印)
贴在显示器旁边,纠结的时候看一眼。
== 重载)你悟到的"思想差不多"是对的:两者都实现了依赖倒置 + 运行期装配。
但精确地说,你用的是构造注入,而且在你这个场景(宿主是每个 AI 对象、要跑单测)它是更优解。
服务定位器不该替代它,而是补它的短板:跨层调用 + 全局系统 + 生命周期边界。