# 观前提醒

项目会在 GitHub 中开源,链接:https://github.com/Maikire/Unity/tree/main/UnityFramework/SaveData

代码较多,所以文章中不放源码

# 存档框架

游戏存档涉及两个相互独立的结果:文件是否已经提交、业务对象是否已经恢复

常见问题包括:资源尚未就绪、多个模块相互依赖、旧字段兼容、版本迁移、坏档、恢复存档、平台持久化差异、压缩、加密

SaveData 将游戏运行时状态转换成独立模块快照,统一处理多栏位读写、默认值、备份、版本迁移、恢复存档、失败回退。业务模块决定保存哪些字段、怎样校验、怎样恢复。项目流程决定何时保存、加载、切换场景、结束会话

# 模块划分与依赖方向

img

目录关键类型职责
SaveDataFramework/CoreManager、Options、Participant、Result、OperationGate会话生命周期、队列、采集、恢复
SaveDataFramework/SchemaDocument、Schema、MigrationPipeline文档模块、版本迁移
SaveDataFramework/SerializationJson、FileSerializer、SerializerFactoryDTO 策略、编码
SaveDataFramework/CompressionNoCompression、MessagePackLz4Compression压缩
SaveDataFramework/EncryptionNoEncryption、Xor、Aes256Gcm、KeyProvider加密
SaveDataFramework/StorageFile、Server、SynchronizedFile持久化、备份、修复
SaveDataFramework/UnitySettings、Bridge、TimeService、Web 插件Unity 路径、设置、平台行为
EditorSettingsWindow、ScriptReferenceEditor、ImplementationLinker编辑器配置
Integrations/Example运行示例运行示例

依赖插件: NewtonsoftJson 、 MessagePack 、 BouncyCastle

# 数据

# 运行时、快照、文档、文件

层级内容持有和修改
运行时玩家、属性系统、背包等可变状态业务逻辑
模块快照DTO 或 JTokenParticipant 采集,Manager 克隆和校验
逻辑文档所有模块、版本、元信息、扩展字段Manager、Schema、迁移管线
文件字节编码、压缩、封装、加密结果Serializer 与 Storage

# SaveDataDocument

默认文档示例

{
  "schemaId": "mygame",
  "version": 1,
  "savedAtUtc": "2026-10-14T02:00:00Z",
  "modules": {
    "progress": {
      "coins": 100,
      "checkpointId": "village"
    }
  },
  "metadata": {},
  "futureRootField": {
    "enabled": true
  }
}
成员类型语义
SchemaIdstring项目数据 ID
Versionint业务结构版本
SavedAtUtcDateTime最近保存时间,设置为 UTC
ModulesDictionary<string, JToken>模块 ID,快照
MetadataJObject模块或项目辅助信息
ExtensionsIDictionary<string, JToken>JsonExtensionData 接收未知根字段

schemaId、version、savedAtUtc、modules、metadata、$saveDataEnvelope 及其大小写变体均为保留字段,不允许扩展字段覆盖。未知根字段保留

# Participant 契约

ISaveDataParticipant 负责 ID、恢复顺序、依赖、就绪、采集、默认值、恢复前校验、恢复。通过 SaveDataParticipant<T> 实现业务方法

EnsureReady() 只检查运行时是否已经准备好, WaitUntilReadyAsync() 只等待项目准备流程。创建角色、初始化资源等属于项目生命周期,不能把这些放到恢复前校验
ValidateData() 可验证 DTO 中 ID 是否存在
RestoreData() 应完整覆盖模块持久化字段,包括清空旧列表和重置缺省值
ValidateCaptureData() 默认复用 ValidateData() ,项目可区分保存与恢复的前置条件

# 调度

# 状态转换

img

# 异步执行

ExecuteAsync 是核心的异步执行方法,流程摘要如下

创建本次请求独有的 result
检查会话、线程、同步上下文、回调重入、栏位与后端
pendingOperations++
等待 FIFO 操作门
再次检查线程和 Faulted
设置 Saving 或 Loading
按需检查备份能力、排序模块、等待就绪
检查调用方取消
执行本次操作主体
失败时记录异常,必要时 Faulted
取得操作门的请求在 finally 中:
    恢复 Ready 或保持 Faulted
    更新 LastResult
    通知 Completed
    释放操作门
清理已计入的 pendingOperations

# 操作门

SaveDataOperationGate 管理先进先出操作门,这是 Manager 内部的请求串行机制

操作门由一个 lock、等待链表、授予队列、 TaskCompletionSource<bool> 实现。空闲时立即取得门,忙碌时加入链表末尾。排队取消通过节点移除,已获得门的请求则由自身 finally 释放
当前没有设置 RunContinuationsAsynchronously,允许当前线程或调用方同步上下文继续执行。代码中使用循环通知来避免可能会越来越深的同步调用栈

# 防止重入 Manager

Call() 与 CallAsync() 通过 AsyncLocal<bool> 在同步和异步调用链中标记线程。异步就绪回调未结束时标记仍有效,可以防止回调再发起 Manager 操作并等待自己占用的队列

# 通知

通知发生在释放门之前,因此通知内不能再操作 Manager。项目要触发下一次保存,应在外层等待本次请求返回后再执行

# 模块排序

某些存档模块必须在其他模块完成准备或恢复之后才能执行,例如背包模块需要先恢复物品数据库,任务模块可能需要先恢复背包

OrderParticipants() 检查模块注册情况、初始化排序数据、按优先级遍历、返回最终顺序
visiting 递归处理模块依赖、检测循环依赖、生成符合依赖关系的顺序

# 就绪期限

同步入口按顺序调用 EnsureReady() 。异步入口使用一个与调用方 token 链接的就绪取消源,并为全部模块启动一个共同期限,逐个 await WaitUntilReadyAsync()

情况结果
调用方取消传播 OperationCanceledException ,归类为 Cancelled
期限到达且模块响应取消转为 TimeoutException ,归类为 TimedOut
TimeService 失败取消模块等待并传播计时失败
模块忽略取消仍等待模块任务结束,继续占用队列
ReadinessTimeout 非正值不启动期限计时,仅保留调用方取消

