chore: initial commit
This commit is contained in:
449
Docs/Design/56_CrashRecoverySystem.md
Normal file
449
Docs/Design/56_CrashRecoverySystem.md
Normal file
@@ -0,0 +1,449 @@
|
||||
# 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 写入失败 UI(SaveErrorPanel)
|
||||
|
||||
当任何存档写入返回非 `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 冲突解决 UI(CloudConflictPanel)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 检测到存档冲突 │
|
||||
│ │
|
||||
│ 发现两份不同的存档,请选择保留哪个版本: │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ 本地存档 │ │ 云端存档 │ │
|
||||
│ │ 上次保存: 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*
|
||||
Reference in New Issue
Block a user