Files
zeling_v2/Docs/Design/56_CrashRecoverySystem.md
2026-05-08 11:04:00 +08:00

17 KiB
Raw Permalink Blame History

56 · 崩溃恢复与错误处理系统Crash Recovery & Error Handling

命名空间 BaseGames.Reliability
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.WorldSaveManager· BaseGames.PlatformIPlatformService
关联 31_SaveDataSchema_Unified · 46_PlatformIntegration · 55_AnalyticsTelemetrySystem


目录

  1. 系统总览
  2. 紧急自动存档机制
  3. 存档写入失败处理
  4. 云存档冲突解决
  5. 崩溃报告器集成
  6. 启动时数据完整性检查
  7. 错误 UI 规范
  8. 错误等级分类
  9. SaveManager 防御性编程规范
  10. 编辑器友好设计

1. 系统总览

崩溃恢复系统职责:
  ├─ EmergencySaveService   → 应用暂停/退出前触发紧急存档
  ├─ SaveWriteErrorHandler  → 处理磁盘满、权限拒绝等写入失败场景
  ├─ CloudConflictResolver  → 云存档与本地存档版本冲突时的 UI 决策流
  ├─ CrashReporter          → 捕获未处理异常,发送崩溃报告(可选联网)
  └─ SaveIntegrityChecker   → 启动时验证存档文件完整性,自动修复或降级

核心原则:玩家的进度安全高于一切其他系统优先级。每个错误路径都必须有明确的恢复策略,绝不因技术失败静默丢失玩家进度。


2. 紧急自动存档机制

2.1 触发时机

namespace BaseGames.Reliability
{
    /// <summary>
    /// 挂载在 Persistent Scene监听应用生命周期事件触发紧急存档
    /// </summary>
    public class EmergencySaveService : MonoBehaviour
    {
        [SerializeField] SaveManager _saveManager;
        [SerializeField] float       _autoSaveIntervalSeconds = 300f;  // 5 分钟定时自动存
        [SerializeField] bool        _saveOnPause = true;              // 平台切换时存档

        float _lastAutoSaveTime;

        void Update()
        {
            // 定时自动存档普通游玩中每5分钟静默存档到"自动存档槽"
            if (Time.realtimeSinceStartup - _lastAutoSaveTime >= _autoSaveIntervalSeconds)
            {
                TriggerEmergencySave("AutoSave_Timer");
                _lastAutoSaveTime = Time.realtimeSinceStartup;
            }
        }

        void OnApplicationPause(bool paused)
        {
            if (paused && _saveOnPause)
                TriggerEmergencySave("AutoSave_Pause");
        }

        void OnApplicationQuit()
        {
            // OnApplicationQuit 是同步的,使用同步存档路径
            _saveManager.SaveEmergencySync("AutoSave_Quit");
        }

        private void TriggerEmergencySave(string reason)
        {
            // 仅当玩家处于"正常游玩"状态时才自动存档
            // (不在主菜单、不在过场动画、不在 Boss 战高潮)
            if (!_saveManager.IsInSaveableState) return;

            _ = _saveManager.SaveEmergencyAsync(reason);
        }
    }
}

2.2 自动存档槽设计

槽位 触发条件 保留策略
AutoSave_01 定时5分钟/ 暂停 轮转:始终覆盖最旧的自动存档
AutoSave_02 同上(交替覆盖) 轮转
AutoSave_Exit 退出游戏 始终是最后一次退出前的状态
Manual_01~03 玩家手动存档 玩家控制,不被自动存档覆盖

自动存档不覆盖手动存档槽——用于防止玩家手动存错位置后无法回退。