finally 会取消并等待计时任务,清理本次计时生命周期

默认系统计时使用 SystemSaveDataTimeService 。Web 使用 UnityMainLoopSaveDataTimeService.cs 和 SaveDataMainLoopTimer.cs,使用 Time.realtimeSinceStartupAsDouble ,在 Update 中检查期限

# 线程、取消、会话结束

其他线程在 Unity 主线程初始化,保持 EnforceThreadAffinity = true ,从该线程上的同步上下文调用并 await 异步 API

异步请求进入统一串行队列。一个请求未结束时,可以排队下一项异步操作,但不能初始化新会话、重置会话、注册或注销模块。同步入口只接受空闲的 ISynchronousSaveDataStorage 后端,不能用来阻塞等待服务器或 Web 持久化确认

场景切换或退出会话时,由持有会话的流程执行以下步骤

  1. 停止接收新请求
  2. 取消当前会话使用的 CancellationTokenSource
  3. 等待所有已经发起的请求结束,包括排队请求和提交后的持久化确认
  4. 注销需要移除的模块,或直接 ResetSession () 结束整个会话
  5. 销毁模块运行时、卸载场景、释放取消源

# 保存

# 保存流程

img

已有栏位会先完成读取、迁移、文档校验。错误 Schema、未来版本、损坏文件、错误密钥会使保存失败,框架不会用当前运行时直接覆盖它
不存在的栏位使用模块创建的当前版本空文档。新栏位数量通过 GetSaveNames 与 MaxSlots 检查,已有栏位覆盖不受新增数量限制
每个模块的快照结果先检查深度,再按模块合并策略替换或合并。可选 Metadata 写入 document.Metadata[ParticipantId] 。新文档更新时间信息并经过全局校验后,Storage 才开始写入

标记存档栏位的 CurrentSaveName 描述 Manager 最近成功的操作,只有在 Storage 成功返回后才切换

# 旧档、新档

顺序固定为 备份旧档 -> 编码新档 -> 提交新档。业务采集和文档校验早已完成,回调只访问独立文档和编码服务
若编码失败,旧正式档不会被覆盖,备份可能已经成功更新为旧正式档。不能将这类失败描述成 所有相关文件完全没变,只能说 旧正式数据仍然可用
自定义 Storage 应保持同样顺序,并定义最终提交后的取消和确认语义。直接忽略该回调契约,会改变框架对备份与错误处理的预期

# 加载

# 解码文档

ReadCore 从后端取得完整字节

  1. 检查字节非空且不超过 MaxBytes
  2. 文件编码器解密、解析内容、解压、内部解码,得到 JObject
  3. Schema.Decode 将根对象转换为文档,项目可在这里识别旧根结构
  4. 迁移管线逐级升级
  5. Manager 进行通用和项目校验

# 恢复前检查

LoadCore 对排序后的每个已注册模块选择目标:文档有模块则深克隆,没有则调用模块 CreateDefault。目标放入独立数组,也写回本次内存文档,以便进行最终文档校验
所有 ValidateRestore(target) 均在提交前执行,再校验完整文档,最后检查调用方取消。无论文档来自文件还是缺档默认分支,都经过同一恢复计划流程
Schema 的 RequiredParticipants 检查注册集合。当前的策略是在缺模块时默认恢复,需要拒绝某些文件缺模块的项目,可在 Schema.Validate 中显式检查

# 恢复

img

提交前通过所有模块快照建立 previous[] 。如果采集旧快照失败,提交尚未开始,业务状态不会被框架恢复流程修改
提交阶段从前到后调用 Restore。失败后,从抛出异常的那个模块开始反向回退,包含失败模块,因为它可能在抛出前已经部分修改状态。尚未执行 Restore 的模块无需回退
原始恢复异常和回退异常聚合到 SaveDataRestoreException 。只有原始异常时, RuntimeStateConsistent 为 true,增加任意回退异常时为 false,调度器将会话设置为 Faulted
这个机制是对遵守快照契约的业务模块进行补偿恢复。Restore 中发出的网络请求、消耗外部资源、不可撤销的副作用不受旧 DTO 自动回退

# 提交与取消的边界

ApplyRestore 是同步提交阶段,开始前检查 token,逐模块恢复期间不再次取消,避免因半途取消主动留下一组部分恢复的对象。Restore 抛出时仍执行完整回退
成功后切换当前栏位,并调用可选 OnRestored(!loaded) 。 !loaded 表示整份文件缺失走默认分支,这不代表每个模块都使用了默认值

# 兼容

层级策略
未注册模块保存从目标栏位文档开始,保持原模块
未知根字段Document.Extensions 接收并重新输出
已注册模块内的未知字段依据 Participant 的快照合并策略

MergeSnapshot(previous, current, typeof(T)) 保留旧 JObject 中当前未提供的顶层字段,当前提供的对象、字典状对象或数组字段整体替换

例如旧存档

{
    "coins": 10,
    "stats": {
        "level": 2,
        "futureStat": 7
    },
    "inventory": {
        "potion": 3,
        "oldSword": 1
    },
    "futureFlag": true
}

当前 DTO 若将 stats 声明为只认识 level 的普通对象,将 inventory 声明为字典,本次采集得到 coins = 20 、 level = 3 、 potion = 2 ,则合并结果如下

{
    "coins": 20,
    "stats": {
        "level": 3,
        "futureStat": 7
    },
    "inventory": {
        "potion": 2
    },
    "futureFlag": true
}

oldSword 会消失, futureStat 与 futureFlag 作为未知对象字段保留
如果类型只是 object ,则合并会整体替换当前提供的 stats ,不会保留其内部 futureStat

需要不同保留策略时,覆盖 MergeSnapshot(previous, current) ,例如返回 current.DeepClone() 实现模块快照整体替换

# Schema 和 相邻版本迁移

默认 Schema 要求非空 ID、当前版本至少为 1
迁移步骤通过 FromVersion 索引。注册要求源版本非负并且低于当前版本

