Article

C# 实战课 06:存档系统:序列化策略、兼容升级与校验

这一章我们把“存档”做成真正可上线的能力,而不是“把几个字段塞进 JSON 就完事”。

如果存档系统没有设计好,后面你每次加一个功能都可能把老玩家存档打坏。典型后果:

  • 更新后读档失败,玩家进不去游戏。
  • 字段升级后默认值错误,进度异常。
  • 多端/多版本共存时,旧数据覆盖新数据。

本章目标是做一套 可演进、可回滚、可观测 的存档系统,兼容 Unity 老版本环境。

本章交付物

  1. 存档数据结构(元数据 + 业务数据分层)
  2. 本地持久化读写器(原子写入)
  3. 版本迁移器(V1 -> V2 示例)
  4. 校验与恢复机制(损坏检测 + 备份回滚)

设计原则

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
  • 覆盖前备份 mainbak
  • 崩溃恢复时还有回退路径。

读取 + 校验 + 回滚

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 没有 unlockedSkillsV2 新增了这个字段。

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

本章验收

  1. 写入中断不会破坏主存档(tmp + bak 策略有效)。
  2. 主存档损坏时能回退到备份。
  3. 旧版本存档可迁移到当前版本并通过校验。
  4. 每次存档成功都有稳定日志可追踪。

下一章预告

第 07 章我们做“错误分类与用户提示”:

  • 把异常映射为可处理故障码
  • 形成统一错误上报与提示策略

目标是让系统出错时“可诊断、可恢复、可沟通”。