2.3 自动存档的 UI 呈现

  • 自动存档时,屏幕右下角显示 SaveIndicator(参见 10_UISystem.md §17持续 1.5s
  • 不打断游戏(不弹对话框,不暂停)
  • 失败时(见 §3才显示警告

3. 存档写入失败处理

3.1 失败原因矩阵

原因 检测方式 处理策略
磁盘空间不足 IOException: No space left 弹出警告 UI + 引导玩家清理磁盘
写入权限拒绝 UnauthorizedAccessException 提示权限问题 + 尝试写入备用路径
路径不存在 DirectoryNotFoundException 自动创建目录,重试一次
文件被占用 IOException: File in use 等待 500ms 后重试,最多 3 次
JSON 序列化失败 JsonException 记录错误日志,保留上一个有效存档

3.2 SaveWriteErrorHandler

namespace BaseGames.Reliability
{
    public static class SaveWriteErrorHandler
    {
        /// <summary>
        /// 包装存档写入,统一处理所有失败场景
        /// </summary>
        public static async UniTask<SaveWriteResult> SafeWrite(
            string path, byte[] data, CancellationToken ct = default)
        {
            // 确保目录存在
            Directory.CreateDirectory(Path.GetDirectoryName(path)!);

            // 先写临时文件,写成功后原子替换(防止写到一半崩溃导致存档损坏)
            string tempPath = path + ".tmp";
            int retries = 0;

            while (retries < 3)
            {
                try
                {
                    await File.WriteAllBytesAsync(tempPath, data, ct);
                    File.Replace(tempPath, path, path + ".bak");  // 同时保留备份
                    return SaveWriteResult.Success;
                }
                catch (IOException ex) when (ex.Message.Contains("No space"))
                {
                    return SaveWriteResult.DiskFull;
                }
                catch (UnauthorizedAccessException)
                {
                    return SaveWriteResult.PermissionDenied;
                }
                catch (IOException)
                {
                    retries++;
                    if (retries >= 3) return SaveWriteResult.FileInUse;
                    await UniTask.Delay(500, cancellationToken: ct);
                }
            }
            return SaveWriteResult.Unknown;
        }
    }

    public enum SaveWriteResult { Success, DiskFull, PermissionDenied, FileInUse, Unknown }
}

3.3 写入失败 UISaveErrorPanel

当任何存档写入返回非 Success 时,弹出 SaveErrorPanel

┌─────────────────────────────────────────────────────────────┐
│  ⚠ 存档失败                                                  │
│                                                              │
│  无法保存进度:磁盘空间不足。                                  │
│  请清理磁盘后重试,否则本次进度可能丢失。                       │
│                                                              │
│  当前可用空间:约 128 MB                                      │
│  建议至少留有200 MB 空余                                    │
│                                                              │
│  [  打开文件管理器  ]     [  重试存档  ]     [  忽略  ]        │
└─────────────────────────────────────────────────────────────┘
  • "打开文件管理器"System.Diagnostics.Process.Start("explorer.exe", folderPath) 仅 PC 平台
  • "重试存档":立即重新触发存档流程
  • "忽略":玩家自担风险,下次存档再次提示

4. 云存档冲突解决

4.1 冲突场景

玩家在两台设备上游玩(如 PC + Steam Deck下线时设备 A 存档比云端新,或云端被设备 B 覆盖。

4.2 冲突检测

public class CloudConflictDetector
{
    public async UniTask<ConflictInfo?> DetectConflict(
        SaveMeta localMeta, IPlatformService platform)
    {
        var cloudData = await platform.CloudLoadAsync("save_meta.json");
        if (cloudData == null) return null;  // 云端无存档,无冲突

        var cloudMeta = JsonSerializer.Deserialize<SaveMeta>(cloudData);

        // 比较时间戳UTC
        if (cloudMeta.LastSavedUtc > localMeta.LastSavedUtc + TimeSpan.FromSeconds(30))
        {
            // 云端更新(在另一台设备上有游玩进度)
            return new ConflictInfo
            {
                LocalMeta  = localMeta,
                CloudMeta  = cloudMeta,
                CloudNewer = true
            };
        }
        return null;
    }
}

4.3 冲突解决 UICloudConflictPanel

┌──────────────────────────────────────────────────────────────┐
│  检测到存档冲突                                                │
│                                                              │
│  发现两份不同的存档,请选择保留哪个版本:                        │
│                                                              │
│  ┌──────────────────────┐  ┌──────────────────────┐         │
│  │ 本地存档              │  │ 云端存档              │         │
│  │ 上次保存: 2小时前      │  │ 上次保存: 30分钟前    │         │
│  │ 区域: 废墟区域         │  │ 区域: 深渊裂隙        │         │
│  │ 完成度: 52%           │  │ 完成度: 61%          │         │
│  │ 游玩时长: 18h 20m     │  │ 游玩时长: 21h 05m    │         │
│  └──────────────────────┘  └──────────────────────┘         │
│                                                              │
│  [  使用本地存档  ]              [  使用云端存档(推荐)  ]     │
└──────────────────────────────────────────────────────────────┘
  • "推荐"标签自动附在时间更新且游玩时长更长的版本上
  • 未选择的版本备份到 save_slot_X_conflict_backup.json(不直接删除)

5. 崩溃报告器集成

5.1 未处理异常捕获

namespace BaseGames.Reliability
{
    public class CrashReporter : MonoBehaviour
    {
        [SerializeField] CrashReporterConfigSO _config;

        void Awake()
        {
            Application.logMessageReceived += OnLogMessage;
            AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
        }

        private void OnLogMessage(string condition, string stackTrace, LogType type)
        {
            if (type != LogType.Exception && type != LogType.Error) return;
            if (!_config.enableCrashReporting) return;

            var report = new CrashReport
            {
                GameVersion   = Application.version,
                Platform      = Application.platform.ToString(),
                UnityVersion  = Application.unityVersion,
                Message       = condition,
                StackTrace    = stackTrace,   // 不包含个人信息
                Timestamp     = DateTimeOffset.UtcNow.ToString("O"),
                SceneName     = SceneManager.GetActiveScene().name
            };

            // 写入本地崩溃日志(始终执行)
            WriteCrashLogLocal(report);

            // 若玩家同意遥测,异步上传(使用遥测系统的上传队列)
            if (PlayerPrefs.GetInt("analytics_consent", 0) == 1)
                _ = UploadCrashReportAsync(report);
        }

        private void WriteCrashLogLocal(CrashReport report)
        {
            string path = Path.Combine(
                Application.persistentDataPath,
                "Crashes",
                $"crash_{DateTimeOffset.UtcNow:yyyyMMdd_HHmmss}.json");
            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
            File.WriteAllText(path, JsonSerializer.Serialize(report));
        }
    }
}

5.2 推荐崩溃报告服务

服务 适用场景 定价
Sentry 有后端时推荐,免费层够用 免费5K 事件/月)
Unity Cloud Diagnostics Unity 原生,无需额外配置 随 Unity 订阅
自建日志收集 完全隐私控制 服务器成本

集成方式SDK 的 Init() 调用在 CrashReporter.Awake() 中执行,且仅当 analytics_consent == 1 时。


6. 启动时数据完整性检查

6.1 SaveIntegrityChecker

public class SaveIntegrityChecker
{
    /// <summary>
    /// 游戏启动后,加载存档前执行完整性检查
    /// </summary>
    public static SaveLoadResult CheckAndRepair(string savePath)
    {
        if (!File.Exists(savePath))
            return SaveLoadResult.FileNotFound;

        var bytes = File.ReadAllBytes(savePath);

        // 1. Checksum 验证(参见 31_SaveDataSchema §8
        if (!ChecksumValidator.Verify(bytes))
        {
            // 尝试从备份恢复
            string backupPath = savePath + ".bak";
            if (File.Exists(backupPath))
            {
                File.Copy(backupPath, savePath, overwrite: true);
                return SaveLoadResult.RestoredFromBackup;
            }
            return SaveLoadResult.Corrupted;
        }

        // 2. JSON Schema 版本检查
        var json = Encoding.UTF8.GetString(bytes);
        var meta = JsonSerializer.Deserialize<SaveVersionMeta>(json);
        if (meta.SchemaVersion < SaveConstants.MinSupportedVersion)
            return SaveLoadResult.TooOld;

        return SaveLoadResult.OK;
    }
}

public enum SaveLoadResult { OK, FileNotFound, Corrupted, RestoredFromBackup, TooOld }

6.2 各结果的 UI 处理

结果 UI 行为
OK 静默加载,正常进入游戏
FileNotFound 主菜单存档槽显示"空槽"
RestoredFromBackup 弹出小通知"存档文件已从备份恢复"
Corrupted 弹出警告"存档文件已损坏,无法读取",提供删除选项
TooOld "此存档来自旧版本,无法兼容,已归档备份"

7. 错误 UI 规范

所有错误 UI 遵循统一的视觉语言(ErrorPanel 组件,继承 UIPanel

属性 规范
图标 ⚠(黄色)= 警告 / (红色)= 致命错误
标题 简短,描述发生了什么("存档失败",而非"IOException"
正文 描述原因 + 玩家可做什么(不展示技术栈信息)
按钮 最多3个优先推荐操作高亮
取消方式 致命错误不可 Escape 取消,警告可以
错误码 右下角小字显示(如 ERR-SAVE-001),供客服/社区参考,不向普通玩家解释

8. 错误等级分类

等级 定义 示例 处理
INFO 非错误,仅记录 自动存档完成 仅写日志
WARNING 可继续但需注意 备份存档恢复成功 屏幕通知 + 日志
ERROR 功能受损,需用户操作 存档写入失败 错误 UI + 日志
FATAL 无法继续游戏 存档完全损坏且无备份 全屏错误弹窗 + 退出选项
// 在所有 catch 块中使用统一的错误上报方法,不直接 Debug.LogError
ErrorReporter.Report(ErrorLevel.ERROR, "ERR-SAVE-001",
    userMessage: "存档失败:磁盘空间不足",
    technicalDetail: ex.ToString(),
    context: savePath);

9. SaveManager 防御性编程规范

SaveManager 中的所有存档相关代码必须遵守:

// ✅ 正确:原子写入,有备份,有错误处理
var result = await SaveWriteErrorHandler.SafeWrite(savePath, serializedData);
if (result != SaveWriteResult.Success)
    ErrorReporter.Report(ErrorLevel.ERROR, "ERR-SAVE-001", ...);

// ❌ 禁止:直接 File.WriteAllText无错误处理
File.WriteAllText(path, json);

// ✅ 正确:反序列化加 try-catch失败时返回新存档而非 null
try {
    return JsonSerializer.Deserialize<SaveData>(json);
} catch (JsonException) {
    ErrorReporter.Report(ErrorLevel.ERROR, "ERR-SAVE-002", ...);
    return SaveData.CreateDefault();
}

// ❌ 禁止:未处理的反序列化调用
return JsonSerializer.Deserialize<SaveData>(json);

10. 编辑器友好设计

  • SaveIntegrityChecker 工具EditorWindow 按钮可对当前所有存档文件运行完整性检查,输出 OK/Corrupted/Recoverable 状态
  • 崩溃日志查看器EditorWindow 列出 {persistentDataPath}/Crashes/ 下所有崩溃日志,可查看详细信息和 StackTrace
  • 存档写入模拟Inspector 中提供"模拟磁盘满"按钮Editor 专属),测试错误 UI 流程
  • 云冲突模拟调试菜单42_DebugCheatSystem中可手动触发云存档冲突 UI

本文档版本 1.0 · 2026-04 · 关联 31_SaveDataSchema_Unified / 46_PlatformIntegration / 55_AnalyticsTelemetrySystem