while (文档版本 < 当前版本)
    查找该版本的迁移步骤,否则失败
    Migrate(document)
    检查步骤没有自行改变 Version 或 SchemaId
    Version 增加 1
执行当前 Schema.Validate 验证

当前版本为 3 的 Schema 可以通过 0 -> 1、1 -> 2、2 -> 3 接受历史版本。默认 Schema 不会识别缺少版本号的旧结构,项目可覆盖 Decode,将旧根对象转换为带版本的文档

Read 和 Load 的迁移不会自动写回文件。Save 读取目标旧档、迁移并采集新数据后,才写出当前业务版本
Schema.Validate 可在独立文档上执行项目规范化。项目应并避免在这里改变业务运行时或启动其他存档请求

迁移只处理已解码的逻辑结构。若当前设置无法解密或解压旧文件,流程在迁移前就会失败

# 编码

保存:逻辑文档 -> 内部编码器 -> 压缩或原样复制 -> 生成完整 JSON 或 Bytes 文件 -> 整文件加密或原样输出 -> 存储字节
读取:按当前配置解密 -> 识别内容 -> 检查压缩状态 -> 当前压缩实现解压 -> 按当前内部编码器解码

可以在框架配置中设置压缩方式和加密方式,压缩和加密都只会按照这个配置进行

# Newtonsoft Json

框架使用 Newtonsoft JSON 做解析

配置作用
TypeNameHandling.None不根据文档中的类型名创建运行时类型
MetadataPropertyHandling.Ignore按框架策略处理元数据属性
ObjectCreationHandling.ReplaceDTO 内容整体构造,避免旧集合残留
DateParseHandling.NoneJToken 读取不自动将字符串转成日期 token
InvariantCulture固定数字等格式语义
MaxDepth = 64限制对象与数组嵌套

JSON 字节使用 UTF-8。读取时拒绝空文件、无效 UTF-8、重复属性名。根对象必须能读取成 JObject,DTO 的 JSON 异常转换为 InvalidDataException

# 读取格式

ReadFile 检查有 SDBF 标记的二进制内容,否则尝试 JSON 根对象。有包装标记时按压缩 JSON 内容读取,没有包装时使用当前内部编码器。Json 与 Bytes 可以在兼容条件下互读

压缩状态必须与当前配置一致:配置为无压缩时拒绝压缩内容,配置为压缩时拒绝未压缩内容

# 压缩

压缩部分使用 MessagePack 插件,框架内部提供 无压缩 和 LZ4 压缩

ISaveDataCompression 只包含压缩和解压,是可扩展的压缩契约

# LZ4

LZ4 压缩将内部逻辑字节作为 byte[] ,使用 MessagePack 的 Lz4Block 压缩,不会直接反射序列化业务 DTO。Resolver 只返回插件的静态 ByteArrayFormatter.Instance ,不支持其他泛型类型,调用点固定为 Serialize<byte[]> 和 Deserialize<byte[]> ,便于 IL2CPP 静态生成

解压前检查:

  1. 输入没有超过字节上限,外层声明解压长度没有越界
  2. MessagePack 内容是单个 Extension
  3. Extension 后没有尾随内容
  4. 内部记录的解压长度等于预期 MessagePack byte[] 编码长度

内部 byte [] 编码包含 bin8/bin16/bin32 头,其开销分别是 2、3、5 字节,因此内部期望长度不是简单等于 JSON 长度。 PackedLength() 按数组长度计算该开销

无压缩实现也遵守独立缓冲区与长度约束。压缩是否节省空间由数据决定,尤其 JSON 压缩包装会再产生 Base64 开销

# 加密

框架内部提供 无加密、AES-256-GCM、XOR

ISaveDataEncryption 只包含加密和解密,是可扩展的加密契约
框架将完整文件作为 plaintext,传入空的附加认证数据数组,没有解密头

ISaveDataKeyProvider 是可扩展的项目密钥来源
同步读取预加载密钥,网络请求须要在启动流程中异步等待完成,不能在此阻塞

框架清理自己持有的中间明文、压缩缓冲区、密码计算密钥副本,不修改提供器返回的数组。提供器缓存和其外部来源的生命周期由相应实现自己管理

# AES-256-GCM

框架使用 Bouncy Castle 的 AesEngine、GcmBlockCipher、AeadParameters,每次操作创建独立 cipher
密钥为 32 字节,加密通过 SecureRandom 生成 12 字节 nonce,标签为 16 字节

文件字节排列

nonce[12] | tag[16] | encryptedCompleteFile[N]

算法输出密文与标签,封装在返回前重新排成上述布局,解密时重新排列回插件需要的顺序,完成 DoFinal 认证后才交付明文
认证失败转换为 CryptographicException ,清理未成功交付的明文
加密开销为 28 字节,计入最终 MaxBytes 限制
文件全部内容都进入加密

# XOR

XOR 使用 Bouncy Castle 的 Bytes.Xor,按密钥长度分段循环变换,要求密钥非空,加密与解密使用同一过程

# 提交、备份、修复、清理

# 路径

路径使用 Application.persistentDataPath 持久化根目录
对于没有本地路径的平台,需要提供自己的 Storage

文件后端只接受 .dat 正式扩展名,备份为 .backup
只识别目录中的 .dat ,还要求文件实际名称已经是规范 NFC 小写形式。手动放入非规范名称会导致枚举失败
避免不同平台把大小写或 Unicode 变体当成不同栏位

# 写入

WriteCore 的操作流程

  1. 创建目录,检查是否取消
  2. 若正式档存在,读取旧字节,通过 WriteFile 提交备份
  3. 再检查是否取消,调用 createData 编码新文件
  4. WriteFile 创建同目录唯一的临时文件,写入文件
  5. 最终替换前检查是否取消
  6. 目标已存在时使用 File.Replace,不存在时使用 File.Move
  7. finally 删除仍存在的临时文件

# 备份与修复

备份复制的是旧文件的原始字节,保留其压缩和加密结果。编码新档前先备份,还能保证编码失败不会破坏旧档

