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

450 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 56 · 崩溃恢复与错误处理系统Crash Recovery & Error Handling
> **命名空间** `BaseGames.Reliability`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.World`SaveManager· `BaseGames.Platform`IPlatformService
> **关联** 31_SaveDataSchema_Unified · 46_PlatformIntegration · 55_AnalyticsTelemetrySystem
---
## 目录
1. [系统总览](#1-系统总览)
2. [紧急自动存档机制](#2-紧急自动存档机制)
3. [存档写入失败处理](#3-存档写入失败处理)
4. [云存档冲突解决](#4-云存档冲突解决)
5. [崩溃报告器集成](#5-崩溃报告器集成)
6. [启动时数据完整性检查](#6-启动时数据完整性检查)
7. [错误 UI 规范](#7-错误-ui-规范)
8. [错误等级分类](#8-错误等级分类)
9. [SaveManager 防御性编程规范](#9-savemanager-防御性编程规范)
10. [编辑器友好设计](#10-编辑器友好设计)
---
## 1. 系统总览
```
崩溃恢复系统职责:
├─ EmergencySaveService → 应用暂停/退出前触发紧急存档
├─ SaveWriteErrorHandler → 处理磁盘满、权限拒绝等写入失败场景
├─ CloudConflictResolver → 云存档与本地存档版本冲突时的 UI 决策流
├─ CrashReporter → 捕获未处理异常,发送崩溃报告(可选联网)
└─ SaveIntegrityChecker → 启动时验证存档文件完整性,自动修复或降级
```
**核心原则**:玩家的进度安全**高于**一切其他系统优先级。每个错误路径都必须有明确的恢复策略,绝不因技术失败静默丢失玩家进度。
---
## 2. 紧急自动存档机制
### 2.1 触发时机
```csharp
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
```csharp
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 冲突检测
```csharp
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 未处理异常捕获
```csharp
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
```csharp
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** | 无法继续游戏 | 存档完全损坏且无备份 | 全屏错误弹窗 + 退出选项 |
```csharp
// 在所有 catch 块中使用统一的错误上报方法,不直接 Debug.LogError
ErrorReporter.Report(ErrorLevel.ERROR, "ERR-SAVE-001",
userMessage: "存档失败:磁盘空间不足",
technicalDetail: ex.ToString(),
context: savePath);
```
---
## 9. SaveManager 防御性编程规范
SaveManager 中的所有存档相关代码必须遵守:
```csharp
// ✅ 正确:原子写入,有备份,有错误处理
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*