这一章我们把“存档”做成真正可上线的能力,而不是“把几个字段塞进 JSON 就完事”。
如果存档系统没有设计好,后面你每次加一个功能都可能把老玩家存档打坏。典型后果:
- 更新后读档失败,玩家进不去游戏。
- 字段升级后默认值错误,进度异常。
- 多端/多版本共存时,旧数据覆盖新数据。
本章目标是做一套 可演进、可回滚、可观测 的存档系统,兼容 Unity 老版本环境。
本章交付物
- 存档数据结构(元数据 + 业务数据分层)
- 本地持久化读写器(原子写入)
- 版本迁移器(V1 -> V2 示例)
- 校验与恢复机制(损坏检测 + 备份回滚)
设计原则
1) 元数据与业务数据分离
存档至少要有三层:
meta:版本、时间戳、校验信息payload:实际业务进度runtime:运行时临时数据(不落盘)
2) 写入必须“先临时文件,再替换”
直接覆盖正式存档,一旦写到一半崩溃,文件会损坏。要用原子替换策略。
3) 迁移是显式流程,不是隐式兼容
每次版本升级都要有明确迁移函数,不靠“字段刚好能反序列化”。
数据结构(Unity JsonUtility 兼容)
using System;
[Serializable]
public class SaveMeta
{
public int schemaVersion;
public long savedAtUnix;
public string checksum;
}
[Serializable]
public class PlayerProgress
{
public int level;
public int gold;
public string[] unlockedSkills;
}
[Serializable]
public class SaveFile
{
public SaveMeta meta;
public PlayerProgress payload;
}
说明:
Dictionary不作为核心字段,避免 JsonUtility 限制。schemaVersion必须放在meta,不要散在业务字段里。
路径约定
using System.IO;
using UnityEngine;
public static class SavePaths
{
public static string MainPath
{
get { return Path.Combine(Application.persistentDataPath, "save-main.json"); }
}
public static string BackupPath
{
get { return Path.Combine(Application.persistentDataPath, "save-main.bak.json"); }
}
public static string TempPath
{
get { return Path.Combine(Application.persistentDataPath, "save-main.tmp.json"); }
}
}
校验策略(轻量)
这里先用轻量校验(哈希),足够应对本地损坏场景:
using System.Security.Cryptography;
using System.Text;
public static class SaveChecksum
{
public static string Compute(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
var hash = SHA256.Create().ComputeHash(bytes);
var sb = new StringBuilder(hash.Length * 2);
for (var i = 0; i < hash.Length; i++)
{
sb.Append(hash[i].ToString("x2"));
}
return sb.ToString();
}
}
写入流程(原子替换)
using System;
using System.IO;
using UnityEngine;
using U3DC.Foundation.Logging;
public static class SaveWriter
{
private const int CurrentSchema = 2;
public static void Save(PlayerProgress progress)
{
var save = new SaveFile
{
meta = new SaveMeta
{
schemaVersion = CurrentSchema,
savedAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
checksum = string.Empty,
},
payload = progress,
};
var payloadJson = JsonUtility.ToJson(save.payload);
save.meta.checksum = SaveChecksum.Compute(payloadJson);
var finalJson = JsonUtility.ToJson(save, true);
File.WriteAllText(SavePaths.TempPath, finalJson);
if (File.Exists(SavePaths.MainPath))
{
File.Copy(SavePaths.MainPath, SavePaths.BackupPath, true);
}
File.Copy(SavePaths.TempPath, SavePaths.MainPath, true);
File.Delete(SavePaths.TempPath);
FoundationLog.Info("save", "save complete path=" + SavePaths.MainPath);
}
}
关键点:
- 先写
tmp,再覆盖main。 - 覆盖前备份
main到bak。 - 崩溃恢复时还有回退路径。
读取 + 校验 + 回滚
using System.IO;
using UnityEngine;
using U3DC.Foundation.Logging;
public static class SaveReader
{
public static SaveFile LoadOrCreateDefault()
{
var loaded = TryLoad(SavePaths.MainPath);
if (loaded != null)
{
return loaded;
}
FoundationLog.Warn("save", "main save invalid, try backup");
loaded = TryLoad(SavePaths.BackupPath);
if (loaded != null)
{
FoundationLog.Warn("save", "backup restored");
return loaded;
}
FoundationLog.Warn("save", "no valid save, use default");
return BuildDefault();
}
private static SaveFile TryLoad(string path)
{
if (!File.Exists(path))
{
return null;
}
var text = File.ReadAllText(path);
if (string.IsNullOrEmpty(text))
{
return null;
}
var file = JsonUtility.FromJson<SaveFile>(text);
if (file == null || file.meta == null || file.payload == null)
{
return null;
}
file = SaveMigrator.Migrate(file);
var payloadJson = JsonUtility.ToJson(file.payload);
var computed = SaveChecksum.Compute(payloadJson);
if (computed != file.meta.checksum)
{
FoundationLog.Error("save", "checksum mismatch path=" + path);
return null;
}
return file;
}
private static SaveFile BuildDefault()
{
var payload = new PlayerProgress
{
level = 1,
gold = 0,
unlockedSkills = new string[0],
};
return new SaveFile
{
meta = new SaveMeta
{
schemaVersion = 2,
savedAtUnix = 0,
checksum = SaveChecksum.Compute(JsonUtility.ToJson(payload)),
},
payload = payload,
};
}
}
版本迁移示例
假设 V1 没有 unlockedSkills,V2 新增了这个字段。
public static class SaveMigrator
{
public static SaveFile Migrate(SaveFile file)
{
if (file.meta.schemaVersion == 1)
{
if (file.payload.unlockedSkills == null)
{
file.payload.unlockedSkills = new string[0];
}
file.meta.schemaVersion = 2;
file.meta.checksum = SaveChecksum.Compute(UnityEngine.JsonUtility.ToJson(file.payload));
}
return file;
}
}
注意:
- 迁移函数必须幂等(重复执行不出错)。
- 每个版本升级都要有单独分支,不要直接跨多版本乱改。
与 Foundation 小库对齐
本章沿用前 1-5 章约定:
- 日志:
foundation/Runtime/Logging/FoundationLog.cs - 断言:
foundation/Runtime/Diagnostics/FoundationAssert.cs - 调度(异步保存/延迟写入可接):
foundation/Runtime/Timing/Scheduler.cs - 对象池(存档快照对象复用可接):
foundation/Runtime/Pooling/ObjectPool.cs
本章验收
- 写入中断不会破坏主存档(
tmp + bak策略有效)。 - 主存档损坏时能回退到备份。
- 旧版本存档可迁移到当前版本并通过校验。
- 每次存档成功都有稳定日志可追踪。
下一章预告
第 07 章我们做“错误分类与用户提示”:
- 把异常映射为可处理故障码
- 形成统一错误上报与提示策略
目标是让系统出错时“可诊断、可恢复、可沟通”。