Manager 的 RepairCore 读取备份,执行 Decode 确认有效,然后将原始 bytes 交给 Storage.Repair。文件后端先复制现有正式档到临时文件,再提交备份字节到正式位置。有效备份本身不改写

它只修复文件,项目需要另行 Load 才恢复业务状态

# 删除

Delete 按参数删除正式档与备份。若删除当前栏位,Manager 清除当前栏位选择。文件后端异步删除在开始前检查是否取消

CleanupTemporaryFiles(minimumAge) 显式扫描符合本框架名称格式、GUID 长度、创建时间要求的临时文件。调用方必须确保没有其他写入者在使用这些文件

# 服务器接口

ISaveDataReader 提供存在检查、读取与枚举。 ISaveDataStorage 增加 CanWrite、SupportsBackup、WriteAsync、RepairAsync、DeleteAsync、名称规范化。同步能力由 ISynchronousSaveDataStorage 声明,持久化范围由 ISaveDataStoragePersistence 声明

ServerSaveDataStorage 将项目的 IServerSaveDataBackend 接入读取流程,CanWrite 固定为 false。它检查远端 ID 不能为空,保留大小写,其他由项目后端决定

项目后端自行负责鉴权、账户隔离、HTTP、重试、响应长度、缺档错误的表达。Manager 的同步 API 不适用此后端。Save、Repair、Delete 在读取适配器上不支持

当前没有内置云端写入、修订冲突解决、离线合并或多设备同步。实现 ISaveDataStorage 时,项目应保证操作完成才返回

# Web 文件与持久化确认

Web 默认由 Unity Bridge 创建原始 FileStorage,再包装为 SynchronizedFileSaveDataStorage。包装仅实现异步存储接口,避免同步入口阻塞等待浏览器回调

读取、枚举、检查 会访问已装载到当前会话的文件
写入、修复、删除 先调用文件后端,再调用 ConfirmPersistenceAsync()

img

托管层用 GUID 请求 ID 保存 TaskCompletionSource,保持回调委托。JS 插件声明 FS、IDBFS、UTF-8 辅助依赖,先确认目录挂载是 IDBFS,然后同步。回调最多执行一次,托管复制字符串后由 JS 释放临时内存

失败包装为 SaveDataPersistenceException : IOException

后端成功返回后的存储范围如下

SaveDataPersistenceScope含义
BackendDefined由项目后端说明
CurrentSession只确认当前会话可读取
PersistentStorage已提交到持久存储,可在相同存储身份下跨页面数据刷新

这个范围不能保证用户清除数据或浏览器回收存储后的保留

Unity Web 默认关闭后台 IO,使用主循环计时

# 文档校验

层级主要检查
本地读取文件长度不超过 maxBytes
Manager 字节入口非空、MaxBytes
FileSerializer内部编码、压缩、容器、密文等每阶段的长度
容器二进制标记、明确版本、Int32 字段、剩余长度、Base64
压缩实现内外解压长度、类型、实际长度
JSONUTF-8、对象根、重复键、深度
Schema 与迁移Schema ID、版本范围、迁移完整性、项目规范
Manager 文档非空模块、元信息、保留根字段、节点上限、有限浮点数
ParticipantDTO 契约、业务范围、资源 ID、恢复前置条件

MaxBytes 默认 8 MiB,同时限制逻辑字节和最终文件。JSON 包装的 Base64 和 AES 的 28 字节开销可能使最终文件先到达上限
MaximumDepth 默认 64,使用栈遍历对象和数组,包含根容器,与 JSON 读取深度语义保持一致。MaxNodes 计数的是整个文档字段树的后代节点,包括属性和容器节点

这些限制主要发生在编码、解码和校验阶段。框架仍需持有独立 DTO、克隆 JToken、编码字节、压缩中间缓冲区

# 配置

配置建立顺序

  1. SaveDataSettings.LoadOptions() 读取资产,没有资产时返回默认 SaveDataOptions
  2. CreateOptions() 根据当前 Editor、Development、Release 选择格式,创建选中的压缩、加密、密钥提供器
  3. 设置窗口中的 ISaveDataOptionsProvider.Configure(options) 最后覆盖窗口生成的选项
  4. 项目启动代码可继续修改返回的 options ,例如替换 Schema、Storage、迁移列表
  5. SaveDataManager.Initialize(options) 复制选项、固定迁移列表、建立本次会话

直接调用 Initialize(new SaveDataOptions { ... }) 使用传入配置,窗口选项不会自动合并进去。希望继承窗口设置时,应先调用 LoadOptions()

更改设置资产或原始 options 不会重新配置已经初始化的会话。切换配置应先结束所有请求、结束会话,再重新初始化。配置中的服务对象按引用交付,不要在会话中途改变

link.xml 中默认保留 Assembly-CSharp 中的 SaveData 命名空间
构建处理器为窗口选择的扩展类型生成附加保留规则。其他业务命名空间、自定义程序集、嵌套 DTO、代码直接交付的反射类型,需要项目按实际引用补充规则

# 类图

img

# 使用方法

# 示例

SaveData\Integrations\Example 中有一个简单的使用示例。将 SaveDataExampleRunner 挂载到空物体上后即可运行

自动示例依次演示:缺档恢复默认值、保存两次、加载正式档、只读预览、枚举栏位、加载上一份备份、删除正式档并从备份修复、处理缺档错误、清理临时栏位。手动面板可观察修改数据后保存与读档的差异。自动演示的临时栏位会清理,手动栏位保留到下次运行

# 常用概念

核心入口是命名空间 SaveData 中的静态 SaveDataManager。一个进程中的入口同时管理一个会话,会话包含一份配置、一组模块、一条迁移管线、一个存储后端。多栏位属于同一会话,可使用 slot-01 、 slot-02 、 autosave 等字符串 ID

常用概念如下

