5. 核心概念
每个 CGS 服务背后的五个理念——引导、服务定位器、时钟、事件与空实现——外加术语表。
五个理念就能解释整个框架。掌握它们之后,23 个服务里的每一个都以同样的方式工作。本章用平实的语言逐一讲解,并配上可以直接粘进你项目的简短代码。章末的术语表定义了本手册用到的所有技术词汇。
5.1 引导:一切自行启动
Common Game System 没有需要拖进场景的预制体,没有管理器对象,也没有初始化调用。当你按下 Play 时,Unity 会在你的第一个场景加载之前自动运行框架的启动代码。这个启动步骤叫做引导(bootstrap)。它按正确顺序创建全部 23 个服务,并逐一注册,让你的代码可以找到它们。
按下 Play 后看看 Console 就能确认它是否成功。最后一行启动消息是:
bootstrap complete (v2.1.0, 23 services)
你永远不需要自己调用引导。它每个 Play 会话恰好运行一次,第二次调用会故意抛出错误。等到你场景中的任何 Awake() 运行时,所有服务都已就绪待命。完整参考:Bootstrap。
5.2 服务定位器:问一次,记住答案
服务是一个自成一体的功能——存档、音频、定时器、输入——通过单个 C# 接口访问。服务定位器是收录所有服务的电话簿。你按接口向它索取服务,它把正在运行的实例交给你:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)] // run after the framework has started
public class GameSetup : MonoBehaviour
{
private ITimeService _time;
private ISaveService _save;
private void Awake()
{
// Ask once, keep the answer.
_time = ServiceLocator.Resolve<ITimeService>();
_save = ServiceLocator.Resolve<ISaveService>();
}
}
两个习惯让这既快又安全:
- 只在
Awake()或Start()中解析一次,并把结果存进字段。 每次查找都是一次字典检索。每帧调用它只是白白浪费时间。 - 给在
Awake()中解析服务的类加上[DefaultExecutionOrder(100)]。 它让 Unity 在框架自身的对象之后运行你的脚本,确保所有服务都已就绪。
如果某个服务可能不存在——例如你移除了某个可选 Unity 包——改用 ServiceLocator.TryResolve<T>(out var service)。它会返回 false 而不是抛异常。完整参考:Service Locator。

Service Debugger 窗口(Tools > Common Game System > Service Debugger)在你运行游戏时列出每个已注册服务。如果你期望的服务没有出现在这里,多半是它对应的可选 Unity 包没有安装——见第 7.2 章。显示的数量可能大于 23——框架在 23 个公开服务之外还会注册几个内部辅助服务。
5.3 时钟:三种速度的时间
时钟是一条独立的时间流。框架运行着三条:Clock.Gameplay、Clock.UI 和 Clock.Background。每一条都可以单独暂停或减速,而不影响另外两条。这解决了一个经典 bug:你暂停了游戏,结果菜单动画也跟着冻住,音乐也停了。
- Gameplay —— 你的游戏世界:角色、物理反应、冷却时间。暂停菜单停的就是这条时钟。
- UI —— 菜单和覆盖层。游戏玩法暂停时它继续运行,按钮因此仍有动画。
- Background —— 音乐以及任何永远不该停下的东西。
一个暂停菜单只需两行:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)]
public class PauseMenu : MonoBehaviour
{
private ITimeService _time;
private void Awake() => _time = ServiceLocator.Resolve<ITimeService>();
public void Open() => _time.Pause(Clock.Gameplay); // world freezes
public void Close() => _time.Resume(Clock.Gameplay); // world resumes
}
用 _time.DeltaTime(Clock.Gameplay) 而不是 Unity 的 Time.deltaTime 来按时钟读取时间。慢动作只需一次调用:_time.SetTimeScale(Clock.Gameplay, 0.3f) —— UI 和音乐保持正常速度。调度器的定时器和补间服务的动画各自绑定到某条时钟,因此无需额外代码就能正确地暂停和减速。唯一的规则:不要自己设置 Unity 的全局 Time.timeScale,那会与按时钟计时的系统相互打架。完整参考:Time。
5.4 事件:订阅、保管句柄、释放它
事件总线让各个系统在互不知晓的情况下交流。一个脚本发布一个事件对象;该事件类型的所有订阅者会按订阅顺序被立即调用。订阅会返回一个小小的句柄。释放(Dispose)这个句柄即取消订阅。忘记释放会让你的处理器永远保持注册——那就是内存泄漏。
using System;
using CommonGameSystem.Core;
using UnityEngine;
// Events are plain classes you define yourself.
public class ScoreChanged
{
public int NewScore;
}
[DefaultExecutionOrder(100)]
public class ScoreLabel : MonoBehaviour
{
private IEventBus _events;
private IDisposable _subscription;
private void Awake() => _events = ServiceLocator.Resolve<IEventBus>();
private void OnEnable() => _subscription = _events.Subscribe<ScoreChanged>(OnScore);
private void OnDisable() => _subscription?.Dispose(); // always unsubscribe
private void OnScore(ScoreChanged e) => Debug.Log($"Score: {e.NewScore}");
}
在任何地方发布都只是一行:_events.Publish(new ScoreChanged { NewScore = 100 });。有两个细节值得了解:事件类型必须是类(不能是结构体);某个订阅者抛出异常绝不会阻塞其他订阅者——错误会被记录,投递继续进行。完整参考:Event Bus。
5.5 空实现:每个服务都有开关
每个服务都附带一个配套的空实现(Null implementation)——接受所有调用但什么都不做的版本。注册它就能关掉对应的子系统,而不必改动任何一个调用方。让全项目所有音频静音只需一行:
ServiceLocator.Replace<IAudioService>(new NullAudio());
你项目里的每个 PlayMusic 和 PlaySfx 调用仍然能编译、仍然能运行——只是什么都不做。这对测试、无头服务器、录音会话,或用你自己的方案替换某个子系统都很有用。第 7.4 章列出了全部 23 个服务的开关,以及在会话中途替换某些服务时应额外加上的一行清理代码。
5.6 术语表
| 术语 | 含义 |
|---|---|
| 服务(Service) | 一个自成一体的框架功能(存档、音频、定时器),通过单个 C# 接口使用。 |
| 解析(Resolve) | 按接口向服务定位器索取服务。只做一次,并把引用保存在字段里。 |
| 引导(Bootstrap) | 在你的第一个场景加载之前创建并注册全部 23 个服务的自动启动步骤。你永远不需要调用它。 |
| 时钟(Clock) | 三条独立时间流(Gameplay、UI、Background)之一。每条都可以单独暂停或减速。 |
| 补间(Tween) | 一段短动画,在设定的时长内把一个值从起点平滑地移动到终点,形状由缓动曲线决定。 |
| 动作映射(Action map) | Unity Input System 资产内一组命名的输入动作(如 “Gameplay” 或 “Menu”)。只有已启用的映射才会响应玩家。 |
| Addressables | Unity 的可选包,用于通过文本地址而非直接引用来按需加载资产。 |
| IL2CPP | Unity 把 C# 转换为原生代码的构建模式。它会移除看起来未被使用的类,这正是你的存档类需要 link.xml 条目的原因(第 6.3 章)。 |
下一章:6. 设置步骤图解