Files
zeling_v2/Docs/Guides/04_SaveSystem_DataPersistence_Guide.md
2026-06-05 18:41:33 +08:00

554 lines
23 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.
# 存档系统与数据持久化手册
> 文件位置:`Docs/Guides/04_SaveSystem_DataPersistence_Guide.md`
> 版本1.0 · 适用项目zeling_v2
---
## 目录
1. [架构概览](#1-架构概览)
2. [SaveData 数据模型](#2-savedata-数据模型)
3. [存档读写完整时序](#3-存档读写完整时序)
- 3.1 [保存流程Save](#31-保存流程save)
- 3.2 [加载流程Load](#32-加载流程load)
- 3.3 [场景切换时的世界状态恢复](#33-场景切换时的世界状态恢复)
4. [ISaveable 接入模式](#4-isaveable-接入模式)
5. [存档槽管理与 UI 集成](#5-存档槽管理与-ui-集成)
6. [序列化与完整性校验](#6-序列化与完整性校验)
7. [存档迁移SaveMigrator](#7-存档迁移savemigrator)
8. [Inspector 配置指南](#8-inspector-配置指南)
9. [扩展指南](#9-扩展指南)
10. [商业对标评估](#10-商业对标评估)
11. [常见问题排查](#11-常见问题排查)
---
## 1. 架构概览
存档系统由四个层次组成严格单向依赖Core 层不持有任何 Unity UI 引用:
```
┌───────────────────────────────────────────────────────────┐
│ UI 层SaveSlotController / SaveSlotUI
│ 展示槽摘要、触发存档槽确认 │
└────────────────────┬──────────────────────────────────────┘
│ EVT_SlotConfirmedIntEventChannelSO
┌────────────────────▼──────────────────────────────────────┐
│ 服务接口层ISaveService / ISaveableRegistry
│ SaveServiceAdapter — 将 MonoBehaviour API 包装为接口 │
└────────────────────┬──────────────────────────────────────┘
│ ServiceLocator<ISaveService>.Get()
┌────────────────────▼──────────────────────────────────────┐
│ 管理器层GameSaveManager
│ 协调 ISaveable 集合、调用 ISaveStorage、管理内存状态 │
└────────────────────┬──────────────────────────────────────┘
┌────────────────────▼──────────────────────────────────────┐
│ 存储层ISaveStorage / LocalFileStorage
│ 负责实际的磁盘 IOJSON 文件,可替换为云存档) │
└───────────────────────────────────────────────────────────┘
横切关注点:
ISaveable ← 各游戏组件Player、WorldObject、Quest 等)实现此接口
SaveData ← 唯一的内存数据容器,各 ISaveable 读写其子节点
```
**核心设计原则:**
- `GameSaveManager` 不感知任何具体业务数据——它只是一个调度者;业务数据由各 `ISaveable` 组件自行读写 `SaveData` 的对应字段。
- `ISaveStorage` 是磁盘操作的唯一入口,方便在测试或平台适配时替换为内存存储或云存储。
- 所有跨层通信通过 `ServiceLocator` 或 SO 事件频道进行Core 层与 UI 层完全解耦。
---
## 2. SaveData 数据模型
`SaveData` 是游戏唯一的持久化数据容器,序列化为 JSON 写入磁盘。
### 顶层结构
```csharp
public class SaveData
{
public SaveMeta Meta; // 元数据:版本、时间戳、校验和
public PlayerSaveData Player; // 玩家HP、零珠、形态、死亡影
public EquipmentSaveData Equipment; // 装备:符文、凹槽
public WorldSaveData World; // 世界访问过的场景、已开门、已击败Boss、已拾取物品
public MapSaveData Map; // 地图:已探索房间、标记、传送点
public QuestSaveData Quests; // 任务:状态、目标进度
public AchievementSaveData Achievements;
public ToolsSaveData Tools; // 工具:已装备、已持有
public ChallengeRoomsSaveData ChallengeRooms; // 挑战间:最高分、最佳时间
public EventChainsSaveData EventChains; // 事件链:完成标记、世界旗标
public ShopsSaveData Shops; // 商店:已售出独特道具、购买次数
public InventorySaveData Inventory; // 背包:物品数量、新物品列表
public JournalSaveData Journal; // 图鉴:已发现敌人、已解锁传说
public StatsSaveData Stats; // 统计:击杀数、死亡数、移动距离
public NGPlusSaveData NGPlus; // NG+null = 非 NG+ 存档)
public TutorialSaveData Tutorial; // 教程:已完成提示 ID
public SettingsSaveData Settings; // 设置:语言偏好等
public Dictionary<string, JObject> DLC; // DLC 扩展点(开放式)
}
```
### SaveMeta 元数据
| 字段 | 类型 | 说明 |
|---|---|---|
| `Version` | `int` | 对应 `SaveMigrator.CurrentVersion`,用于向前迁移 |
| `SlotIndex` | `int` | 02 为普通槽98 = QuickSave |
| `LastSaved` | `string` | ISO 8601 时间戳(如 `"2026-06-04T14:30:00Z"`|
| `Playtime` | `float` | 累计游戏秒数 |
| `SavePointId` | `string` | 最后使用的检查点/存档点 ID |
| `NGPlusCount` | `int` | NG+ 周目计数0 = 初周目)|
| `SaveCount` | `int` | 累计保存次数(用于存档槽 UI 徽章显示)|
| `Checksum` | `string` | HMAC-SHA256Base64用于完整性校验 |
| `IsSteelSoul` | `bool` | 钢铁之魂模式(一命通关)标志,锁定后不可修改 |
### 摘要数据SlotSummary
`GetSlotSummaryAsync(int slot)` 返回用于 UI 展示的轻量对象,**不加载完整 SaveData**
| 字段 | 说明 |
|---|---|
| `Playtime` | 游戏时长(格式化后用于 UI 显示)|
| `LocationName` | 上次存档的场景显示名(本地化 Key|
| `Currency` | 零珠数量 |
| `MaxHP` | 当前最大血量 |
| `IsSteelSoul` | 是否为钢铁之魂存档UI 显示特殊徽章)|
| `HasData` | 该槽是否有存档(空槽 = false|
---
## 3. 存档读写完整时序
### 3.1 保存流程Save
```
触发点:检查点交互 / 自动存档触发器 / 手动调用 ISaveService.SaveAsync(slot)
├─ GameSaveManager.SaveAsync(slot)
│ │
│ ├─ 1. await _saveLock.WaitAsync() ← 防止并发写入
│ │
│ ├─ 2. 更新 Meta
│ │ └─ Meta.LastSaved = DateTime.UtcNow.ToString("o")
│ │ Meta.Playtime += (Time.time - _sessionStartTime)
│ │ Meta.SaveCount += 1
│ │
│ ├─ 3. 遍历 _saveablesHashSet<ISaveable>
│ │ └─ foreach ISaveable s → s.OnSave(_current)
│ │ (各组件将自身状态写入 _current 对应字段)
│ │
│ ├─ 4. 序列化 + 注入校验和
│ │ ├─ _current.Meta.Checksum = null ← 归零,确保 HMAC 计算一致
│ │ ├─ json = JsonConvert.SerializeObject(_current)
│ │ ├─ hmac = ComputeHMAC(json)
│ │ └─ json = InjectChecksum(json, hmac) ← 字符串替换,避免二次序列化
│ │
│ └─ 5. _storage.WriteAsync(slot, json)
│ └─ 写入本地文件(路径:{persistentDataPath}/saves/slot_{slot}.json
└─ _saveLock.Release()
副作用:
EVT_SaveCompleted可选─► 触发存档点 UI 动画(旋转图标 → 对号)
```
### 3.2 加载流程Load
```
触发点:主菜单「继续」选择存档 / 死亡后在检查点复活
├─ GameSaveManager.LoadAsync(slot)
│ │
│ ├─ 1. json = await _storage.ReadAsync(slot)
│ │
│ ├─ 2. data = JsonConvert.DeserializeObject<SaveData>(json)
│ │
│ ├─ 3. 校验和验证
│ │ ├─ extractedHMAC = data.Meta.Checksum
│ │ ├─ data.Meta.Checksum = null
│ │ ├─ recomputedHMAC = ComputeHMAC(JsonConvert.SerializeObject(data))
│ │ └─ if (extractedHMAC != recomputedHMAC)
│ │ IsSteelSoul → 拒绝加载(防存档篡改作弊)
│ │ Normal Mode → 记录警告,允许加载(兼容旧版本存档)
│ │
│ ├─ 4. SaveMigrator.Migrate(ref data)
│ │ └─ 若 data.Meta.Version < CurrentVersion执行版本迁移补丁
│ │
│ ├─ 5. _current = data_currentSlot = slot
│ │
│ └─ 6. 遍历 _saveables此时已注册的组件
│ └─ foreach ISaveable s → s.OnLoad(_current)
└─ 加载完成
注意:
加载流程不触发 EVT_SceneLoadRequest。
场景切换由调用方MainMenuController在加载前发起。
OnLoad 在新场景的 OnEnable 阶段由 ISaveableRegistry 驱动。
```
### 3.3 场景切换时的世界状态恢复
```
SceneService.LoadSceneCoroutine()
├─ (省略)淡出 → 加载场景 → 等待一帧
├─ EVT_SceneWorldStateRestored.Raise()
│ └─ 新场景中所有已 OnEnable 的 ISaveable 组件收到通知
│ └─ 重新调用 ISaveableRegistry.OnSceneRestored()
│ └─ foreach ISaveable → s.OnLoad(_current)
│ (仅恢复场景相关状态:已拾取物品、已销毁对象、已开门等)
└─ EVT_FadeInRequest.Raise() ← 世界状态恢复完成后再淡入,避免闪烁
```
---
## 4. ISaveable 接入模式
### 接口定义
```csharp
public interface ISaveable
{
void OnSave(SaveData saveData); // 将自身状态写入 saveData保存前调用
void OnLoad(SaveData saveData); // 从 saveData 恢复自身状态(加载后调用)
}
```
### 标准接入模板
```csharp
public class MyWorldObject : MonoBehaviour, ISaveable
{
[SerializeField] private string _uniqueId; // Inspector 中配置唯一 ID场景内唯一
private ISaveableRegistry _registry;
private bool _isCollected;
private void OnEnable()
{
_registry = ServiceLocator.Get<ISaveableRegistry>();
_registry.Register(this); // 注册:若已加载则立即调用 OnLoad
}
private void OnDisable()
{
_registry?.Unregister(this); // 取消注册:防止悬空引用
}
public void OnSave(SaveData saveData)
{
if (_isCollected)
saveData.World.CollectedItemIds.Add(_uniqueId);
}
public void OnLoad(SaveData saveData)
{
_isCollected = saveData.World.CollectedItemIds.Contains(_uniqueId);
gameObject.SetActive(!_isCollected); // 已拾取则隐藏
}
}
```
### 接入规则
| 规则 | 原因 |
|---|---|
| **必须**在 `OnEnable` 注册,`OnDisable` 取消注册 | 防止场景卸载后 GameSaveManager 持有悬空组件引用 |
| `OnSave` 只写,`OnLoad` 只读 | 避免在 OnLoad 中触发副作用(如粒子、音频)导致加载时闪烁 |
| `_uniqueId` 必须在场景内唯一 | 存档数据以 ID 为键,重复 ID 会造成存档互相覆盖 |
| 不要在 `OnSave/OnLoad` 中操作 Transform 或启动 Coroutine | 此时帧时序不稳定,应改在 `OnLoad` 后的首帧 Update 中执行 |
---
## 5. 存档槽管理与 UI 集成
### 存档槽 UI 职责分离
```
SaveSlotControllerScriptableObject 事件驱动)
├─ OnEnable()
│ └─ RefreshAsync()
│ └─ 并行调用 ISaveService.GetSlotSummaryAsync(0/1/2)
│ └─ 更新 SaveSlotUI[i](卡片显示)
├─ 模式NewGame
│ ├─ 空槽点击 → 显示 NewGameModeController普通 / 钢铁之魂选择)
│ └─ 已占用槽点击 → 显示 ConfirmDialogController覆盖确认
│ └─ 确认 → 显示 NewGameModeController
└─ 模式Continue
└─ 有存档槽点击 → ISaveService.LoadAsync(slot)
→ Raise EVT_SlotConfirmed(slot)
→ MainMenuController.HandleSlotConfirmed()
→ Raise EVT_SceneLoadRequest
```
### 新游戏创建流程
```
NewGameModeController 玩家选择(普通 / 钢铁之魂)
└─ ISaveService.CreateSlot(slotIndex, steelSoul: bool)
├─ new SaveData(),写入默认值
├─ Meta.IsSteelSoul = steelSoul
├─ Meta.SlotIndex = slotIndex
└─ 存入内存 _current不立即写磁盘
└─ Raise EVT_SlotConfirmed(slotIndex)
└─ MainMenuController 触发场景加载
└─ 首次进入 Gameplay 后的第一个检查点交互触发首次写盘
```
### 存档槽 UI 元素规范
| UI 元素 | 数据来源 | 备注 |
|---|---|---|
| 存档时长 | `SlotSummary.Playtime` | 格式化为 `HH:MM:SS` |
| 最后位置 | `SlotSummary.LocationName`(本地化 Key | 通过 LocalizationService 解析 |
| 零珠数量 | `SlotSummary.Currency` | 显示硬币图标 + 数值 |
| 血量进度 | `SlotSummary.MaxHP` | 用于显示心形图标数 |
| 钢铁之魂徽章 | `SlotSummary.IsSteelSoul` | 条件激活特殊 UI 装饰 |
| 空槽占位 | `!SlotSummary.HasData` | 显示「空」或「点击开始」提示 |
---
## 6. 序列化与完整性校验
### JSON 序列化配置
- 库:`Newtonsoft.Json``JsonConvert`
- 设置:`NullValueHandling.Ignore`(减少文件体积)
- 日期格式ISO 8601`"o"` 格式)
### HMAC-SHA256 校验流程
```
保存时:
1. _current.Meta.Checksum = null
2. json = Serialize(_current)
3. hmac = HMACSHA256.ComputeHash(Encoding.UTF8.GetBytes(json), _secretKey)
4. json = json.Replace("\"Checksum\":null", "\"Checksum\":\"" + base64hmac + "\"")
写盘 → json含校验和
加载时:
1. json = 从磁盘读取
2. data = Deserialize(json)
3. stored = data.Meta.Checksum
4. data.Meta.Checksum = null
5. expected = HMACSHA256.ComputeHash(Serialize(data))
6. if stored != expected:
IsSteelSoul → 拒绝(防篡改保护)
Normal → 警告日志,允许(向后兼容)
```
> **注意**`_secretKey` 在 `GameSaveManager` Inspector 中配置(`[SerializeField] private string _hmacKey`)。
> 不可将 Key 硬编码在代码中。生产发布前应使用随机生成的唯一 Key 替换默认值。
---
## 7. 存档迁移SaveMigrator
当代码层 `SaveMigrator.CurrentVersion` > 存档文件 `Meta.Version` 时,迁移器自动执行。
### 添加新迁移补丁
`SaveMigrator.cs``Migrate()` 方法中追加:
```csharp
// 版本 2 → 3为 Inventory 增加 FavoriteSlots 字段
if (data.Meta.Version < 3)
{
data.Inventory ??= new InventorySaveData();
data.Inventory.FavoriteSlots ??= new List<string>();
data.Meta.Version = 3;
}
```
### 版本历史规范
| 版本 | 变更说明 | 迁移操作 |
|---|---|---|
| 1 | 初始版本 | — |
| 2 | 新增 `Stats.DistanceTraveled` | 默认 `0f`,无需迁移 |
| 3 | 新增 `Inventory.FavoriteSlots` | 初始化为空列表 |
> 每次存档结构变动都**必须**递增版本号并编写迁移补丁,否则旧存档加载时会报 `NullReferenceException`。
---
## 8. Inspector 配置指南
### GameSaveManager挂载在 Persistent 场景 `[SERVICES]` 下)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
| `_storage` | `LocalFileStorage` 组件引用 | 磁盘 IO 实现;测试时可替换为 InMemorySaveStorage |
| `_hmacKey` | 任意非空字符串(生产环境用随机 UUID| 校验和计算密钥 |
| `_onSaveCompleted`(可选) | `EVT_SaveCompleted`VoidEventChannelSO| 触发存档点 UI 反馈动画 |
### LocalFileStorage与 GameSaveManager 同节点)
| Inspector 字段 | 赋值 | 说明 |
|---|---|---|
| `_saveDirectory` | 留空(默认 `Application.persistentDataPath/saves/`| 可覆盖为绝对路径(仅测试用)|
| `_fileExtension` | `".json"` | 存档文件后缀 |
### SaveServiceAdapter与 GameSaveManager 同节点)
无需配置——它是一个纯粹的接口转发层,仅需保证与 `GameSaveManager` 在同一 GameObject 上。
### 事件频道速查
| SO 名称 | 类型 | 发布者 | 订阅者 |
|---|---|---|---|
| `EVT_SlotConfirmed` | IntEventChannelSO | SaveSlotController | MainMenuController |
| `EVT_SaveCompleted`(可选) | VoidEventChannelSO | GameSaveManager | SavePointControllerUI 动画)|
| `EVT_SceneWorldStateRestored` | VoidEventChannelSO | SceneService | (所有 ISaveable 组件间接响应)|
---
## 9. 扩展指南
### 添加新的存档子数据节点
1. 新建 `[Serializable]` 数据类,如 `SkillsSaveData`
2.`SaveData` 中添加公开字段:`public SkillsSaveData Skills;`
3. 递增 `SaveMigrator.CurrentVersion` 并添加迁移补丁(初始化默认值)。
4. 在需要持久化的组件中实现 `ISaveable.OnSave` / `OnLoad` 读写 `saveData.Skills`
### 接入云存档iCloud / Google Play Games
1. 实现 `ISaveStorage` 接口,新建 `CloudSaveStorage.cs`
2.`GameServiceRegistrar.Awake()` 中根据平台选择注入:
```csharp
#if UNITY_IOS
_saveStorage = GetComponent<iCloudSaveStorage>();
#elif UNITY_ANDROID
_saveStorage = GetComponent<GooglePlaySaveStorage>();
#else
_saveStorage = GetComponent<LocalFileStorage>();
#endif
ServiceLocator.Register<ISaveStorage>(_saveStorage);
```
3. 云存档冲突解决策略建议:以 `Meta.SaveCount` 最大者为准(总是取保存次数更多的版本)。
### 添加存档槽截图预览
在 `GameSaveManager.SaveAsync()` 末尾注入:
```csharp
// 截图后以 Texture2D PNG 压缩存储到 Meta.PreviewImageBase64
var tex = await ScreenCapture.CaptureScreenshotAsTextureAsync();
_current.Meta.PreviewImageBase64 = Convert.ToBase64String(tex.EncodeToPNG());
```
在 `SaveSlotUI` 中使用 `Meta.PreviewImageBase64` 创建 `Sprite` 并显示。
### 实现自动存档(定时 + 过图触发)
```csharp
// 在 PlayerController 的场景加载完成回调中:
private void HandleSceneWorldStateRestored()
{
ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
}
// 在 GameManager 的定时协程中:
private IEnumerator AutoSaveLoop()
{
while (true)
{
yield return new WaitForSeconds(300f); // 每 5 分钟
if (_fsm.Current == GameStateId.Gameplay)
await ServiceLocator.Get<ISaveService>().SaveAsync(_currentSlot);
}
}
```
---
## 10. 商业对标评估
以下评估以"丝之歌"级别商业 2D 动作游戏Steam/主机发行标准)为基准,逐项审核当前存档系统的完备性与商业成熟度。
### ✅ 已达到商业标准
| 维度 | 当前实现 | 评价 |
|---|---|---|
| **三存档槽** | SlotIndex 0/1/2 | 符合行业惯例,足够主流平台需求 |
| **钢铁之魂保护** | IsSteelSoul + HMAC 拒绝加载被篡改存档 | 与 Hollow Knight 同等保护级别 |
| **存档摘要展示** | SlotSummary时长/位置/零珠/血量)| 满足玩家快速辨识存档内容的需求 |
| **存档版本迁移** | SaveMigrator 补丁链 | 支持无缝热更新存档结构 |
| **DLC 扩展点** | `Dictionary<string, JObject>` | 未来 DLC 内容无需修改核心存档类 |
| **NG+ 数据** | `NGPlusSaveData` 节点保留 | 为周目系统预留专用空间 |
| **ISaveable 自注册** | OnEnable/OnDisable 生命周期驱动 | 无需手动在 Manager 中维护组件列表 |
| **存储层抽象** | `ISaveStorage` 接口 | 云存档接入改动范围仅限适配层 |
### ⚠️ 建议改进项
| 维度 | 当前状态 | 建议 |
|---|---|---|
| **存档截图预览** | 未实现 | 主流商业游戏(如《原神》《空洞骑士》续作)普遍提供。建议在 Save 时截图并以 Base64 存入 Meta |
| **自动存档策略** | 未见独立的 AutoSaveService 实现 | 建议以两种触发结合:场景加载完成(过图自动存)+ 定时(每 5 分钟)|
| **存档文件加密** | 当前仅 HMAC 完整性校验,未加密 | PC 平台可接受若有主机认证要求PS/Xbox需配合平台 SDK 云加密 |
| **存档大小监控** | 未见大小上限检测 | 主机平台认证通常要求存档 ≤ 12 MB建议在 SaveAsync 后记录日志文件大小 |
| **QuickSave 槽编号** | 使用 `SlotIndex = 98`(魔法数字)| 建议用具名常量 `SaveSlots.QuickSave = 98` 替换,避免他处误用 |
| **存档损坏降级恢复** | Normal 模式下校验失败仅记录警告 | 建议在校验失败时尝试加载同槽的 `.bak` 备份文件(每次存档前备份一次)|
| **写入原子性** | 直接覆盖目标文件 | 建议先写 `.tmp` 文件,写入成功后再原子重命名,防止写入中断导致存档损坏 |
### ❌ 商业发行前必须补全
| 维度 | 当前状态 | 影响 |
|---|---|---|
| **HMAC 密钥管理** | `_hmacKey` 为 Inspector 可见字段 | 若使用默认值,钢铁之魂保护形同虚设;正式构建前必须在构建管线中注入随机密钥并从 Inspector 中移除 |
| **平台存档路径合规** | 仅实现 `persistentDataPath` 本地存储 | PS/Xbox 认证要求使用平台 SDK 的存档 APIPS: `savedata://`Xbox: `XboxStorage`|
---
## 11. 常见问题排查
### ❌ 加载存档后部分对象状态未恢复(仍显示默认状态)
**原因 1** 目标组件的 `OnEnable` 在 `EVT_SceneWorldStateRestored` 触发**之后**才执行(异步实例化的 Prefab
**解决:** 在组件的 `OnEnable` 中检查 `_registry.IsLoaded`,若为 `true` 则立即调用 `OnLoad(_registry.CurrentData)`。
**原因 2** 忘记在 `OnDisable` 调用 `_registry.Unregister(this)`,导致旧实例残留,新实例的 `OnLoad` 被跳过。
**解决:** 确保每个 `ISaveable.OnEnable` 都对应一个 `OnDisable.Unregister`。
---
### ❌ 点击「继续」后游戏场景加载但世界对象全部重置
**原因:** `MainMenuController.HandleSlotConfirmed` 在调用 `ISaveService.LoadAsync` 之前就发起了 `EVT_SceneLoadRequest`,导致新场景的 `ISaveable` 在 `OnLoad` 之前已触发 `EVT_SceneWorldStateRestored`。
**解决:** 调用顺序必须为:`LoadAsync(slot)` → `await` → `Raise(EVT_SceneLoadRequest)`。
---
### ❌ 存档校验失败:`Invalid checksum` 警告频繁出现
**原因:** 多个编辑器/构建版本使用了不同的 `_hmacKey` 值,旧存档无法通过当前 Key 验证。
**解决:**
1. 开发阶段:统一使用版本控制中记录的固定 Key在 `ProjectSettings` 或 `StreamingAssets` 中管理)。
2. 生产阶段:构建流水线注入唯一 Key每个游戏版本的 Key 不变(只在大版本号更迭时更换)。
---
### ❌ 存档文件体积异常(> 500 KB
**原因:** 某个 ISaveable 在 `OnSave` 中写入了图像数据、大型列表或未过滤的字典。
**解决:** 在 `SaveAsync` 后添加日志:
```csharp
Debug.Log($"[SaveManager] Slot {slot} size: {Encoding.UTF8.GetByteCount(json)} bytes");
```
逐一排查各 `ISaveable.OnSave` 调用前后的数据差量,定位膨胀来源。
---
*文档最后更新2026-06-04*