7. 让框架适配你的项目

七个程序集、移除可选包、用自己的实现替换服务、关闭子系统,以及文件在磁盘上的位置。

这个框架生来就是可以重塑的。每个服务都可以换成你自己的实现,或整个关掉;当你不想要某些可选部分时,它们会干净地自行移除。本章逐一展示这些调节手段,外加框架在磁盘上存放文件的位置。

7.1 七个程序集,以及该引用哪些

运行时代码被拆分为七个程序集。拆分的目的是让可选 Unity 包保持可选:每个附加程序集只在其对应的包已安装时才参与编译。它们共享同一个命名空间 CommonGameSystem.Core,因此你的 using 语句永远不变。

如果你的脚本位于默认的 Assembly-CSharp(你没有创建任何程序集定义文件),可以跳过本节——Unity 会自动为你引用一切。

如果你的代码使用自己的程序集定义(.asmdef),按你所使用的内容添加引用:

程序集里面有什么何时引用它……
CommonGameSystem.Core23 个服务中的 18 个:日志、事件、时间、存档、设置、对象池、音频、场景流、成就、定时器、补间、补间序列、随机数、状态机、状态栈、延迟事件队列、命令注册表,以及 UI 面板栈契约。始终引用。它是核心,而且完全不需要任何 Unity 包。
CommonGameSystem.Input输入服务:动作映射上下文与重绑定。你的代码使用 IInputService。需要 Input System 包。
CommonGameSystem.UI面板栈的 uGUI 实现。你的代码构造 PanelStack 或使用它的 uGUI 辅助类。仅解析 IPanelStack 的话只需要 Core。
CommonGameSystem.Localization本地化服务及其文本绑定组件。你的代码使用 ILocalizationService。需要 uGUI。
CommonGameSystem.Assets资产提供器(按地址加载、引用计数)。你的代码使用 IAssetProvider。需要 Addressables。
CommonGameSystem.AddressableScene来自 Addressables 的叠加场景加载。你的代码使用 IAddressableSceneService。需要 Addressables。
CommonGameSystem.Bootstrap自动启动序列。永远不需要。里面没有任何供你调用的东西。

把框架引用添加到你自己的程序集定义:

  1. 在 Project 窗口中选中你的 .asmdef 文件。
  2. 在 Inspector 中找到 Assembly Definition References,点击 +
  3. 选择 CommonGameSystem.Core,再加上你用到的附加程序集(例如 CommonGameSystem.Input)。
  4. 点击 Inspector 底部的 Apply

7.2 移除某个可选 Unity 包

有三个 Unity 包是可选的。移除任何一个都不会弄坏框架:需要它的程序集会把自己排除在编译之外,其余一切照常工作。启动流程仍会正常完成。

你移除的包什么被关掉什么仍然工作
Input System<br>com.unity.inputsystem输入服务消失(它的类型不再参与编译)。UI 面板栈退回到空操作版本,因为焦点导航需要输入。其他一切。剩下的每个服务都正常运行;面板栈的调用只是什么都不做。
uGUI<br>com.unity.uguiuGUI 面板栈退回到空操作版本。本地化服务消失。演示场景的脚本也会把自己排除在外。其他一切,包括输入。
Addressables<br>com.unity.addressables资产提供器和可寻址场景服务消失。其他一切,包括常规场景服务。

两点提醒。第一,你自己的脚本中凡是提到已移除服务的地方将无法编译——请把那些引用一并移除,或者用 ServiceLocator.TryResolve<T>(...) 检查可用性,并把相关代码放在你自己的脚本宏定义之后。第二,重新安装该包会让一切原样回归,无需任何额外设置。

7.3 用你自己的实现替换服务

每个服务都只通过它的接口被使用,所以你可以换上自己的版本,任何调用方都不会察觉。模式永远是同样的三行:拿到旧实例,用 Replace 注册你的实例,然后释放旧实例。

下面是一个完整的真实例子。存档服务接受一个可插拔的序列化器——负责把你的数据转成文本的那个部件。这个自定义序列化器把存档存成 Base64 而不是可读的 JSON,玩家就没法随手编辑它们了:

using System;
using CommonGameSystem.Core;
using UnityEngine;

// The framework's serializer seam has just two methods.
public sealed class Base64SaveSerializer : ISaveSerializer
{
    public string Serialize(object o)
    {
        string json = JsonUtility.ToJson(o);
        byte[] bytes = System.Text.Encoding.UTF8.GetBytes(json);
        return Convert.ToBase64String(bytes);
    }