概念用途示例
栏位 ID标识一份正式档及其备份slot-01
模块 ID标识文档中一组业务快照progress 、 inventory
DTO只保存持久化字段的普通 C# 数据对象金币数、检查点 ID
Participant把运行时与 DTO 相互转换的适配器SaveDataParticipant<ProgressData>
Schema标识项目数据契约、当前版本、必需模块new SaveDataSchema("mygame", 1, "progress")
Storage执行字节读写、备份、修复、删除FileSaveDataStorage

# 接入

# 定义持久化 DTO 与运行时

using Newtonsoft.Json;
namespace Game.Persistence
{
    /// <summary>
    /// 独立的进度存档数据
    /// </summary>
    public sealed class ProgressData
    {
        /// <summary>
        /// 玩家金币
        /// </summary>
        [JsonProperty("coins", Required = Required.Always)]
        public int Coins;
        /// <summary>
        /// 稳定的检查点 ID
        /// </summary>
        [JsonProperty("checkpointId", Required = Required.Always)]
        public string CheckpointId = "start";
    }
    /// <summary>
    /// 游戏实际持有的进度状态
    /// </summary>
    public sealed class ProgressRuntime
    {
        /// <summary>
        /// 当前金币
        /// </summary>
        public int Coins { get; set; }
        /// <summary>
        /// 当前检查点
        /// </summary>
        public string CheckpointId { get; set; } = "start";
    }
}

用字符串 ID 保存检查点、技能、物品、装备槽,再由业务系统解析 ID。避免直接保存 GameObject、Transform、场景实例、委托、其他运行时对象引用。列表、字典、嵌套对象也应复制为独立 DTO

这里的 Required.Always 表示字段缺失或为 null 时解码失败。以后新增必填字段时,应提供迁移

# 模块适配器

using SaveData;
using System;
using System.IO;
namespace Game.Persistence
{
    /// <summary>
    /// 连接游戏进度与持久化快照
    /// </summary>
    public sealed class ProgressSaveParticipant : SaveDataParticipant<ProgressData>
    {
        /// <summary>
        /// 文档中的稳定模块键
        /// </summary>
        public const string Id = "progress";
        private readonly ProgressRuntime runtime;
        /// <summary>
        /// 当前模块 ID
        /// </summary>
        public override string ParticipantId => Id;
        /// <summary>
        /// 绑定已经创建的运行时
        /// </summary>
        public ProgressSaveParticipant(ProgressRuntime runtime)
        {
            this.runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
        }
        /// <summary>
        /// 每次采集都创建独立快照
        /// </summary>
        public override ProgressData CaptureData()
        {
            return new ProgressData
            {
                Coins = runtime.Coins,
                CheckpointId = runtime.CheckpointId
            };
        }
        /// <summary>
        /// 完整定义新游戏的持久化状态
        /// </summary>
        public override ProgressData CreateDefaultData()
        {
            return new ProgressData
            {
                Coins = 0,
                CheckpointId = "start"
            };
        }
        /// <summary>
        /// 只校验快照
        /// </summary>
        public override void ValidateData(ProgressData snapshot)
        {
            if (snapshot == null || snapshot.Coins < 0 || string.IsNullOrWhiteSpace(snapshot.CheckpointId))
                throw new InvalidDataException("金币不能为负数,检查点 ID 不能为空");
        }
        /// <summary>
        /// 完整覆盖运行时
        /// </summary>
        public override void RestoreData(ProgressData snapshot)
        {
            ValidateData(snapshot);
            runtime.Coins = snapshot.Coins;
            runtime.CheckpointId = snapshot.CheckpointId;
        }
    }
}
方法要求
CaptureData()复制全部需要保存的状态,不与运行时共享可变集合
CreateDefaultData()创建完整新游戏状态,不修改当前运行时
ValidateData()检查结构、范围、资源 ID,不改变游戏状态
RestoreData()完整覆盖持久化状态,支持恢复此前采集的回退快照

基类会将 DTO 转为 JToken,校验和恢复时分别生成新 DTO
Capture () 默认调用 ValidateCaptureData (),后者默认复用 ValidateData ()。如果保存与恢复的前置条件不同,可以单独覆盖 ValidateCaptureData (),例如允许战斗中采集,但限制技能激活期间恢复

# 初始化、注册、加载、保存

由 Unity 主线程上的异步方法调用 await SaveDataQuickStart.RunAsync(token) 。它会写入并保留 quick-start 栏位,结束后释放自己的会话。实际游戏应让统一启动流程长期持有会话

using SaveData;
using System.Threading;
using System.Threading.Tasks;
namespace Game.Persistence
{
    /// <summary>
    /// 在独立会话中演示进度保存与恢复
    /// </summary>
    public static class SaveDataQuickStart
    {
        /// <summary>
        /// 从 Unity 主线程调用,并等待全部操作结束
        /// </summary>
        public static async Task RunAsync(CancellationToken cancellationToken = default)
        {
            var runtime = new ProgressRuntime();
            var participant = new ProgressSaveParticipant(runtime);
            SaveDataOptions options = SaveDataSettings.LoadOptions();
            options.DirectoryName = "SaveDataQuickStart";
            options.Schema = new SaveDataSchema("quickstart.progress", 1, ProgressSaveParticipant.Id);
            options.Migrations = System.Array.Empty<ISaveDataMigration>();
            SaveDataManager.Initialize(options);
            try
            {
                SaveDataManager.Register(participant);
                await SaveDataManager.LoadOrDefaultAsync("quick-start", cancellationToken);
                runtime.Coins = 100;
                runtime.CheckpointId = "village";
                await SaveDataManager.SaveAsync("quick-start", cancellationToken);
                runtime.Coins = 999;
                await SaveDataManager.LoadAsync("quick-start", cancellationToken: cancellationToken);
                // 此时 runtime.Coins 为 100
            }
            finally
            {
                // 演示独占会话,所有已发起请求均已结束
                SaveDataManager.ResetSession();
            }
        }
    }
}

启动顺序是:读取设置 -> 提供项目 Schema 和迁移 -> 初始化 Manager -> 注册已经创建的模块 -> 等待模块就绪 -> 加载或恢复默认状态

