- 将 ShrinkEventBus、ShrinkDataSaver 及其 EventBus 集成从 gitlink 转为仓库直接维护的完整 UPM 包,补齐运行时、编辑器工具、测试与文档 - 新增 Command 和 Network 的 App 集成组件,支持 ContextLoader 服务发布、可逆注销及 Network Loopback 生命周期管理 - 更新 Starter 与演示组合逻辑,缺失模块时可注册、已有兼容安装器时可覆盖,并补充宿主启动断言 - 升级内部包依赖与 Shared CodeGen 包定义,放宽 Integration.App 包的 Git 忽略规则 - 将独立服务器生成器改为基于已编译程序集的语义扫描,支持 partial、复杂泛型、命名冲突检测及模板 SHA-256 覆写保护 - 新增 Network 语义扫描、模板保护和 App 组件生命周期测试 - 新增真实 UPM 消费工程验证脚本,校验内部版本一致性、程序集加载及 EditMode 测试 - 重构当前架构文档并归档已完成的 Cordis 迁移与旧代码地图
ShrinkDataSaver
一个为 Unity C# 项目设计的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。
✨ 特性概览
| 特性 | 说明 |
|---|---|
| 💾 模块化存档 | 按模块拆分存档数据,注册即用,读写隔离 |
| 🔢 多存档槽 | 槽位数量可配置,支持元数据轻量查询 |
| 🔄 链式版本迁移 | 注册迁移规则后自动链式执行,失败时整体回滚 |
| 🔒 可选加密 | AES-256-CBC + PBKDF2-SHA256,随机 Salt/IV,密钥由使用者管理 |
| ⚡ 事件驱动 | 8 种原生事件,覆盖保存/加载/删除/迁移的完整生命周期 |
| 🔍 跨模块查询 | QueryModule<T> 只读查询其他模块的运行时数据 |
| 🛡️ 关键模块保护 | CriticalModule 标记的模块序列化失败将中止整个保存操作 |
| ⏱️ 自动保存 | 按模块配置的最小间隔自动触发保存 |
| ⚙️ Settings 系统 | 独立于存档的键值对设置,防抖写入,本地持久化 |
| 🧷 最近游玩槽位 | 自动记录最近一次成功进入的槽位,可用于“继续游戏” |
| 🛠️ 双副本备份 | 主文件原子替换,自动保留 .bak1 / .bak2 双副本轮换 |
| ☁️ 云存档兼容 | 每槽单文件 .sav,模块级 EnableCloudSync 开关 |
| 🔗 EventBus 集成 | 可选接入 ShrinkEventBus,所有事件自动桥接到事件总线 |
| 🖥️ Editor 工具 | 中文可视化调试窗口,实时查看/操作设置与存档 |
📦 依赖
- Unity 2022.3+
- UniTask
2.x - Newtonsoft.Json(
com.unity.nuget.newtonsoft-json)
⚙️ 安装
在项目的 Packages/manifest.json 中添加:
{
"dependencies": {
"com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"com.cneicy.shrink-datasaver": "https://github.com/cneicy/ShrinkDataSaver.git"
}
}
或通过 Package Manager → + → Add package from git URL 输入:
https://github.com/cneicy/ShrinkDataSaver.git
🚀 快速上手
第一步:创建配置资产
菜单 Assets → Create → ShrinkDataSaver → Settings。
配置文件可放置在项目任意目录下,编辑器会通过 AssetDatabase 自动搜索。也可放在 Resources/ 下供运行时加载,或在 Bootstrap 组件上手动指定。
⚠️ 未找到配置文件时,控制台会输出警告并使用默认配置。
第二步:放置 Bootstrap
在首场景中创建 GameObject,挂载 ShrinkDataSaverBootstrap 组件:
- Settings Override:可选,手动拖入配置资产(留空则自动查找)
- Current Save Version:当前存档版本号(从
1开始)
Bootstrap 会自动 DontDestroyOnLoad,并在应用退出 / 移动端切后台时自动写入 Settings。
从当前版本开始,真正的初始化逻辑已下沉到 ShrinkDataSaverRuntime。这意味着:
- 旧项目继续挂
ShrinkDataSaverBootstrap也能正常工作 - 如果项目接入了
ShrinkApp,则可由宿主统一调用ShrinkDataSaverRuntime.Initialize(...) - 宿主已接管时,旧 bootstrap 会自动幂等退出,不重复初始化
第三步:注册存档模块
// 方式 A:Lambda(轻量)
ShrinkSave.RegisterModule(
key: "inventory",
serialize: () => inventoryManager.GetData(),
deserialize: data => inventoryManager.LoadData(data)
);
// 方式 B:Lambda + 模块配置
ShrinkSave.RegisterModule(
key: "quests",
serialize: () => questSystem.GetData(),
deserialize: data => questSystem.LoadData(data),
config: new ModuleConfig
{
EnableCloudSync = true,
AutoSaveIntervalSeconds = 60f, // 每 60 秒自动保存
CriticalModule = true // 序列化失败将中止保存
}
);
// 方式 C:接口(结构化)
public class InventoryModule : ISaveModule<InventoryData>
{
public string Key => "inventory";
public InventoryData Serialize() => inventoryManager.GetData();
public void Deserialize(InventoryData data) => inventoryManager.LoadData(data);
}
ShrinkSave.RegisterModule(new InventoryModule());
第四步:保存与加载
// 保存
await ShrinkSave.SaveSlotAsync(0, new SaveOptions
{
SlotName = "第一周目",
CaptureScreenshot = true
});
// 加载
await ShrinkSave.LoadSlotAsync(0);
// 删除
await ShrinkSave.DeleteSlotAsync(0);
📖 核心概念
事件系统
系统在关键操作时触发原生 C# 事件,所有事件携带完整载荷数据:
| 事件 | 触发时机 | 载荷 |
|---|---|---|
OnSaveStarted |
开始保存前 | SlotIndex, Timestamp |
OnSaveCompleted |
保存成功后 | SlotIndex, ModuleNames[], Timestamp |
OnSaveFailed |
保存失败时 | SlotIndex, ErrorMessage |
OnLoadStarted |
开始加载前 | SlotIndex, Timestamp |
OnLoadCompleted |
加载成功后 | SlotIndex, ModuleNames[], Version, Timestamp |
OnLoadFailed |
加载失败时 | SlotIndex, ErrorMessage |
OnMigrationCompleted |
版本迁移完成后 | SlotIndex, FromVersion, ToVersion |
OnDeleteCompleted |
删除存档后 | SlotIndex |
ShrinkSave.OnSaveCompleted += args =>
Debug.Log($"槽位 {args.SlotIndex} 保存成功,模块: {string.Join(", ", args.ModuleNames)}");
ShrinkSave.OnLoadFailed += args =>
ShowErrorDialog($"加载失败: {args.ErrorMessage}");
ShrinkSave.OnMigrationCompleted += args =>
Debug.Log($"存档从 v{args.FromVersion} 迁移到 v{args.ToVersion}");
模块配置
注册模块时可传入 ModuleConfig 控制行为:
var config = new ModuleConfig
{
EnableCloudSync = false, // 不参与云存档(如本地设置)
AutoSaveIntervalSeconds = 30f, // 自动保存间隔(0 = 禁用)
CriticalModule = true // 序列化失败中止整个保存
};
ShrinkSave.RegisterModule("settings", () => data, d => data = d, config);
- EnableCloudSync:标记该模块是否参与云同步(供业务层查询)
- AutoSaveIntervalSeconds:Bootstrap 会取所有模块中最小的非零间隔,定时自动保存到当前已加载的槽位
- CriticalModule:标记为关键模块后,序列化异常会触发
OnSaveFailed并中止保存;非关键模块异常仅跳过该模块
跨模块只读查询
模块间需要共享数据时,通过存档管理器提供的只读接口查询,避免直接耦合:
// 查询其他模块的当前运行时数据(序列化快照)
var playerStats = ShrinkSave.QueryModule<PlayerStatsData>("playerStats");
if (playerStats != null)
Debug.Log($"玩家等级: {playerStats.Level}");
// 检查已加载存档中某模块是否包含特定键
bool hasCoins = ShrinkSave.HasKey("inventory", "coins");
// 检查模块是否已注册
bool registered = ShrinkSave.HasModule("quests");
最近游玩槽位
包内会在 LoadSlotAsync(...) 成功后自动记录最近一次成功进入的槽位,并在删除该槽位时自动回退到其他有效槽位:
int recentSlot = ShrinkSave.GetRecentSlotIndex();
int continueSlot = await ShrinkSave.GetRecommendedContinueSlotAsync();
if (continueSlot >= 0)
{
await ShrinkSave.LoadSlotAsync(continueSlot);
}
版本迁移
每次存档结构变化时,注册迁移规则。必须在 LoadSlotAsync 调用之前注册。
// 注册迁移(在游戏初始化时)
MigrationChain.Register(fromVersion: 1, toVersion: 2, data =>
{
// data 是整个存档的 JObject,包含所有模块
if (data["inventory"] is JObject inv)
{
inv["gold"] = inv["coins"];
inv.Remove("coins");
}
return data;
});
MigrationChain.Register(fromVersion: 2, toVersion: 3, data =>
{
if (data["quests"] is JObject q)
q["dailyQuestReset"] = 0;
return data;
});
// 同步更新 Bootstrap 上的 Current Save Version = 3
ShrinkSave.SetCurrentSaveVersion(3);
加载时自动检测版本差异,链式执行所有中间迁移(v1 → v2 → v3)。迁移失败时自动回滚到迁移前的数据,不会丢失原始存档。
Settings 系统
独立于存档的键值对设置,本地持久化,不参与云存档:
// 读写
ShrinkSettings.Set("MasterVolume", 0.8f);
ShrinkSettings.Set("Language", "zh-CN");
float volume = ShrinkSettings.Get("MasterVolume", defaultValue: 1f);
bool exists = ShrinkSettings.Has("MasterVolume");
ShrinkSettings.Remove("MasterVolume");
// 查看所有设置
var all = ShrinkSettings.GetAllRaw(); // IReadOnlyDictionary<string, JToken>
// 监听变更
ShrinkSettings.OnChanged += (key, value) => Debug.Log($"{key} = {value}");
ShrinkSettings.Watch<float>("MasterVolume", vol => ApplyVolume(vol));
变更时立刻触发回调,写入磁盘有防抖延迟(默认 300ms),防止高频调用产生大量 IO。
加密
可选加密,默认关闭。使用 AES-256-CBC + PBKDF2-SHA256(10000 次迭代),每次加密随机生成 Salt 和 IV:
// 加密保存
await ShrinkSave.SaveSlotAsync(0, new SaveOptions
{
Encrypt = true,
EncryptionKey = "your-secret-key"
});
// 解密加载(需传入相同密钥)
await ShrinkSave.LoadSlotAsync(0, decryptionKey: "your-secret-key");
// 通过元数据判断是否加密
var meta = await ShrinkSave.GetMetaAsync(0);
if (meta.IsEncrypted)
ShowPasswordPrompt();
🔗 ShrinkEventBus 集成
项目中同时包含 ShrinkDataSaver.Integration.EventBus 时,桥接器会在游戏启动时自动初始化,将所有原生事件映射到 EventBus:
[EventBusSubscriber]
public class SaveUIManager : MonoBehaviour
{
[EventSubscribe(EventPriority.NORMAL)]
private void OnSaveCompleted(SaveCompletedEvent e)
{
ShowSaveIndicator(e.SlotIndex, e.ModuleNames);
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnLoadFailed(LoadFailedEvent e)
{
ShowErrorDialog(e.ErrorMessage);
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnSettingsChanged(SettingsChangedEvent e)
{
if (e.Key == "MasterVolume")
ApplyVolume(e.Get<float>());
}
}
EventBus 事件类型完整列表:
SettingsChangedEvent · SaveStartedEvent · SaveCompletedEvent · SaveFailedEvent · LoadStartedEvent · LoadCompletedEvent · LoadFailedEvent · MigrationCompletedEvent · SlotDeletedEvent
🖥️ Editor 调试工具
菜单 ShrinkSDK → 存档 → 数据查看器
| 标签页 | 功能 |
|---|---|
| 设置 | 运行时查看所有设置项(键、值、类型),支持新增/修改/删除,一键保存/加载 |
| 存档槽 | 查看所有槽位元数据,每个槽位可直接加载/删除,支持加密参数 |
| 工具 | 打开持久化数据目录,定位配置资源文件,一键删除所有存档 |
☁️ Steam Auto-Cloud 配置
每个逻辑存档槽默认包含一个主文件和最多两个轮换备份文件:.sav、.sav.bak1、.sav.bak2。
在 Steamworks 后台(App Admin → Steam Cloud)配置同步路径:
| 字段 | 值 |
|---|---|
| Root Path | {userdata} |
| Subdirectory | 指向 persistentDataPath 的相对路径 |
| File Pattern | saves/*.sav* |
通过 ModuleConfig.EnableCloudSync = false 可将特定模块(如本地设置)排除在云同步之外,供业务层在合并逻辑中过滤。
🔧 API 参考
ShrinkSave(静态门面)
模块注册
ShrinkSave.RegisterModule(ISaveModule module, ModuleConfig config = null)
ShrinkSave.RegisterModule<T>(string key, Func<T> serialize, Action<T> deserialize, ModuleConfig config = null)
ShrinkSave.UnregisterModule(string key)
ShrinkSave.HasModule(string moduleName) → bool
ShrinkSave.GetModuleConfig(string key) → ModuleConfig
ShrinkSave.GetRegisteredModuleNames() → IReadOnlyCollection<string>
存档操作
ShrinkSave.SaveSlotAsync(int slotIndex, SaveOptions, CancellationToken) → UniTask
ShrinkSave.LoadSlotAsync(int slotIndex, string decryptionKey, ct) → UniTask
ShrinkSave.DeleteSlotAsync(int slotIndex, CancellationToken) → UniTask
ShrinkSave.SlotExistsAsync(int slotIndex, CancellationToken) → UniTask<bool>
ShrinkSave.LoadedSlot → int (-1 = 未加载)
元数据查询
ShrinkSave.GetAllMetaAsync(CancellationToken) → UniTask<SaveMeta[]>
ShrinkSave.GetMetaAsync(int slotIndex, ct) → UniTask<SaveMeta>
ShrinkSave.GetRecentSlotIndex() → int
ShrinkSave.GetRecommendedContinueSlotAsync(ct) → UniTask<int>
跨模块查询
ShrinkSave.QueryModule<T>(string moduleName) → T
ShrinkSave.HasKey(string moduleName, string key) → bool
版本
ShrinkSave.SetCurrentSaveVersion(int version)
ShrinkSave.GetMinAutoSaveInterval() → float
MigrationChain.Register(int from, int to, Func<JObject, JObject>)
事件
ShrinkSave.OnSaveStarted += Action<SaveStartedEventArgs>
ShrinkSave.OnSaveCompleted += Action<SaveCompletedEventArgs>
ShrinkSave.OnSaveFailed += Action<SaveFailedEventArgs>
ShrinkSave.OnLoadStarted += Action<LoadStartedEventArgs>
ShrinkSave.OnLoadCompleted += Action<LoadCompletedEventArgs>
ShrinkSave.OnLoadFailed += Action<LoadFailedEventArgs>
ShrinkSave.OnMigrationCompleted += Action<MigrationCompletedEventArgs>
ShrinkSave.OnDeleteCompleted += Action<SlotDeletedEventArgs>
ShrinkSettings(静态门面)
ShrinkSettings.Set<T>(string key, T value)
ShrinkSettings.Get<T>(string key, T defaultValue = default) → T
ShrinkSettings.Has(string key) → bool
ShrinkSettings.Remove(string key)
ShrinkSettings.GetAllRaw() → IReadOnlyDictionary<string, JToken>
ShrinkSettings.Watch<T>(string key, Action<T> callback)
ShrinkSettings.Unwatch(string key, Action<object> callback)
ShrinkSettings.SaveAsync(CancellationToken) → UniTask
ShrinkSettings.LoadAsync(CancellationToken) → UniTask
ShrinkSettings.OnChanged += Action<string, object>
🏗️ 架构说明
ShrinkDataSaver/
├── Runtime/
│ ├── ShrinkSave 存档系统静态门面(保存/加载/删除/事件/查询)
│ ├── ShrinkSettings 设置系统静态门面(键值对/防抖写入/监听)
│ ├── ShrinkDataSaverBootstrap 兼容旧入口的初始化组件
│ ├── ShrinkDataSaverRuntime 可重入运行时初始化入口 + 生命周期驱动
│ ├── ShrinkDataSaverSettings ScriptableObject 全局配置(支持任意目录)
│ ├── SaveTypes 核心类型(SaveMeta/ISaveModule/ModuleConfig/EventArgs)
│ ├── MigrationChain 版本迁移链(注册/链式执行/回滚)
│ ├── DataSerializer JSON 序列化(Newtonsoft.Json)
│ ├── SaveEncryptor AES-256-CBC 加密/解密
│ ├── IStorageProvider 存储后端接口
│ └── LocalStorageProvider 本地文件系统实现(异步读写 + 原子替换 + 双副本轮换)
│
├── Editor/
│ └── ShrinkDataSaverEditorWindow 中文可视化调试窗口
│
└── Tests/
├── MockStorageProvider 内存模拟存储
└── *Tests.cs NUnit 单元测试(69 个用例)
ShrinkDataSaver.Integration.EventBus/ (可选)
├── DataSaverEvents 9 个 EventBus 事件类
└── DataSaverEventBusBridge 自动桥接(RuntimeInitializeOnLoadMethod)
保存流程:
SaveSlotAsync(slotIndex, options)
├─ ValidateSlotIndex
├─ OnSaveStarted ← 事件
├─ 序列化所有模块
│ ├─ CriticalModule 失败 → OnSaveFailed ← 事件,中止
│ └─ 普通模块失败 → LogError,跳过继续
├─ 加密(可选)
├─ 写入存储
└─ OnSaveCompleted ← 事件(含 ModuleNames[])
加载流程:
LoadSlotAsync(slotIndex, decryptionKey)
├─ OnLoadStarted ← 事件
├─ 读取存储 → 解密(可选)
├─ 版本迁移(如需)
│ ├─ DeepClone 备份
│ ├─ 链式执行迁移
│ ├─ 失败 → 回滚到备份
│ └─ OnMigrationCompleted ← 事件
├─ 反序列化到各模块
├─ 缓存模块数据(供 QueryModule/HasKey)
└─ OnLoadCompleted ← 事件(含 ModuleNames[], Version)
✅ 最佳实践
设置模块标记 EnableCloudSync = false
// ✅ 本地设置不上传云端,避免跨设备覆盖
ShrinkSave.RegisterModule("settings", () => localPrefs, d => localPrefs = d,
new ModuleConfig { EnableCloudSync = false });
关键模块标记 CriticalModule = true
// ✅ 玩家核心数据序列化失败时中止保存,防止存档损坏
ShrinkSave.RegisterModule("playerStats", () => stats, d => stats = d,
new ModuleConfig { CriticalModule = true });
迁移注册必须在加载之前
// ✅ 游戏启动时立即注册所有迁移
MigrationChain.Register(1, 2, MigrateV1ToV2);
MigrationChain.Register(2, 3, MigrateV2ToV3);
ShrinkSave.SetCurrentSaveVersion(3);
// 之后才能调用 LoadSlotAsync
加密存档先查询元数据
// ✅ 通过 Meta 判断是否需要密钥,避免盲目加载
var meta = await ShrinkSave.GetMetaAsync(slotIndex);
string key = meta?.IsEncrypted == true ? AskPlayerForPassword() : null;
await ShrinkSave.LoadSlotAsync(slotIndex, key);
⚠️ 注意事项
- 模块注册顺序无关:
SaveSlotAsync序列化所有已注册模块,LoadSlotAsync分发到对应模块,缺失的模块会跳过但不会导致加载失败。 - 宿主接管兼容:如果项目接入了
ShrinkApp,推荐通过ShrinkDataSaver.Integration.App让宿主统一初始化,而不是继续依赖场景 bootstrap。 - 写入原子性:
LocalStorageProvider先异步写入.tmp临时文件,再通过原子替换提交主文件,并自动轮换.bak1/.bak2两份备份。 - 读取恢复:
ShrinkSave与ShrinkSettings读取主文件失败时,会自动回退到.bak1、.bak2,并在成功后修复主文件。 - Settings 防抖:
Set()调用后不立刻写磁盘,在 300ms(可配置)内连续调用只触发一次写入。退出时强制跳过防抖直接写入。 - 加密密钥管理:框架不存储密钥。密钥丢失则对应存档无法解密,建议在 UI 层给玩家明确提示。
- 截图与 Steam Cloud:截图压缩为 JPG 并限制最大宽度(默认 256px),仍需注意模块数据体积。Steam Cloud 默认单文件限制 1MB。
- 配置文件查找顺序:
Resources.Load→ 编辑器AssetDatabase全局搜索 → 创建默认实例并输出控制台警告。 - 自动保存:仅在有槽位已加载(
LoadedSlot >= 0)且存在AutoSaveIntervalSeconds > 0的模块时生效。