cneicy d26f1a94ef
Publish UPM package / publish (push) Successful in 2s
Publish NuGet packages / publish (push) Successful in 31s
chore: exclude .NET metadata from UPM packages
2026-09-05 04:06:49 +08:00

ShrinkDataSaver

一个可用于 Unity、Godot 和普通 .NET 宿主的模块化存档与设置管理系统。支持多存档槽、链式版本迁移、可选 AES-256 加密、关键模块保护、跨模块只读查询,以及完整的事件驱动架构。

Godot 和普通 .NET 项目安装 ShrinkSDK.DataSaver;可打包源码位于 DotNet~,持久化路径与生命周期由宿主平台服务注入。

特性概览

特性 说明
💾 模块化存档 按模块拆分存档数据,注册即用,读写隔离
🔢 多存档槽 槽位数量可配置,支持元数据轻量查询
🔄 链式版本迁移 注册迁移规则后自动链式执行,失败时整体回滚
🔒 可选加密 AES-256-CBC + PBKDF2-SHA256,随机 Salt/IV,密钥由使用者管理
事件驱动 8 种原生事件,覆盖保存/加载/删除/迁移的完整生命周期
🔍 跨模块查询 QueryModule<T> 只读查询其他模块的运行时数据
🛡️ 关键模块保护 CriticalModule 标记的模块序列化失败将中止整个保存操作
⏱️ 自动保存 按模块配置的最小间隔自动触发保存
⚙️ Settings 系统 独立于存档的键值对设置,防抖写入,本地持久化
🧷 最近游玩槽位 自动记录最近一次成功进入的槽位,可用于“继续游戏”
🛠️ 双副本备份 主文件原子替换,自动保留 .bak1 / .bak2 双副本轮换
☁️ 云存档兼容 每槽单文件 .sav,模块级 EnableCloudSync 开关
🔗 EventBus 集成 可选接入 ShrinkEventBus,所有事件自动桥接到事件总线
🖥️ Editor 工具 中文可视化调试窗口,实时查看/操作设置与存档

📦 依赖

⚙️ 安装

在项目的 Packages/manifest.json 中添加:

{
  "dependencies": {
    "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
    "com.cneicy.shrink-datasaver": "https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git"
  }
}

或通过 Package Manager → +Add package from git URL 输入:

https://git.crash.work/ShrinkSDK/ShrinkDataSaver.git

🚀 快速上手

第一步:创建配置资产

菜单 ShrinkSDK存档创建设置

菜单会创建 Assets/Resources/GameAssets/Runtime/Data/ShrinkSDK/ShrinkDataSaverSettings.asset。 运行时优先从该路径加载,并保留旧的 Assets/Resources/ShrinkDataSaverSettings.asset 回退;也可以在 Bootstrap 组件上手动指定。

⚠️ 未找到配置文件时,控制台会输出警告并使用默认配置。

第二步:放置 Bootstrap

在首场景中创建 GameObject,挂载 ShrinkDataSaverBootstrap 组件:

  • Settings Override:可选,手动拖入配置资产(留空则自动查找)
  • Current Save Version:当前存档版本号(从 1 开始)

Bootstrap 会自动 DontDestroyOnLoad,并在应用退出 / 移动端切后台时自动写入 Settings。

从当前版本开始,真正的初始化逻辑已下沉到 ShrinkDataSaverRuntime。这意味着:

  • 旧项目继续挂 ShrinkDataSaverBootstrap 也能正常工作
  • 如果项目接入了 ShrinkApp,则可由宿主统一调用 ShrinkDataSaverRuntime.Initialize(...)
  • 宿主已接管时,旧 bootstrap 会自动幂等退出,不重复初始化

第三步:注册存档模块

// 方式 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());

第四步:保存与加载

// 保存
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-SHA25610000 次迭代),每次加密随机生成 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

项目中同时包含 ShrinkDataSaver.Integration.EventBus 时,由 Context 组件或宿主显式管理桥接生命周期,将原生事件发布到指定的 ShrinkEventBus 2.0 Bus

[ShrinkEventSubscriber(DefaultBus = "game")]
public sealed partial class SaveUIManager : MonoBehaviour
{
    [ShrinkSubscribe]
    private void OnSaveCompleted(SaveCompletedEvent e)
    {
        ShowSaveIndicator(e.SlotIndex, e.ModuleNames);
    }

    [ShrinkSubscribe]
    private void OnLoadFailed(LoadFailedEvent e)
    {
        ShowErrorDialog(e.ErrorMessage);
    }

    [ShrinkSubscribe]
    private void OnSettingsChanged(SettingsChangedEvent e)
    {
        if (e.Key == "MasterVolume")
            ApplyVolume(e.Get<float>());
    }
}

普通 C# 对象通过 EventBus.Attach(target) 接入;MonoBehaviour 可使用 ShrinkMonoEventScope,也可在 OnEnable/OnDisable 中自行持有并释放 binding。桥接器本身由 ShrinkDataSaverEventBusComponentDataSaverEventBusBridge.Register()/Unregister() 管理。

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 两份备份。
  • 读取恢复ShrinkSaveShrinkSettings 读取主文件失败时,会自动回退到 .bak1.bak2,并在成功后修复主文件。
  • Settings 防抖Set() 调用后不立刻写磁盘,在 300ms(可配置)内连续调用只触发一次写入。退出时强制跳过防抖直接写入。
  • 加密密钥管理:框架不存储密钥。密钥丢失则对应存档无法解密,建议在 UI 层给玩家明确提示。
  • 截图与 Steam Cloud:截图压缩为 JPG 并限制最大宽度(默认 256px),仍需注意模块数据体积。Steam Cloud 默认单文件限制 1MB。
  • 配置文件查找顺序Resources.Load → 编辑器 AssetDatabase 全局搜索 → 创建默认实例并输出控制台警告。
  • 自动保存:仅在有槽位已加载(LoadedSlot >= 0)且存在 AutoSaveIntervalSeconds > 0 的模块时生效。

📄 License

MIT

S
Description
ShrinkSDK Unity UPM package.
Readme
104 KiB
Languages
C# 100%