此演示定义全新的版本契约 1 ,因此清空从项目配置继承的迁移列表。实际项目保留自己的 Schema ID,并交付与其当前版本匹配的迁移步骤

Initialize () 不能重复调用。部分入口在未初始化时会隐式初始化默认配置,接入时应主动初始化,避免第一个模块注册时意外选择默认 Schema。ResetSession () 清理模块、迁移、栏位选择、通知订阅,保留存档文件

# 常用 API

下面优先列出异步入口。多数方法有同名同步版本,删除 Async 即可。TryLoadAsync 只有异步版本,另有无参数同步 Save ()

API返回值与行为修改运行时修改文件
SaveAsync(slotId, token)采集模块,保留目标栏位未知数据,提交正式档不执行恢复是
ReadAsync(slotId, backup: false, cancellationToken: token)返回独立、已解码并迁移的 SaveDataDocument否否
LoadAsync(slotId, backup: false, cancellationToken: token)校验全部目标快照,然后恢复模块是否
LoadOrDefaultAsync(slotId, token)正式档存在返回 true,缺档恢复默认状态并返回 false是否
TryLoadAsync(slotId, token)返回本次请求自己的 SaveDataResult ,用结果表达异常成功时修改否
ExistsAsync(slotId, token)只检查正式档存在否否
GetSaveNamesAsync(token)返回正式栏位 ID 数组否否
GetSaveSlotsAsync(token)返回 SaveSlotInfo[] ,逐栏位读取并诊断否否
RestoreBackupAsync(slotId, token)验证备份并修复正式文件,保留有效备份否是
DeleteAsync(slotId, includeBackup: true, cancellationToken: token)删除正式档及可选备份否是
GetSavePath(slotId)文件后端返回绝对正式档路径,其他后端可能不支持否否

# 缺档、缺模块、坏档

LoadOrDefaultAsync() 只在正式档不存在时使用默认快照。文件损坏、Schema 不匹配、未来版本、缺少迁移、解密失败都属于错误

有效文档缺少某个已注册模块时, LoadAsync() 与 LoadOrDefaultAsync() 都会使用该模块的 CreateDefault() 。 UsedDefaults 记录的是整份正式档不存在而走默认分支,不能表明文件中的每个模块都来自文件

恢复默认值不创建文件。首次 SaveAsync() 才创建正式档。如果正式档坏了,保存也会先读取并验证它,因此不能依靠直接保存来静默覆盖坏档。应由项目明确选择修复、删除、另存新栏位

# 当前栏位

成功保存、加载、缺档默认恢复后, CurrentSaveName 才切换到栏位 ID。 ReadAsync() 和 RestoreBackupAsync() 不切换当前栏位。删除当前栏位会将 CurrentSaveName 清空

无参数 Save() 保存当前已选定栏位,没有选定栏位会抛出异常。UI 和自动存档流程通常使用带 slotId 的方法,让保存目标更明确

# 预览

# 只读预览

下面是已有异步方法内的代码片段,需要 using SaveData; 、 using SaveData.Example; ,以及调用方持有的 token

SaveDataDocument document = await SaveDataManager.ReadAsync("example-player", cancellationToken: token);
if (document.Modules.TryGetValue(SaveDataExampleParticipant.Id, out var module))
{
    SaveDataExampleProgress preview = SaveDataJson.FromToken<SaveDataExampleProgress>(module);
    UnityEngine.Debug.Log($"金币:{preview.Coins},检查点:{preview.CheckpointId}");
}

这是预览项目已有示例栏位的写法,业务项目换成自己的模块 ID 和 DTO。读取只做文档层面的解码、迁移、校验,不要求注册恢复模块。若预览需要确认业务可恢复性,应该另外校验资源 ID 和业务条件

修改返回文档不影响运行时或磁盘。正式保存的来源是已注册模块的实时采集

# 栏位列表

GetSaveNamesAsync() 仅列出正式栏位。 GetSaveSlotsAsync() 会逐个读取正式档,提供以下信息

字段含义
SlotId栏位 ID
SchemaId 、 Version文档契约与迁移后的版本
SavedAtUtc文件中的最近保存 UTC 时间,显示本地时间时由 UI 转换
IsValid 、 Error正式文档能否完成读取及迁移,失败保留异常
HasBackup后端声明支持备份且备份存在,尚未验证备份内容

单个坏档通常记录在自己的 SaveSlotInfo.Error 中,其他栏位仍会显示。枚举失败或调用方取消会结束整个扫描。 IsValid 不能保证当前游戏资源和所有 Participant 的恢复条件都满足

仅剩 .backup 的栏位不会出现在本地正式栏位列表中,已知 ID 时仍可执行备份读取或修复

# 加载备份与修复正式档

本地第一次保存通常只创建正式档。再次保存时先备份已有正式档,再编码和提交新档。因此备份代表该次覆盖前的正式数据,编码失败后,备份也可能已更新为仍有效的旧正式档

恢复到运行时而保留正式文件

await SaveDataManager.LoadAsync("slot-01", backup: true, cancellationToken: token);

修复正式文件,然后恢复游戏

await SaveDataManager.RestoreBackupAsync("slot-01", token);
await SaveDataManager.LoadAsync("slot-01", cancellationToken: token);

RestoreBackupAsync() 验证备份后复制字节。已有正式档会保留为 .dat.corrupt-<GUID> 副本。后续正常保存才会把迁移结果写成当前版本

框架不会在正式档失败时自动加载备份。项目可在展示错误后提供从备份恢复的操作。后端不支持备份时,显式备份读取和修复会报告 NotSupportedException

# 设置

菜单入口为 Tools -> SaveData -> Settings
窗口中的校验按钮可以发现部分配置错误
使用自定义的压缩或加密时要求实现指定接口。可以将实现类放入配置中

资源资产保存在 Resources,改动位置时需要同时改动 SaveDataSettings.LoadOptions() 中的代码

以下是 SaveDataOptions 的代码默认值,窗口资产和项目提供器可以覆盖它们

