存档/读档
基于槽位的 JSON 存档,具备防崩溃的原子写入与版本迁移。
基于槽位的 JSON 存档,具备防崩溃的原子写入与版本迁移 · 一行代码的关闭开关:
NullSaveService
功能概述
存档服务把你的游戏状态写入磁盘并在之后恢复——不会损坏数据,即使游戏在存档中途崩溃。你用一个命名槽位(例如 "slot0" 或 "autosave")调用 Save<T>(slot, data) 或 Load<T>(slot)(槽位就是一个不带扩展名的文件名)。其余交给服务处理:JSON 序列化、原子文件写入(先写入临时文件再替换)、以及数据格式变化时的架构迁移。如果存档文件损坏,或它的版本已无法迁移,你得到的是一个明确的结果码而不是异常——恢复体验的主动权始终在你手里。
快速示例
using CommonGameSystem.Core;
using UnityEngine;
using Logger = CommonGameSystem.Core.Logger; // Unity has its own Logger type
[DefaultExecutionOrder(100)]
public class SaveLoadDemo : MonoBehaviour
{
private ISaveService _save;
void Awake()
{
_save = ServiceLocator.Resolve<ISaveService>();
}
public void SaveGame(string slot, PlayerData data)
{
var result = _save.Save(slot, data);
if (result.Status != SaveStatus.Ok)
Logger.Error(Logger.Categories.Save, $"Save failed: {result.Message}");
}
public void LoadGame(string slot)
{
var result = _save.Load<PlayerData>(slot);
if (result.Status == SaveStatus.Ok)
ApplyPlayerData(result.Value);
else
Logger.Warning(Logger.Categories.Save, $"Load failed: {result.Message}");
}
private void ApplyPlayerData(PlayerData data)
{
// Apply the loaded values to your game state here.
}
}
[System.Serializable]
public class PlayerData
{
public int level;
public float health;
}
存档文件默认位于 {Application.persistentDataPath}/saves/{slot}.json。
完整 API 一览
写入
-
SaveResult Save<T>(string slot, T data) where T : class—— 序列化data并原子地写入命名槽位。成功返回SaveStatus.Ok;磁盘错误(磁盘满、权限问题)返回IOFailure——那种情况下现有文件保持完全未动。槽位名非法时抛出ArgumentNullException/ArgumentException——那些是调用代码的 bug,不是磁盘状况。 -
SaveResult Delete(string slot)—— 删除槽位文件。成功返回Ok;槽位(或 saves 文件夹本身)不存在时返回NotFound——重复调用安全,不抛异常——文件系统拒绝删除时返回IOFailure。配套的.bak备份文件被有意保留不动;它的生命周期由对该槽位的下一次Save管理。 -
void RegisterMigration(int fromVersion, ISaveMigration migration)—— 注册一步架构迁移,从版本v到v + 1,形式是纯 JSON 文本变换。fromVersion必须不小于 1,迁移不得为 null。请在启动时、第一次Load之前注册一次迁移。如果两个迁移共享同一个fromVersion,后注册者生效。
读取
-
SaveLoadResult<T> Load<T>(string slot) where T : class—— 读取并反序列化一个槽位。成功返回Ok及其值。槽位不存在返回NotFound;文件无法解析(或某个迁移步骤抛出异常)返回Corrupt;文件无法升级到当前版本返回VersionUnsupported。对磁盘或数据状况绝不抛出——只对非法槽位名抛出。 -
bool Exists(string slot)—— 槽位文件在磁盘上存在时返回true。它不检查文件是否可读或格式正确——那要用Load。 -
IReadOnlyList<string> ListSlots()—— 列出所有槽位名(不带扩展名的文件名),按字母排序。尚无存档时返回空列表;它绝不会作为副作用创建 saves 文件夹。
属性
int CurrentSchemaVersion { get; }—— 印在每个新存档上的架构版本。已处于该版本的文件无需迁移即可加载。默认 1;数据格式变化时通过SaveServiceOptions提升它。
结果类型
两种结果类型都是带公共只读字段的轻量结构体:
SaveResult——Status(一个SaveStatus)与Message(诊断字符串,Ok时为null;供日志使用,不面向玩家)。SaveLoadResult<T>——Status、Value(仅当Status == Ok时非 null)与Message。
SaveStatus 取值:
| 状态 | 含义 |
|---|---|
Ok | 操作成功完成。 |
NotFound | 槽位文件不存在。由 Load 和 Delete 返回。 |
Corrupt | 文件存在但无法解析或反序列化——JSON 格式错误、字段缺失、类型不匹配,或某个迁移步骤抛出了异常。文件及其 .bak 保持逐字节不变,你仍可以手工恢复它们。 |
VersionUnsupported | 存储的版本比 CurrentSchemaVersion 更新(存档从不降级),或迁移链存在缺口导致文件无法升级。文件保持原样。 |
IOFailure | 写入或删除期间发生文件系统错误。目标文件及其 .bak 保持不变,底层异常被记录而不是被抛出。 |
Schema 迁移
当你更改存档数据格式时,提升 CurrentSchemaVersion 并为每个版本步骤注册一个 ISaveMigration。每一步都把原始负载 JSON 文本从版本 v 变换到 v + 1——不访问文件、没有副作用,就是 string 进、string 出。Load 时,服务按顺序应用各步骤直至数据达到当前版本,然后再反序列化。
using CommonGameSystem.Core;
// v1 stored "oldField"; v2 renamed it to "newField".
public sealed class V1ToV2Migration : ISaveMigration
{
public string Migrate(string payloadJson)
=> payloadJson.Replace("\"oldField\"", "\"newField\"");
}
// Once at startup, before the first Load:
_save.RegisterMigration(1, new V1ToV2Migration());
如果某个迁移步骤抛出异常,Load 返回 Corrupt,磁盘上的文件保持未动。不支持向下迁移:由更新版本的游戏写出的文件会返回 VersionUnsupported。
自定义位置、扩展名与序列化器
默认服务由启动引导以标准选项注册。要更改文件夹名、文件扩展名、备份行为或架构版本,请构造自己的实例并替换注册:
using CommonGameSystem.Core;
using UnityEngine;
[DefaultExecutionOrder(100)]
public class CustomSaveInstaller : MonoBehaviour
{
void Awake()
{
var options = new SaveServiceOptions(
saveDirectoryName: "mygame_saves", // folder inside persistentDataPath (default "saves")
fileExtension: ".sav", // default ".json"
backupExtension: ".sav.bak", // default ".bak"; must differ from fileExtension
currentSchemaVersion: 2, // default 1; must be >= 1
createBackup: true); // default true — keep a backup of the prior save
ServiceLocator.Replace<ISaveService>(
new SaveService(new JsonUtilitySaveSerializer(), options));
}
}
构造函数会验证每个参数,对非法值抛出 ArgumentException(目录名包含路径分隔符、扩展名缺少前导点、文件与备份扩展名相同、或版本小于 1)。
要使用 Newtonsoft JSON、System.Text.Json 或加密,请实现 ISaveSerializer 并把它传给 SaveService 构造函数,替代 JsonUtilitySaveSerializer。
关闭此服务
ServiceLocator.Replace<ISaveService>(new NullSaveService());
这一行代码在不触碰任何调用方代码的情况下禁用持久化。Save 返回 Ok(什么都不做),Load 返回 NotFound,Exists 返回 false,ListSlots() 返回空列表。零磁盘访问,零分配。适用于开发期或无头服务器构建。注意,配置服务默认通过存档服务路由设置——该路由在启动时决定一次,因此会话早些时候写入的设置不受之后换成 NullSaveService 的影响。
行为与边界情况
-
仅限主线程。 所有方法都会检查自己运行在 Unity 主线程上(该检查在 Release 构建中被剔除)。从其他线程调用会抛出
InvalidOperationException。 -
非法槽位名立即抛出。 槽位必须是纯粹的文件名主干:不能为
null、不能为空或空白、不能含路径分隔符、不能含..、不能含:,也不能含你的文件系统在文件名中禁止的字符。违规会在任何磁盘访问之前抛出ArgumentNullException或ArgumentException——它们不会被包装成结果码。真正的磁盘状况(文件缺失、损坏、版本缺口)则以结果码返回。 -
原子写入。 服务先写入
.tmp文件,再用File.Replace把它替换进去。如果替换被打断(例如断电),原槽位保持完好。下一次Save会直接覆盖遗留的.tmp文件。你还会自动获得上一份存档的.bak备份;如果想降低磁盘写入量,可以通过SaveServiceOptions.CreateBackup关闭它(在你同时提升架构版本时不建议这样做——一次失败的迁移写入将没有.bak可用于恢复)。 -
失败绝不破坏数据。 每个非
Ok的结果——Corrupt、VersionUnsupported、IOFailure——都让槽位文件及其.bak保持逐字节不变。服务绝不会"清理"一个它读不了的文件。 -
IL2CPP / link.xml。 服务自身的类型已受 IL2CPP 代码裁剪保护。你自己的存档数据类(如上面的
PlayerData)必须在**你项目的link.xml**中保留,否则裁剪后的构建将无法反序列化它们。 -
序列化限制。 默认序列化器使用 Unity 的
JsonUtility:只支持公共字段和[SerializeField]字段,不支持自动属性、UnityEngine.Object引用和引用循环。如果某个字段悄悄没有存上,通常的罪魁祸首是自动属性——把它改成公共字段。