〇它是干什么的(一句话)
把「我用到的某个能力」和「这个能力具体由谁实现」分开。
// 底层框架想播声音,就得认识上层的声音类 SoundManager.GetInstance().Play("Play_Box_Appear"); // 但程序集依赖只能单向(上层 → 底层),写不通
// 实现在启动期"注册"进来,谁实现都行 services.GetRequired<ISoundService>() .Play("Play_Box_Appear");
参考实现早期版本用三套机制解决这件事:静态委托 SysMgr(约 250 个字段)+ 系统注册表 CSystemManager + 世界级容器 DimensionWorld。
本框架把它收成一套:注册 → 取用 → 作用域 → 释放。
什么时候用它 / 别用它
| ✅ 该用 | ❌ 别用 |
|---|---|
| 底层模块要调用上层能力(UI 要播声音、动作序列要发奖励、资源系统要报日志) | 只是"少写一个构造参数" —— 直接传参更清楚 |
| 同一服务类型在不同场景/局面需要各一份(一局战斗一份战斗上下文) | 需要自动解析复杂依赖图(那是 DI 容器的活,且与 IL2CPP 热路径相性差) |
| 想把业务依赖从框架里赶出去,并且能在不启游戏的情况下跑单测 | 进程内只有唯一实现的简单工具类 —— 一个 static class 更直接 |
一3 分钟跑起来(可粘贴)
五步:定接口 → 写实现 → 启动期装配 → 运行期取用 → 一局的边界(作用域)。
public interface ISoundService { void Play(string eventName); } public interface IConfigService { string SoundBankPath { get; } }
// ① 没有依赖的实现 public sealed class SoundService : ISoundService { public void Play(string eventName) { /* 真的调你的音频系统 */ } } // ② 需要依赖别人:实现 RevIServiceInit,在 OnInit 里取 public sealed class SoundServiceWithDeps : ISoundService, RevIServiceInit { private IConfigService _config; public void OnInit(RevIServiceLocator services) => _config = services.GetRequired<IConfigService>(); // ← 这里取依赖 public void Play(string eventName) { } }
public static class GameServices { public static readonly RevServiceLocator Root = RevServiceLocator.Create() .AddSingleton<ISoundService, SoundServiceWithDeps>() // 接口 → 实现(零反射,启动期不创建) .AddSingleton<IConfigService>(Config.Load()) // 已有实例(容器不释放它) .Build(); }
var sound = GameServices.Root.GetRequired<ISoundService>(); // 必须有:没注册就报错 sound.Play("Play_Box_Appear"); GameServices.Root.Get<IDebugService>()?.Draw(); // 可选能力:没有就是 null
using (var battle = GameServices.Root.CreateScope()) { var ctx = battle.GetRequired<ICombatContext>(); // 这一局独有的一份 battle.Tick(Time.deltaTime); // 驱动这一局里实现了 RevITickable 的服务 } // ← 离开 using:这一局的服务按"逆创建序"释放
不写就编译不过 —— Create() 返回的是"构建器",只有 Build() 才给你容器。
这样"忘了冻结装配"是编译期问题,而不是运行到一半才发现的事故。
二"我要做 X" 对照表(背下就会用)
左边是你脑子里的需求,右边是可以直接抄的代码。
| 我想做的 | 写法 |
|---|---|
| 注册"全局一份"的服务 | .AddSingleton<ISoundService, SoundService>() |
| 注册一个已经存在的对象 | .AddSingleton<IConfigService>(Config.Load())(容器不释放它) |
| 注册需要构造参数的服务 | .AddSingleton<ISoundService>(s => new SoundService(s.GetRequired<IConfigService>().SoundBankPath)) |
| 注册"每局一份"的服务 | .AddScoped<ICombatContext, CombatContext>() |
| 冻结装配、拿到容器 | .Build()(只能调一次,调两次会报错) |
| 取一个必需的服务 | services.GetRequired<ISoundService>() |
| 取一个可选的服务 | services.Get<IDebugService>()?.Draw() 或 if (services.TryGet<IDebugService>(out var d)) ... |
| 开一局/一个场景的边界 | using (var scope = services.CreateScope()) { ... } |
| 每帧驱动服务 | services.Tick(Time.deltaTime)(服务要实现 RevITickable) |
| 释放(退出场景 / 关游戏) | services.Dispose()(幂等,重复调用没事) |
| 让服务在创建后取依赖 | 服务实现 RevIServiceInit.OnInit(RevIServiceLocator) |
| 单测里替换实现 | 另建一个 RevServiceLocator.Create()...Build(),注入假实现(不动生产那份) |
三四个核心概念(讲透,别跳)
只有四个,理解了就不会用错。
3.1 Singleton:整个应用一份
- 第一次被取用时才创建(启动期不会创建,所以不会因为"装了一堆系统"变慢);
- 根容器与它下面的所有作用域共享同一个实例;
- 直到根容器
Dispose()才释放。
3.2 Scoped:每个作用域一份
- 每个
CreateScope()出来的容器各持一份(也就是"同一类型多实例"); - 第一次被取用时创建,随作用域
Dispose()释放; - ★ 在根容器上取 Scoped 服务会直接报错 —— 这是刻意的:根没有"一轮"的语义,取到全局唯一的一份就是 bug 的起点。
① A 先取 Scoped:容器给 A 创建一份,只属于 A。
② 再取 Singleton:从根容器拿 —— A、B、根拿到的是同一个对象。
③ B 取 Scoped:另建一份,与 A 互不影响(这就是"同类型多实例")。
DimensionWorld 用"实例字段数组"让同一个服务类型在多 World 各有一份;本框架把它泛化成 CreateScope()。3.3 CreateScope:一层的边界,不再嵌套
- 作用域只能从根容器开(一层就够);在作用域上再
CreateScope()会报错并告诉你正确写法; - 需要"多轮并发",就开多个平行作用域,而不是作用域里套作用域。
DimensionWorld 用的就是"一层隔离",这个尺度刚好够用。3.4 谁创建谁负责释放
| 怎么注册的 | 谁释放 | 说明 |
|---|---|---|
.AddSingleton<IX, X>() / 工厂注册 | 容器 | 逆创建序释放(后创建的先放,因为后创建的可能依赖先创建的) |
.AddScoped<IX, X>() / 工厂注册 | 容器 | 作用域 Dispose() 时;根 Dispose() 会先释放它下面所有作用域 |
.AddSingleton<IX>(已有实例) | 你自己 | 容器只持有它、绝不 Dispose 它(你把宿主对象传进来,容器不该替你销毁) |
四完整演练:底层要播声音(参考实现的真实动机)
三个文件,把"底层要调上层能力"讲清楚。
// 底层模块里:我只知道"有人能播声音" public interface ISoundService { void Play(string eventName); } public static class RevSound { public static void PlayUiSound(RevIServiceLocator services, string name) { // 可选能力:没接声音系统就不播(不是错误) services.Get<ISoundService>()?.Play(name); } }
public sealed class WwiseSoundService : ISoundService { public void Play(string eventName) => Wwise.PostEvent(eventName); }
public static readonly RevServiceLocator Root = RevServiceLocator.Create()
.AddSingleton<ISoundService, WwiseSoundService>()
.Build();
FakeSoundService 一行不改就能跑。单测里怎么替换实现
var test = RevServiceLocator.Create() .AddSingleton<ISoundService>(new FakeSoundService()) // 用假的,不动生产那份 .Build();
不要在测试里给生产容器的字段赋值(那种写法在那套参考实现里要靠"改静态字段"来伪造,是它的主要痛点之一)。测试就自己 Build 一个。
五六个最容易白干半天的坑
每一条都有对应报错,看到就能改。
| # | 坑 | 现象 | 正确做法 |
|---|---|---|---|
| 1 | 注册用接口、取用具体类 | 取不到服务 SoundService | 取的时候也用接口:GetRequired<ISoundService>()(报错信息里会提醒你) |
| 2 | 在根容器上取 Scoped | ICombatContext 是 Scoped…不能在根容器上取 | 先 CreateScope(),在作用域里取 |
| 3 | 同一个服务注册两处 | 服务 IXxx 已经注册过了(第二次来自 …) | 装配只留一处(框架刻意不做静默覆盖) |
| 4 | 两个服务在构造/OnInit 里互相取 | 检测到循环依赖:A → B → A | 构造函数只初始化自己;用 OnInit 取依赖,且别互相取 |
| 5 | 忘了缓存 Build 结果 | 重复 Build 报错 / 白付装配成本 | 缓存到 static readonly 字段 |
| 6 | 到处给"全局静态字段"赋值 | 运行到一半行为变了,排查要翻全工程 | 只有组合根能装配;Build() 之后容器只读 |
那套参考实现的 202 个 public static 字段谁都能改(只靠"组合根之外禁止赋值"的口头纪律)。
本框架把这条纪律变成了类型:注册只能发生在构建器上,Build() 之后就改不了了。
六报错信息怎么读(把真实文案抄给你)
这些文案就是框架真的会打出来的,不是示意。
| 你会看到 | 它的意思 / 怎么改 |
|---|---|
取不到服务 IXxx(没有注册过)。已注册的服务:… | 没注册,或名字对不上。看"已注册的服务"列表对一下;注册用接口就要用接口取 |
IXxx 是 Scoped(每个作用域一份)服务,不能在根容器上取。请在它所属的边界里开作用域… | 用法错误(不是"没有"):using (var scope = Root.CreateScope()) { scope.GetRequired<IXxx>(); } |
检测到循环依赖:A → B → A | 两个服务互相等对方先创建 → 把互相取用挪到 OnInit 之后,或只取真正需要的那一个 |
服务 IXxx 已经注册过了(第二次来自 AddSingleton<IXxx, Xxx>()) | 重复注册 → 合并到一处 |
这个 RevServiceLocator 已经 Dispose 了 —— 容器释放后不能再取服务 | 用了已释放的容器 → 场景卸载前把需要的东西取好 |
作用域不再嵌套:请从根容器 CreateScope() | 在作用域上又开了作用域 → 开多个平行作用域 |
这个 RevServiceBuilder 已经 Build 过了 | 重复 Build → 只 Build 一次并缓存 |
它不出任何 Log —— 问题一律用"抛异常 + 一句人话"暴露。所以你不会遇到"运行了但什么都没说"的情况。
七怎么验证它在工作
本模块是纯 C#(不引用 UnityEngine),所以能放进普通 .NET 工程跑,也可以直接写成 NUnit 用例。
var root = RevServiceLocator.Create() .AddSingleton<IClock, Clock>() .AddScoped<ISession, Session>() .Build(); // ① 作用域隔离:两个作用域的 Scoped 不是同一个对象 var a = root.CreateScope(); var b = root.CreateScope(); Assert.AreNotSame(a.GetRequired<ISession>(), b.GetRequired<ISession>()); // ② Singleton 共享:根与作用域拿到同一个 Assert.AreSame(root.GetRequired<IClock>(), a.GetRequired<IClock>()); // ③ Scoped 在根上取会抛异常(用法守卫) Assert.Throws<InvalidOperationException>(() => root.GetRequired<ISession>()); // ④ 释放后再取会抛异常,而不是默默给你一个坏对象 a.Dispose(); Assert.Throws<InvalidOperationException>(() => a.GetRequired<ISession>());
本框架就是用这类断言验证的:19 条全过(含作用域隔离、Singleton 共享、逆创建序释放、循环依赖报错、OnInit 失败回滚、Tick 只驱动本容器、根 Dispose 连带释放作用域)。
八一页速查卡(可打印)
贴在显示器旁边,写代码时不用回来翻文档。
RevServiceLocator.Create() → ... → .Build().AddSingleton<IX, X>().AddSingleton<IX>(实例)(容器不释放它).AddSingleton<IX>(s => new X(...)).AddScoped<IX, X>()GetRequired<IX>()Get<IX>() / TryGet<IX>(out var x)using (var s = Root.CreateScope())scope.Tick(dt)(服务实现 RevITickable)Dispose()(幂等;根会连带释放作用域)RevIServiceInit.OnInit(services)RevIServiceLocator(3 个方法)① 装配只在一处(组合根),Build() 之后改不了;
② GetRequired 用于必需依赖(漏注册启动期就炸),Get 只用于可选能力;
③ 谁创建谁负责释放:容器释放"工厂创建的服务",你自己传进来的实例由你释放。
九与参考实现的对照(一句话版)
完整 22 条(每条带出处行号)在代码目录的 README 第三节。
| ✓ 保留的精华 | ✗ 改掉的糟粕 |
|---|---|
| 显式注册 + 组合根唯一前置(运行期只读) | 202 个 public static 可变字段(谁都能改,只靠口头纪律) |
| 接口优先、依赖倒置 | Init 抛异常但实例仍留在容器里(半初始化) |
| 幂等注册 | 重复注册静默覆盖(旧实例还不销毁) |
零反射泛型 new() 工厂 | 手写 22 分支 switch 工厂 + 枚举扩展仪式 |
| "先入表、再 Init" 不变量(并补上失败回滚) | 全量卸载不摘钩子(与单个卸载不对称) |
能力探测式挂载(RevITickable 自动进 Tick 列表) | 内部集合外泄(GetSystems() 返回可变 List) |
| 逆序卸载 | 未注入返回 null 兜底 → 掩盖错误 |
| 作用域隔离(同类型多实例) | World 级服务被挂成全局单例就破坏多实例 |
Get / GetRequired / TryGet 三态语义 | 250 字段单文件 + 5 个近义入口 |
| — | 容器绑死 MonoBehaviour 单例,无法脱离引擎单测 |
十文件都在哪(你只需要读两个)
代码位置:Assets\Revolution\Runtime\RevServiceLocator\
| 文件 | 行数 | 要不要读 |
|---|---|---|
Facade\RevServiceLocator.cs | 317 | ★ 必读:唯一入口(取服务 / 作用域 / 释放) |
Facade\RevServiceBuilder.cs | 137 | ★ 必读:注册(AddSingleton / AddScoped / Build) |
Interfaces\RevIServiceLocator.cs | 37 | 建议扫一眼:取服务的只读契约(底层模块只依赖它) |
Core\RevServiceLifetime.cs | 22 | 扫一眼:Singleton / Scoped |
Implementation\RevServiceRegistry.cs · RevServiceDescriptor.cs | 98 / 44 | 不用读(实例表、逆序释放、Tick 列表) |
Interfaces\RevIServiceInit.cs · RevITickable.cs | 26 / 21 | 用到再读(两个可选钩子) |
想搞清楚"为什么这么设计"(以及那套参考实现每一处取舍的出处):读 Assets\Revolution\Runtime\RevServiceLocator\README.md。