选项默认值用途
SchemaSaveDataSchema("default") ,版本 1项目数据契约
DirectoryNameSaveData持久化根目录下的相对目录
FileFormatAutomaticEditor 和 Development 为 JSON,Release 为 Bytes
PrettyPrintJsontrueJSON 缩进
CompressionModeNoneNone 、 Lz4 、 Custom
EncryptionModeNoneNone 、 Xor 、 Aes256Gcm 、 Custom
EncryptionKeyId空字符串当前会话读写共用的密钥 ID
MaxBytes8 MiB最终文件、中间字节、解压内容的容量限制
MaxNodes100000文档后代节点数量限制
MaxSlots256Manager 创建新正式栏位时的数量限制
ReadinessTimeout30 秒所有模块共享的就绪等待期限
EnforceThreadAffinitytrue要求从初始化线程及其同步上下文调用
UseBackgroundIOtrue支持的平台后台调度同步文件后端

Storage 、 Serializer 、 Compression 、 Encryption 、 TimeService 可以交付项目实现。自定义 Compression 、 Encryption 实例优先于内置选项

ReadinessTimeout <= TimeSpan.Zero 时只使用调用方取消。它不会限制排队、网络请求、整次操作总耗时

# 文件、栏位名称、编码选择

# 文件位置

默认本地目录为 Application.persistentDataPath/SaveData 。 DirectoryName 允许根目录内的相对子目录,拒绝绝对路径和根目录穿越,自定义存储后端可自行定义位置

文件用途
slot-01.dat正式档
slot-01.backup覆盖前正式档的字节备份
slot-01.dat.sd-<GUID>.tmp正式档提交前的临时文件
slot-01.backup.sd-<GUID>.tmp备份提交前的临时文件
slot-01.dat.corrupt-<GUID>修复前保留的正式档诊断副本

删除栏位只处理 .dat 和可选择的 .backup ,诊断副本不会自动删除

# 本地栏位 ID 规则

本地后端会移除可选的结尾 .dat ,将名称转为 Unicode NFC 和小写。名称不能空白、包含首尾空白、句点、控制字符或 /\:*?"<>| ,移除扩展名后的原始名称最长 128 个字符,拒绝 con 、 prn 、 aux 、 nul 、 com1 至 com9 、 lpt1 至 lpt9

例如 SLOT-01.dat 和 slot-01 指向同一个本地栏位。模块 ID、Schema ID、密钥 ID 应由项目保持稳定且大小写一致。服务器适配器保留远端栏位 ID 的大小写

# JSON、Bytes、压缩、加密

选择实际行为
JSON + 无压缩 + 无加密.dat 内直接保存可读 JSON
Bytes + 无压缩 + 无加密二进制文件头包装逻辑编码器生成的字节,内部仍是 JSON
JSON + LZ4 + 无加密JSON 包装记录解压长度,内部为压缩字节的 Base64
Bytes + LZ4 + 无加密二进制包装记录解压长度和压缩内容
任意格式 + 加密完整编码文件进入加密,磁盘只写加密实现返回的原始密文

框架只使用当前配置,更改压缩、加密、密钥配置会影响旧档和备份的读取

# 加密、密钥来源

本地秘钥可以直接在配置中填入

下面是已取得密钥后的配置片段。 keyBytes 来自项目密钥服务, participant 是已经创建的模块

var keys = new PreloadedSaveDataKeyProvider();
keys.PreloadKey("player-save-v1", keyBytes);
SaveDataOptions options = SaveDataSettings.LoadOptions();
options.Schema = new SaveDataSchema("mygame", 1, participant.ParticipantId);
options.Encryption = null;
options.EncryptionMode = SaveDataEncryptionMode.Aes256Gcm;
options.EncryptionKeyProvider = keys;
options.EncryptionKeyId = "player-save-v1";
SaveDataManager.Initialize(options);
SaveDataManager.Register(participant);

预加载提供器按 ID 保存独立内存副本。设置窗口通过脚本创建的是新实例,直接预加载一个实例后,应像示例中一样把同一个实例交给 options。跨启动读取同一文件必须得到同一有效密钥,不能每次启动随机换钥匙

# 模块、依赖、异步就绪

每个业务系统注册自己的 Participant。唯一 ParticipantId,同一实例重复注册会忽略,同 ID 的不同实例会报错

Schema 的 RequiredParticipants 要求在执行保存或恢复时这些模块已注册。依赖通过 Dependencies 声明,框架先恢复依赖模块,无依赖约束时先按 RestoreOrder 排序,再按模块 ID 排序。依赖缺失或成环会在业务执行前被拒绝

例如 先恢复世界,再恢复玩家。在玩家适配器内覆盖以下内容

/// <summary>
/// 玩家恢复前先恢复世界模块
/// </summary>
public override System.Collections.Generic.IReadOnlyCollection<string> Dependencies =>
    new[]
    {
        "world"
    };
/// <summary>
/// 无依赖约束时的恢复优先级
/// </summary>
public override int RestoreOrder => 100;

异步资源模块应覆盖 WaitUntilReadyAsync(CancellationToken) ,由项目加载配置、资源和角色,模块只等待并检查结果
一个方法片段如下, IsReady 是项目已有状态, EnsureReady() 负责确认初始化成功

/// <summary>
/// 等待项目运行时完成准备
/// </summary>
public override async System.Threading.Tasks.Task WaitUntilReadyAsync(
    System.Threading.CancellationToken cancellationToken)
{
    while (!IsReady)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await System.Threading.Tasks.Task.Yield();
    }
    cancellationToken.ThrowIfCancellationRequested();
    EnsureReady();
}

全部模块共享一份就绪期限,并按恢复顺序依次等待。模块必须响应取消且结束任务,忽略取消会继续占用操作队列,期限不会强制终止业务代码。同步 API 只调用 EnsureReady() ,不会异步等待资源

# 结果、错误、恢复失败

普通异步 API 通过异常报告失败。只有 TryLoadAsync() 将异常保留在返回结果中,包括取消,不会把取消重新抛给调用方