    public object Deserialize(Type t, string s)
    {
        byte[] bytes = Convert.FromBase64String(s);
        string json = System.Text.Encoding.UTF8.GetString(bytes);
        return JsonUtility.FromJson(json, t);
    }
}

替换脚本放在你的第一个场景里:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(-100)] // swap before other scripts cache the save service
public sealed class SaveSetup : MonoBehaviour
{
    private void Awake()
    {
        var previous = ServiceLocator.Resolve<ISaveService>() as System.IDisposable;
        ServiceLocator.Replace<ISaveService>(
            new SaveService(new Base64SaveSerializer()));
        previous?.Dispose();
    }
}

同样的模式适用于任何服务:写一个实现该接口的类(或者像第 6.1 和 6.2 章那样,用不同的选项构建框架自带的类),然后 Replace 它。三条规则让替换干净利落:

  • 尽早替换。 脚本会保留它们解析到的引用,因此要在你的第一个场景、以执行顺序 -100 替换服务,赶在任何东西缓存旧实例之前。少数框架服务也会在启动时相互接线——例如设置持久化和成就会一直持有它们启动时拿到的存档服务——因此过晚的替换只影响之后才解析的代码。
  • 释放被你顶替的实例。 Replace 不会销毁旧实例。有七个服务(时间、对象池、音频、UI 面板栈、调度器、补间、补间序列)各自在层级中拥有一个名为 “[CGS] ...” 的隐藏辅助对象。释放旧实例会移除它;跳过释放则它会运行一整个会话。
  • 先替换,后释放,顺序绝不能反过来,这样就不会有脚本在中间的空档解析到一个已死的实例。

7.4 关闭某个子系统

要彻底关掉一项功能,用它内置的空操作版本替换它。所有调用方继续工作;调用只是什么都不做。读取操作返回安全的默认值(零、false、空,或“未找到”)。

ServiceLocator.Replace<IAudioService>(new NullAudio()); // total silence, no code changes

每个服务都有一个:

服务(接口)空操作类
ILoggerNullLogger
IObjectPoolServiceNullObjectPool
ITimeServiceNullTimeService
IEventBusNullEventBus
ISaveServiceNullSaveService
IConfigurationNullConfiguration
IInputServiceNullInputService
IAudioServiceNullAudio
IPanelStackNullPanelStack
ISceneServiceNullSceneService
ILocalizationServiceNullLocalization
ISchedulerNullScheduler
ITweenServiceNullTweenService
IAssetProviderNullAssetProvider
IRandomServiceNullRandomService
IStateMachineServiceNullStateMachineService
IPushdownStackServiceNullPushdownStackService
ITweenSequenceServiceNullTweenSequenceService
IAddressableSceneServiceNullAddressableSceneService
IDeferredBusNullDeferredBus
ICommandRegistryNullCommandRegistry
IAchievementServiceNullAchievementService

7.3 节的释放规则同样适用于这里:当你关掉那七个拥有 “[CGS] ...” 辅助对象的服务之一时,请释放被你顶替的实例。

7.5 你的文件在磁盘上的位置

框架写入的所有内容都放在 Unity 标准的按游戏区分的数据文件夹 Application.persistentDataPath 之下:

  • Windows: C:\Users\<you>\AppData\LocalLow\<Company>\<Product>
  • macOS: ~/Library/Application Support/<Company>/<Product>
  • Linux: ~/.config/unity3d/<Company>/<Product>

其内部:

  • 存档 —— saves/<slot>.json,每个槽位一个文件,外加上一版本的 .bak 备份。写入期间,服务会先写一个临时文件再原子地替换进去,因此崩溃或断电永远不会损坏已有存档。
  • 设置 —— 同一 saves/ 文件夹中以 config_ 开头的文件(例如 config_AudioSettings.json)。如果你关掉了存档服务,设置会自动退回到 Unity 的 PlayerPrefs。
  • 成就 —— 通过存档服务存储在同一文件夹中。
  • 输入重绑定 —— 存储在 Unity 的 PlayerPrefs 中(Windows 上是注册表;macOS 上是 plist 文件)。

公司名和产品名来自 Edit > Project Settings > Player。同步文件的云存档系统(如 Steam Auto-Cloud)直接指向 saves/ 文件夹即可。


下一章:8. 疑难解答与常见问题