feat(packages): 内置 SDK 包并完善 ContextLoader 集成

- 将 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 迁移与旧代码地图
This commit is contained in:
2026-08-18 18:06:34 +08:00
parent 517c4cf46e
commit d74c2f08ca
240 changed files with 13647 additions and 545 deletions
+538
View File
@@ -0,0 +1,538 @@
# 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](https://github.com/Cysharp/UniTask) `2.x`
- [Newtonsoft.Json](https://docs.unity3d.com/Packages/com.unity.nuget.newtonsoft-json@3.2/manual/index.html)`com.unity.nuget.newtonsoft-json`
## ⚙️ 安装
在项目的 `Packages/manifest.json` 中添加:
```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 会自动幂等退出,不重复初始化
### 第三步:注册存档模块
```csharp
// 方式 ALambda(轻量)
ShrinkSave.RegisterModule(
key: "inventory",
serialize: () => inventoryManager.GetData(),
deserialize: data => inventoryManager.LoadData(data)
);
// 方式 BLambda + 模块配置
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());
```
### 第四步:保存与加载
```csharp
// 保存
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 |
```csharp
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` 控制行为:
```csharp
var config = new ModuleConfig
{
EnableCloudSync = false, // 不参与云存档(如本地设置)
AutoSaveIntervalSeconds = 30f, // 自动保存间隔(0 = 禁用)
CriticalModule = true // 序列化失败中止整个保存
};
ShrinkSave.RegisterModule("settings", () => data, d => data = d, config);
```
- **EnableCloudSync**:标记该模块是否参与云同步(供业务层查询)
- **AutoSaveIntervalSeconds**Bootstrap 会取所有模块中最小的非零间隔,定时自动保存到当前已加载的槽位
- **CriticalModule**:标记为关键模块后,序列化异常会触发 `OnSaveFailed` 并中止保存;非关键模块异常仅跳过该模块
### 跨模块只读查询
模块间需要共享数据时,通过存档管理器提供的只读接口查询,避免直接耦合:
```csharp
// 查询其他模块的当前运行时数据(序列化快照)
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(...)` 成功后自动记录最近一次成功进入的槽位,并在删除该槽位时自动回退到其他有效槽位:
```csharp
int recentSlot = ShrinkSave.GetRecentSlotIndex();
int continueSlot = await ShrinkSave.GetRecommendedContinueSlotAsync();
if (continueSlot >= 0)
{
await ShrinkSave.LoadSlotAsync(continueSlot);
}
```
### 版本迁移
每次存档结构变化时,注册迁移规则。**必须在 `LoadSlotAsync` 调用之前注册。**
```csharp
// 注册迁移(在游戏初始化时)
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 系统
独立于存档的键值对设置,本地持久化,不参与云存档:
```csharp
// 读写
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-SHA25610000 次迭代),每次加密随机生成 Salt 和 IV:
```csharp
// 加密保存
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](https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus)
项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,桥接器会在游戏启动时自动初始化,将所有原生事件映射到 EventBus:
```csharp
[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(静态门面)
#### 模块注册
```csharp
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>
```
#### 存档操作
```csharp
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 = )
```
#### 元数据查询
```csharp
ShrinkSave.GetAllMetaAsync(CancellationToken) UniTask<SaveMeta[]>
ShrinkSave.GetMetaAsync(int slotIndex, ct) UniTask<SaveMeta>
ShrinkSave.GetRecentSlotIndex() int
ShrinkSave.GetRecommendedContinueSlotAsync(ct) UniTask<int>
```
#### 跨模块查询
```csharp
ShrinkSave.QueryModule<T>(string moduleName) T
ShrinkSave.HasKey(string moduleName, string key) bool
```
#### 版本
```csharp
ShrinkSave.SetCurrentSaveVersion(int version)
ShrinkSave.GetMinAutoSaveInterval() float
MigrationChain.Register(int from, int to, Func<JObject, JObject>)
```
#### 事件
```csharp
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(静态门面)
```csharp
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`**
```csharp
// ✅ 本地设置不上传云端,避免跨设备覆盖
ShrinkSave.RegisterModule("settings", () => localPrefs, d => localPrefs = d,
new ModuleConfig { EnableCloudSync = false });
```
**关键模块标记 `CriticalModule = true`**
```csharp
// ✅ 玩家核心数据序列化失败时中止保存,防止存档损坏
ShrinkSave.RegisterModule("playerStats", () => stats, d => stats = d,
new ModuleConfig { CriticalModule = true });
```
**迁移注册必须在加载之前**
```csharp
// ✅ 游戏启动时立即注册所有迁移
MigrationChain.Register(1, 2, MigrateV1ToV2);
MigrationChain.Register(2, 3, MigrateV2ToV3);
ShrinkSave.SetCurrentSaveVersion(3);
// 之后才能调用 LoadSlotAsync
```
**加密存档先查询元数据**
```csharp
// ✅ 通过 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` 的模块时生效。
---
## 📄 License
[MIT](LICENSE)