SaveDataResult result = await SaveDataManager.TryLoadAsync("slot-01", token);
if (!result.Success)
{
    UnityEngine.Debug.LogError($"读档失败:{result.ErrorCode}\n{result.Error}");
}
字段或属性用途
SuccessError == null
Operation 、 SlotId操作名和按后端规则规范化后的栏位 ID
ErrorCode 、 Error错误类别及原始诊断异常
UsedDefaults缺少正式档时完成默认恢复
UsedBackup本次请求选择读取或修复备份
RuntimeStateConsistent恢复失败后运行时能否确认一致

错误类别包括 NotFound 、 InvalidData 、 EncryptionFailed 、 Cancelled 、 TimedOut 、 Unsupported 、 IOFailure 、 InvalidOperation 、 RestoreFailed 、 Unexpected 。缺少模块注册、线程错误、同步后端限制通常属于 InvalidOperation

LastResult 和 Completed 在请求取得操作门后结束时更新,入口校验失败、排队阶段取消的请求可能不会更新它们。需要本次读档的确定结果时使用 TryLoadAsync() 返回值,避免用共享 LastResult 推断某个并发请求的结果

加载会先校验所有目标快照,再采集所有旧快照,最后逐个恢复。某个模块恢复失败时,会包含该模块在内反向回退。如果回退也失败,Manager 进入 Faulted,后续操作被拒绝,结果中的 RuntimeStateConsistent 为 false

遇到 Faulted 时,项目应等待请求结束,结束会话并重建可靠运行时,再重新初始化和注册。 ResetSession() 清理管理状态

Completed 订阅者和 OnRestored() 抛出的异常会交给 NotificationFailed 。通知用于日志和观察,核心业务应放在项目流程中

# 相邻版本迁移

业务结构版本在 SaveDataDocument.Version 中。升级项目时保持 Schema ID,增加 CurrentVersion ,注册每个相邻步骤。例如版本 1 升到 3 需要 1 -> 2 和 2 -> 3 两步

下面的完整迁移类将 v1 的 gold 字段重命名为 v2 的 coins ,并补充新版本必填检查点字段

using Newtonsoft.Json.Linq;
using SaveData;
using System.IO;
namespace Game.Persistence
{
    /// <summary>
    /// 把项目 v1 进度升级为 v2
    /// </summary>
    public sealed class ProgressV1ToV2 : ISaveDataMigration
    {
        /// <summary>
        /// 本步骤的源版本
        /// </summary>
        public int FromVersion => 1;
        /// <summary>
        /// 转换文档字段,版本号由迁移管线递增
        /// </summary>
        public void Migrate(SaveDataDocument document)
        {
            if (!document.Modules.TryGetValue("progress", out JToken token))
                return;
            if (!(token is JObject progress))
                throw new InvalidDataException("旧进度模块必须为对象");
            if (progress["coins"] == null && progress["gold"] != null)
            {
                progress["coins"] = progress["gold"].DeepClone();
            }
            progress.Remove("gold");
            if (progress["checkpointId"] == null)
            {
                progress["checkpointId"] = "start";
            }
        }
    }
}

在项目 options 中设置

options.Schema = new SaveDataSchema("mygame", 2, "progress");
options.Migrations = new ISaveDataMigration[]
{
    new Game.Persistence.ProgressV1ToV2()
};

迁移仅修改独立文档,不执行文件写入或运行时恢复。 ReadAsync() 返回迁移后文档,后面还需要正常存档

# 扩展、服务器

# 扩展接口

接口扩展的内容
ISaveDataOptionsProvider最后覆盖窗口选项、交付业务 Schema 、服务实例
ISaveDataStorage云端读写、用户隔离、备份
ISaveDataSerializer内部逻辑文档编码,读取返回 JObject
ISaveDataCompression字节压缩、解压
ISaveDataEncryption按当前密钥 ID 进行整文件加密、解密
ISaveDataKeyProvider预加载密钥、平台密钥服务
ISaveDataTimeService实际时间计时
ISaveDataMetadataParticipant模块元信息采集、全部恢复成功后的通知
ISaveDataSnapshotMergeParticipant模块快照合并策略

需要构造参数的服务可以在项目启动代码或 options 提供器中直接交付

# 服务器读取

项目实现 IServerSaveDataBackend 的存在检查、字节读取、栏位枚举,然后设置 Storage

options.Storage = new ServerSaveDataStorage(backend, supportsBackup: false);

backend 是项目已创建的服务器读取对象。认证、账户隔离、请求路径、重试、响应大小限制由项目实现。当前 ServerSaveDataStorage 是只读适配器,不支持保存、修复、删除,也没有本地路径。需要云存档写入时,实现完整 ISaveDataStorage

# 常见问题

现象原因与处理
当前会话已初始化统一启动流程只初始化一次,切换配置前结束请求并重置
没有注册存档模块、必需模块未注册先创建运行时并注册模块,核对 Schema 的必需 ID
模块 ID 已注册多个实例使用相同 ID,统一管理实例生命周期
注册、注销或重置被拒绝仍有请求排队、执行或正在通知,等待 Task 结束
异步同步上下文错误从 Unity 主线程发起,避免 Task.Run 包裹 Manager
就绪超时检查项目初始化结果、就绪状态、模块取消响应
缺档恢复后磁盘没有文件默认恢复不保存,调用 SaveAsync() 创建文件
有备份但加载正式档仍失败不自动回退,显式加载备份或修复正式档
改设置后旧档无法读取核对当前压缩、加密、密钥、部编码器
变回了旧值读档会覆盖运行时,保存、读档时机由项目流程控制
删除字典项后又出现检查自定义合并策略,内置策略会整体替换字典
Faulted表示回退也失败,重建运行时并重新开始会话
Editor 正常,IL2CPP 解码失败核对业务 DTO 与扩展类的裁剪保留规则
Web 保存失败但当前页能读到新值文件已变更而持久化未确认,检查 SaveDataPersistenceException

建议接入后的验证项:新游戏默认值完整覆盖、同栏位覆盖与备份、多栏位隔离、坏档报告、历史版本迁移、恢复失败回退、目标平台的跨启动读取