服务定位器

获取任意框架或游戏服务时唯一需要调用的静态类。

获取任意服务时唯一需要调用的类 · 所有框架与游戏服务的全局注册表 · 始终已初始化——无需替换

功能概述

服务定位器是一个小型静态注册表,只回答一个问题:"X 服务在哪里?"启动引导在启动时把 23 个框架服务逐一注册进来。你的代码在任何地方调用 ServiceLocator.Resolve<IMyService>() 就能拿回实例——不需要场景引用、不需要单例、不需要编译期接线。如果想替换某个实现(例如用你自己的存档系统替代默认实现),调用一次 Replace<T>() 即可;其他任何东西都不必改动。

快速示例

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]  // Bootstrap registers services first — class-level attribute
public class GameController : MonoBehaviour
{
    [SerializeField] private AudioClip _clickSound;

    // Cache in a field; never call Resolve in Update().
    private IAudioService _audio;
    private ISaveService _save;

    void Awake()
    {
        // Bootstrap has already called ServiceLocator.Register<T>() for each service.
        _audio = ServiceLocator.Resolve<IAudioService>();
        _save = ServiceLocator.Resolve<ISaveService>();
    }

    public void OnPlayButtonPressed()
    {
        _audio.PlaySfx(_clickSound);
    }
}

如果想在没有真实存档系统的情况下运行——为了测试,或想关闭该功能——换入内置的无操作实现:

// In a test setup, or anywhere before the code under test resolves it:
ServiceLocator.Replace<ISaveService>(new NullSaveService());

// New Resolve calls now return the replacement.
// Code that cached the old instance earlier keeps using that old instance —
// the locator never updates references you already hold.

完整 API 一览

所有方法都是 ServiceLocator 类上的静态方法。所有方法都仅限主线程(参见行为与边界情况)。

核心操作

  • void Register<T>(T instance) —— 以 T 为键添加一个新服务。如果 T 已注册过服务则抛出 ServiceAlreadyRegisteredException;如果 T 是具体类则抛出 ArgumentException(键必须是接口或抽象类)。用它注册你自己的游戏服务;框架自身的服务由 Bootstrap 注册。

  • T Resolve<T>() —— 获取已注册的服务。如果 T 没有注册任何服务则抛出 ServiceNotRegisteredException。每次调用都是一次字典查找——不要每帧调用;在 Awake/Start 中解析一次,把结果缓存到字段里。

  • void Replace<T>(T instance) —— 覆盖或创建一个注册项(已存在也不会报错)。它不会释放被替换掉的实例——它无法知道你是否仍持有引用。如果被换出的服务拥有内部 GameObject(时间、对象池、音频、UI 框架、调度器、补间与补间序列各自会创建一个 [CGS] … GameObject),需要你自行释放,否则该 GameObject 会在本次会话余下的时间里一直存活并持续运转:

    var previous = ServiceLocator.Resolve<ITimeService>() as System.IDisposable;
    ServiceLocator.Replace<ITimeService>(new NullTimeService());
    previous?.Dispose();   // after the swap, so nothing resolves a disposed instance
    
  • bool TryResolve<T>(out T service) —— 软查找;未注册时返回 false 并把 service 置为 null。不抛异常。用于可选服务。

  • bool IsRegistered<T>() —— 只检查是否存在,不获取实例。

  • void Unregister<T>() —— 移除注册项。未注册时调用也是安全的(调用两次没有问题)。

调试与测试辅助

  • void Reset() —— 清空所有注册项。仅在 Editor 和 Development 构建中可用;在 Release 构建中被编译剔除,因此无法在生产代码路径中调用。
  • IReadOnlyDictionary<Type, object> GetRegistrySnapshot() —— 所有已注册服务的只读副本。仅限 Editor。
  • int RegisteredServiceCount —— 已注册条目的实时数量。仅限 Editor。

异常

  • ServiceLocatorException —— 所有服务定位器错误的基类型;捕获它即可处理所有此类错误。
  • ServiceAlreadyRegisteredException —— Register 因键已存在而失败;暴露 .ServiceType 属性。
  • ServiceNotRegisteredException —— Resolve 失败;暴露 .ServiceType 以及一条列出最常见原因的消息。

缺失服务与可选查找

服务定位器本身没有无操作替代——注册表总是会初始化,在每次进入 Play 以及 Editor 程序集重载之后都是如此。如果某个服务缺失,Resolve<T>() 会立即抛出 ServiceNotRegisteredException。这是有意为之:你会在需要该服务的那一行代码处立刻发现问题,而不是稍后无声地出错。

如果想对可选服务做优雅处理,改用 TryResolve<T>

if (ServiceLocator.TryResolve<IAudioService>(out var audio))
    Debug.Log("Audio service is available");
else
    Debug.LogWarning("Audio service not registered");

行为与边界情况

  • 缓存你解析到的东西。 Resolve<T>() 每次都是一次字典查找。在 Update() 中调用它每帧都在浪费时间。在 AwakeStart 中解析一次,把引用保存在字段里。

  • 仅限主线程。 在 Debug 构建中,所有公共方法都会断言自己运行在 Unity 主线程上。从 Task.Run、线程池或任何后台线程调用会以"[ServiceLocator] Main thread only."失败。如果后台线程需要某个服务,先在主线程上解析它,再把引用传进去。

  • 键只能是接口或抽象类。 Register<ConcreteClass>(impl) 会抛出 ArgumentException。始终用接口或抽象类作为 T。这样每个服务都可以在调用方毫不知情的情况下被替换。

  • 不会遍历接口继承。 假如 ISaveService 派生自某个 IPersistenceService,而你只注册了 ISaveService,那么 Resolve<IPersistenceService>() 会抛出异常。注册表是一个精确类型的字典。要么把两个键都注册到同一个实例上,要么解析你注册时使用的确切键。

  • Replace 不会重定向你已经持有的引用。 Replace<T>() 之后,新的 Resolve<T>() 调用返回新实例,但缓存了旧实例的代码会继续使用旧实例。请在消费者解析之前替换实现——通常放在更早运行的 Awake 里,或测试的 setup 中。

  • 过早解析。 Bootstrap 在第一个场景加载前注册所有框架服务,因此场景代码总能使用框架服务。但如果你自己的代码在之后才注册额外的服务——例如在某个 MonoBehaviour 的 Awake 里——那么在那个时间点之前运行的代码还无法解析它们。ServiceNotRegisteredException 的消息会带你排查常见的顺序问题。

  • Domain Reload 与 IL2CPP。 无论 Editor 中是否启用 Domain Reload,注册表都会在每次进入 Play 时清空并重新初始化。如果你用 IL2CPP(Unity 的 AOT 编译后端)构建,通过服务调用传递的自定义数据类型需要在你自己的项目中添加 link.xml 条目,以免构建的代码裁剪把它们移除;框架自身的类型已经受到保护。

相关页面

  • 启动引导 —— 启动顺序与完整的 23 个服务列表
  • 日志 —— 第一个注册的服务,以及会话中途替换它的那个注意点
  • 存档/读档 —— 一个典型的"解析并缓存"消费者,外加它的 NullSaveService 关闭开关
  • 快速上手 —— 你的第一个"解析并缓存"脚本
  • 手册:故障排查 —— 诊断 ServiceNotRegisteredException