零基础手把手 · 从"听不懂"到"能上手写"

服务定位器 · 使用说明

这份文档只回答一个问题:我该怎么用它?

读完你能做到:3 分钟把服务装起来 · 分清"全局一份"和"每局一份" · 看懂每一句报错 · 避开 6 个最容易白干的坑

〇它是干什么的(一句话)

把「我用到的某个能力」和「这个能力具体由谁实现」分开。

✗ 不用它:底层反向依赖上层
// 底层框架想播声音,就得认识上层的声音类
SoundManager.GetInstance().Play("Play_Box_Appear");
// 但程序集依赖只能单向(上层 → 底层),写不通
✓ 用它:底层只认接口
// 实现在启动期"注册"进来,谁实现都行
services.GetRequired<ISoundService>()
        .Play("Play_Box_Appear");
一句话对照

参考实现早期版本用三套机制解决这件事:静态委托 SysMgr(约 250 个字段)+ 系统注册表 CSystemManager + 世界级容器 DimensionWorld。 本框架把它收成一套:注册 → 取用 → 作用域 → 释放。

什么时候用它 / 别用它

✅ 该用❌ 别用
底层模块要调用上层能力(UI 要播声音、动作序列要发奖励、资源系统要报日志)只是"少写一个构造参数" —— 直接传参更清楚
同一服务类型在不同场景/局面需要各一份(一局战斗一份战斗上下文)需要自动解析复杂依赖图(那是 DI 容器的活,且与 IL2CPP 热路径相性差)
想把业务依赖从框架里赶出去,并且能在不启游戏的情况下跑单测进程内只有唯一实现的简单工具类 —— 一个 static class 更直接

一3 分钟跑起来(可粘贴)

五步:定接口 → 写实现 → 启动期装配 → 运行期取用 → 一局的边界(作用域)。

第 1 步:定义你要用的能力(接口)
放在底层模块里 —— 谁都能引用它,因为它只是一个接口
public interface ISoundService
{
    void Play(string eventName);
}

public interface IConfigService
{
    string SoundBankPath { get; }
}
第 2 步:写实现(业务层)
构造函数里只初始化自己;要依赖别人就实现 RevIServiceInit
// ① 没有依赖的实现
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) { }
}
第 3 步:启动期装配(整个游戏只写一处)
只 Build 一次,缓存到 static readonly
public static class GameServices
{
    public static readonly RevServiceLocator Root = RevServiceLocator.Create()
        .AddSingleton<ISoundService, SoundServiceWithDeps>()   // 接口 → 实现(零反射,启动期不创建)
        .AddSingleton<IConfigService>(Config.Load())           // 已有实例(容器不释放它)
        .Build();
}
第 4 步:运行期取用(一行)
必需用 GetRequired,可选用 Get
var sound = GameServices.Root.GetRequired<ISoundService>();  // 必须有:没注册就报错
sound.Play("Play_Box_Appear");

GameServices.Root.Get<IDebugService>()?.Draw();              // 可选能力:没有就是 null
第 5 步:一局 / 一个场景的边界(作用域)
共享 Singleton、各持一份 Scoped、退出即释放
using (var battle = GameServices.Root.CreateScope())
{
    var ctx = battle.GetRequired<ICombatContext>();   // 这一局独有的一份
    battle.Tick(Time.deltaTime);                     // 驱动这一局里实现了 RevITickable 的服务
}   // ← 离开 using:这一局的服务按"逆创建序"释放
为什么第 3 步要写 .Build()?

不写就编译不过 —— 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:整个应用一份

适合配置、音频、网络、任务系统、日志 —— 那些"全局只有一个才合理"的东西。

3.2 Scoped:每个作用域一份

作用域隔离长什么样 纯 CSS 动画
scope A(第一局)
ICombatContext ← A 自己的一份
ISession ← A 自己的一份
根容器
IClock(Singleton:全局唯一,共享)
IConfig(Singleton:共享)
scope B(第二局)
ICombatContext ← B 自己的一份
ISession ← B 自己的一份

① A 先取 Scoped:容器给 A 创建一份,只属于 A。

② 再取 Singleton:从根容器拿 —— A、B、根拿到的是同一个对象。

③ B 取 Scoped:另建一份,与 A 互不影响(这就是"同类型多实例")。

对应参考实现:DimensionWorld 用"实例字段数组"让同一个服务类型在多 World 各有一份;本框架把它泛化成 CreateScope()。

3.3 CreateScope:一层的边界,不再嵌套

为什么嵌套会让"某个服务到底属于哪一层"变得难以回答。参考实现 DimensionWorld 用的就是"一层隔离",这个尺度刚好够用。

3.4 谁创建谁负责释放

怎么注册的谁释放说明
.AddSingleton<IX, X>() / 工厂注册容器逆创建序释放(后创建的先放,因为后创建的可能依赖先创建的)
.AddScoped<IX, X>() / 工厂注册容器作用域 Dispose() 时;根 Dispose() 会先释放它下面所有作用域
.AddSingleton<IX>(已有实例)你自己容器只持有它、绝不 Dispose 它(你把宿主对象传进来,容器不该替你销毁)

四完整演练:底层要播声音(参考实现的真实动机)

三个文件,把"底层要调上层能力"讲清楚。

底层:只认 ISoundService→ 上层:WwiseSoundService 实现它→ 组合根:注册 + Build→ 运行期:Get 到并用
① 底层(BaseScripts 层,只引用接口)
// 底层模块里:我只知道"有人能播声音"
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在根容器上取 ScopedICombatContext 是 Scoped…不能在根容器上取先 CreateScope(),在作用域里取
3同一个服务注册两处服务 IXxx 已经注册过了(第二次来自 …)装配只留一处(框架刻意不做静默覆盖)
4两个服务在构造/OnInit 里互相取检测到循环依赖:A → B → A构造函数只初始化自己;用 OnInit 取依赖,且别互相取
5忘了缓存 Build 结果重复 Build 报错 / 白付装配成本缓存到 static readonly 字段
6到处给"全局静态字段"赋值运行到一半行为变了,排查要翻全工程只有组合根能装配;Build() 之后容器只读
第 6 条为什么重要

那套参考实现的 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.cs317★ 必读:唯一入口(取服务 / 作用域 / 释放)
Facade\RevServiceBuilder.cs137★ 必读:注册(AddSingleton / AddScoped / Build)
Interfaces\RevIServiceLocator.cs37建议扫一眼:取服务的只读契约(底层模块只依赖它)
Core\RevServiceLifetime.cs22扫一眼:Singleton / Scoped
Implementation\RevServiceRegistry.cs · RevServiceDescriptor.cs98 / 44不用读(实例表、逆序释放、Tick 列表)
Interfaces\RevIServiceInit.cs · RevITickable.cs26 / 21用到再读(两个可选钩子)
下一步读什么

想搞清楚"为什么这么设计"(以及那套参考实现每一处取舍的出处):读 Assets\Revolution\Runtime\RevServiceLocator\README.md。