1. 项目概述为什么存档系统是游戏开发的“命门”在游戏开发圈子里摸爬滚打十几年我见过太多因为存档系统设计不当而“翻车”的项目。一个看似简单的“保存/加载”功能背后牵扯到的是整个游戏状态的管理、数据持久化策略、以及玩家体验的连贯性。很多新手开发者甚至一些有经验的团队初期都会用PlayerPrefs对付一下结果到了项目中后期存档数据膨胀、加载卡顿、版本兼容性差、甚至被玩家轻易修改作弊等问题接踵而至这时候再想重构成本就非常高了。这次我们聊的“备忘录模式”Memento Pattern就是解决这类问题的经典设计模式。它不是什么新潮的技术但却是构建一个健壮、灵活、可维护的存档与状态管理系统的绝佳思想武器。尤其在 Unity 开发中游戏对象的状态复杂多变位置、血量、背包、任务进度等如何在不破坏对象封装性的前提下捕获其内部状态并在未来某个时刻恢复这正是备忘录模式的核心价值。简单来说这个项目就是将备忘录模式的思想与 Unity 引擎的特性深度结合打造一套从架构设计到具体实现的存档系统解决方案。它不仅要解决“怎么存”的问题更要解决“存什么”、“怎么高效存”、“怎么安全存”以及“怎么优雅地管理状态快照”这一系列问题。无论你是在做一款 Roguelike 地牢探险还是一款开放世界 RPG这套思路都能帮你构建起游戏数据的“时光机”。2. 备忘录模式精解不只是“保存状态”在深入 Unity 实现之前我们必须先吃透备忘录模式本身。很多资料把它讲得太抽象我们直接用一个游戏开发中最常见的场景来理解。2.1 核心角色与游戏场景映射备忘录模式通常包含三个核心角色发起人 (Originator)需要被保存状态的对象。在游戏中这可以是Player玩家、Enemy敌人、Inventory背包等任何包含重要状态数据的游戏对象或管理器。备忘录 (Memento)用于存储发起人内部状态的对象。它就是那个“存档点”或“快照”。关键点在于它通常只暴露给发起人对外界特别是管理者是黑盒这保护了发起人状态的封装性。管理者 (Caretaker)负责保存和管理备忘录的对象但它不能也不应该操作或检查备忘录的内容。在游戏中它就是我们的SaveLoadManager存档管理器。一个生活化的类比想象你在玩一个复杂的策略游戏。你控制的英雄单位就是“发起人”它有攻击力、防御力、魔法值、装备列表等内部状态。你手动点击“快速存档”时游戏会为这个英雄创建一个“备忘录”这个备忘录里封存了英雄那一刻的所有状态数据。游戏的存档系统就是“管理者”它负责把这个备忘录存档文件保存到硬盘的某个位置比如slot1.save。当你打不过 BOSS读档时管理者把slot1.save这个备忘录交还给英雄单位英雄单位自己从备忘录中读取数据恢复状态。存档系统管理者从头到尾都不知道你的英雄具体有多少血、穿了什么装备它只负责保管“黑盒子”。2.2 为何在游戏开发中非它不可你可能觉得我直接让Player脚本把自己所有属性写进一个SaveData类然后让SaveLoadManager把这个类序列化成 JSON 存盘不就行了为什么非要绕个弯子用备忘录模式这里面的区别正是设计模式的精髓所在保持封装降低耦合如果SaveLoadManager知道Player内部所有属性的细节比如player.health,player.equippedWeapon那么一旦Player类的结构发生变化比如把health拆分成currentHealth和maxHealthSaveLoadManager也必须跟着改。它们耦合在了一起。而备忘录模式中SaveLoadManager只接触IMemento接口它不关心具体内容Player的内部变化被隔离了。支持复杂的内部状态Player的状态可能不仅仅是几个基础变量。它可能包含对场景中其他对象的引用如homeTown、复杂的集合如ListQuest甚至是私有字段。备忘录模式允许Player自己决定哪些状态需要保存、以及如何将这些状态转换成可序列化的形式备忘录这个过程对外是隐藏的。实现“撤销/重做”等高级功能备忘录模式天然适合需要历史状态回溯的场景。比如在策略游戏中撤销一步操作在编辑器中撤销一次地形修改。管理者可以维护一个备忘录栈轻松实现这些功能。注意备忘录模式的一个潜在缺点是如果发起人对象非常庞大频繁创建备忘录快照可能会消耗大量内存。在游戏中我们需要有策略地创建快照例如只在关键节点检查点、手动存档或定时自动存档时进行而不是每帧都创建。3. Unity 存档系统架构设计理解了模式我们开始在 Unity 里搭架子。一个好的架构应该职责清晰、易于扩展、性能可控。3.1 核心架构图与模块职责我们的系统主要分为四层[游戏运行时对象] (Originator) ↓ (创建/恢复) [状态备忘录] (Memento) —— 纯数据类可序列化 ↓ (保存/加载) [存档管理器] (Caretaker) —— 单例负责IO和缓存 ↓ (读写) [持久化存储] (磁盘文件、云存储等)各模块详细职责可存档接口 (IOriginator)这是一个我们自定义的接口任何需要被存档的游戏对象或管理器都应实现它。它定义了两个核心方法public interface IOriginator { // 创建当前状态的备忘录 IMemento CreateMemento(); // 从给定的备忘录恢复状态 void RestoreFromMemento(IMemento memento); }这强制所有可存档对象遵循统一的协议。备忘录接口与基类 (IMemento / MementoBase)IMemento接口可能只包含一个Guid用于标识。我们更常用一个抽象基类MementoBase它包含一些元数据如存档时间、版本号、关联的 Originator ID 等。[System.Serializable] // 关键必须可序列化 public abstract class MementoBase : IMemento { public string OriginatorId; // 哪个对象创建的 public System.DateTime SaveTime; public int DataVersion; // 用于处理版本兼容 // ... 其他元数据 }具体的状态数据由派生类定义。例如PlayerMemento : MementoBase里面包含health,position,inventoryItemIds等。存档管理器 (SaveLoadManager)这是系统的中枢通常设计为单例。注册中心维护一个Dictionarystring, IOriginator用于通过 ID 查找可存档对象。快照捕获遍历所有已注册的IOriginator调用其CreateMemento()收集所有备忘录。序列化与IO将收集到的备忘录列表通常包装在一个GameSaveData容器类里序列化如转为 JSON然后加密、压缩最后写入文件。反序列化与恢复读取文件解密解压反序列化得到GameSaveData然后遍历数据找到对应的IOriginator调用其RestoreFromMemento。存档槽管理管理多个存档文件。持久化策略决定数据如何最终落盘。Unity 提供了Application.persistentDataPath作为跨平台的持久化数据路径。我们可以在此路径下创建Saves/目录来存放存档文件。3.2 序列化方案选型JSON vs. 二进制这是实战中的关键抉择。上面网络资料提到了JsonUtility,MessagePack,Protobuf等我们分析一下在 Unity 存档场景下的选择。方案优点缺点适用场景UnityJsonUtility无需第三方库Unity 内置支持对[Serializable]类型和ISerializationCallbackReceiver支持好在 AOT 平台如 iOS上稳定。功能较弱不支持字典、多态序列化结构需严格匹配性能不是最优。新手项目、中小型数据、快速原型开发。推荐大多数项目起步使用。Newtonsoft.Json (JSON.Net)功能极其强大支持几乎所有 C# 特性高度可配置社区熟悉度高。需要导入第三方 DLL需注意 Unity 兼容版本在 IL2CPP 下可能需额外处理序列化/反序列化速度比JsonUtility慢。数据结构非常复杂、需要灵活序列化策略的项目。MessagePack-CSharp极高的性能序列化后体积小二进制对 Unity 和 IL2CPP 支持良好。数据为二进制不可读调试不便需要为待序列化的类添加[MessagePackObject]等属性。对性能和存档文件大小有严格要求的商业项目尤其是移动端。Protobuf-net高性能跨语言支持好协议清晰。在 Unity 中使用需要处理代码生成配置稍复杂。需要与后端非 C#通信或对协议有严格要求的项目。我的实战建议从JsonUtility开始。它的易用性和稳定性是最大的优点。先把存档功能跑通架构搭好。遇到性能瓶颈或数据膨胀时再考虑迁移到 MessagePack。MessagePack 的性能提升是实实在在的特别是当你的存档数据包含大量数组或列表时。迁移成本主要是给数据类添加属性但架构IOriginator/IMemento通常不需要大改。绝对不要使用BinaryFormatter。正如 Unity 官方警告它有严重的安全漏洞未来版本中可能会被移除。3.3 状态管理的边界与粒度“存什么”和“怎么存”同样重要。不是所有数据都值得放进备忘录。静态数据 vs. 动态数据场景引用、预制体、配置表如物品属性这些属于静态数据不应该存档。存档里只存 ID 或 Key加载时根据 ID 去静态配置里查找还原。例如存档里存itemId: “sword_001”而不是整个SwordItem对象。引用类型与循环引用直接序列化对场景中GameObject或MonoBehaviour的引用是行不通的。我们需要将其转换为可序列化的标识符如InstanceID或自定义的Guid并在恢复时通过管理器重新解析。同时要小心对象间的循环引用导致序列化失败JsonUtility对此处理能力很弱。状态粒度是为整个游戏世界创建一个巨大的WorldStateMemento还是为每个实体Player, Enemy, Chest创建独立的备忘录我推荐混合策略。核心玩家数据、全局游戏进度用一个全局备忘录。而场景中大量的、可动态创建销毁的实体怪物、掉落物适合用独立的备忘录并由一个EntityManager统一管理它们的创建与恢复。这有助于实现分块加载和保存。4. 实战构建基于备忘录模式的 Unity 存档系统理论说再多不如一行代码。我们开始动手实现一个最核心的流程。4.1 第一步定义可存档接口与备忘录基类// 1. 可存档对象接口 public interface IOriginator { string GetOriginatorId(); // 获取唯一标识符 IMemento CreateMemento(); void RestoreFromMemento(IMemento memento); } // 2. 备忘录接口可以很简单 public interface IMemento { string OriginatorId { get; } } // 3. 可序列化的备忘录基类 [System.Serializable] public abstract class MementoBase : IMemento { public string OriginatorId; public long saveTimestamp; // 使用时间戳便于管理 string IMemento.OriginatorId OriginatorId; protected MementoBase(string originatorId) { OriginatorId originatorId; saveTimestamp System.DateTime.UtcNow.Ticks; } }4.2 第二步实现具体的游戏对象备忘录以玩家为例[System.Serializable] public class PlayerMemento : MementoBase { // 只保存必要的数据而非引用 public float health; public float mana; public SerializableVector3 position; // 需要自定义可序列化的Vector3 public string[] equippedItemIds; // 装备的物品ID数组 public Dictionarystring, int inventory; // 物品ID到数量的映射 (JsonUtility需处理) public PlayerMemento(string playerId, float health, float mana, Vector3 pos, string[] equipped, Dictionarystring, int inv) : base(playerId) { this.health health; this.mana mana; this.position new SerializableVector3(pos); this.equippedItemIds equipped; this.inventory inv; } } // 辅助类因为Unity的Vector3不可直接序列化 [System.Serializable] public struct SerializableVector3 { public float x, y, z; public SerializableVector3(Vector3 v) { x v.x; y v.y; z v.z; } public Vector3 ToVector3() { return new Vector3(x, y, z); } }4.3 第三步让玩家对象实现 IOriginatorpublic class PlayerController : MonoBehaviour, IOriginator { public string playerId Player_01; public float health 100f; public float mana 50f; private Liststring equippedItems new Liststring(); private Dictionarystring, int inventory new Dictionarystring, int(); public string GetOriginatorId() playerId; public IMemento CreateMemento() { // 捕获当前状态创建备忘录 return new PlayerMemento( playerId, health, mana, transform.position, equippedItems.ToArray(), new Dictionarystring, int(inventory) // 创建副本 ); } public void RestoreFromMemento(IMemento memento) { if (memento is PlayerMemento playerMemento) { // 从备忘录恢复状态 health playerMemento.health; mana playerMemento.mana; transform.position playerMemento.position.ToVector3(); equippedItems new Liststring(playerMemento.equippedItemIds); inventory new Dictionarystring, int(playerMemento.inventory); Debug.Log($Player state restored. Health: {health}, Position: {transform.position}); } } // ... 玩家其他的逻辑代码 }4.4 第四步构建存档管理器 (Caretaker)这是最复杂的一部分我们实现一个简单的单例管理器using UnityEngine; using System.Collections.Generic; using System.IO; public class SaveLoadManager : MonoBehaviour { public static SaveLoadManager Instance { get; private set; } // 存储所有可存档对象的注册表 private Dictionarystring, IOriginator originatorRegistry new Dictionarystring, IOriginator(); // 存档文件路径 private string saveDirectoryPath; private string currentSaveFilePath; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); saveDirectoryPath Path.Combine(Application.persistentDataPath, Saves); if (!Directory.Exists(saveDirectoryPath)) { Directory.CreateDirectory(saveDirectoryPath); } currentSaveFilePath Path.Combine(saveDirectoryPath, savegame.json); } // 注册/注销可存档对象 public void RegisterOriginator(IOriginator originator) { string id originator.GetOriginatorId(); if (!originatorRegistry.ContainsKey(id)) { originatorRegistry.Add(id, originator); } else { Debug.LogWarning($Originator with id {id} is already registered.); } } public void UnregisterOriginator(string id) { originatorRegistry.Remove(id); } // 创建游戏存档数据容器 [System.Serializable] private class GameSaveData { public ListMementoBase mementos new ListMementoBase(); public string gameVersion; public long saveTime; } // 保存游戏 public void SaveGame() { GameSaveData saveData new GameSaveData(); saveData.gameVersion Application.version; saveData.saveTime System.DateTime.UtcNow.Ticks; // 1. 捕获所有状态 foreach (var kvp in originatorRegistry) { var memento kvp.Value.CreateMemento() as MementoBase; if (memento ! null) { saveData.mementos.Add(memento); } } // 2. 序列化为 JSON (使用 JsonUtility) string jsonString JsonUtility.ToJson(saveData, true); // prettyPrint 为 true 便于调试 // 3. (可选) 加密和压缩 // byte[] encryptedData YourEncryptionMethod(jsonString); // 4. 写入文件 try { File.WriteAllText(currentSaveFilePath, jsonString); Debug.Log($Game saved successfully to: {currentSaveFilePath}); } catch (System.Exception e) { Debug.LogError($Failed to save game: {e.Message}); } } // 加载游戏 public void LoadGame() { if (!File.Exists(currentSaveFilePath)) { Debug.LogWarning(No save file found.); return; } try { // 1. 读取文件 string jsonString File.ReadAllText(currentSaveFilePath); // 2. (可选) 解密和解压 // string decryptedString YourDecryptionMethod(encryptedData); // 3. 反序列化 GameSaveData saveData JsonUtility.FromJsonGameSaveData(jsonString); // 4. 恢复状态 // 注意这里假设所有需要的 Originator 已经注册例如在场景Awake/Start中注册。 // 更健壮的做法是先反序列化所有备忘录然后根据 OriginatorId 匹配并恢复。 Dictionarystring, MementoBase mementoMap new Dictionarystring, MementoBase(); foreach (var memento in saveData.mementos) { mementoMap[memento.OriginatorId] memento; } foreach (var kvp in originatorRegistry) { string id kvp.Key; if (mementoMap.TryGetValue(id, out var memento)) { kvp.Value.RestoreFromMemento(memento); } else { Debug.LogWarning($No save data found for originator: {id}); } } Debug.Log(Game loaded successfully.); } catch (System.Exception e) { Debug.LogError($Failed to load game: {e.Message}); } } }4.5 第五步在场景中集成与测试将SaveLoadManager脚本挂载到一个空的 GameObject 上并确保它在场景中唯一且常驻 (DontDestroyOnLoad)。确保你的PlayerController或其他IOriginator在Start()或OnEnable()中向管理器注册在OnDisable()或OnDestroy()中注销。void Start() { SaveLoadManager.Instance?.RegisterOriginator(this); } void OnDestroy() { if (SaveLoadManager.Instance ! null) { SaveLoadManager.Instance.UnregisterOriginator(GetOriginatorId()); } }创建 UI 按钮分别调用SaveLoadManager.Instance.SaveGame()和LoadGame()。运行游戏操作玩家移动、扣血、拾取物品点击保存。退出游戏再重新运行点击加载观察玩家状态是否被正确恢复。5. 高级议题与性能优化基础系统搭建完毕后我们面临的就是工程化难题和性能挑战。5.1 版本兼容性当数据结构发生变化这是线上游戏必须考虑的问题。你今天存档的数据结构在下个版本可能就变了比如给PlayerMemento增加了一个stamina字段。直接加载旧存档会反序列化失败。解决方案版本号在GameSaveData和每个MementoBase中强制加入dataVersion字段。增量迁移编写迁移器 (SaveDataMigrator)。加载存档时检查版本号如果低于当前版本则按顺序执行一系列迁移函数将旧数据结构逐步升级到新结构。public GameSaveData MigrateSaveData(GameSaveData oldData, int oldVersion) { GameSaveData currentData oldData; for (int v oldVersion; v CURRENT_DATA_VERSION; v) { currentData ApplyMigrationStep(currentData, v); } return currentData; } private GameSaveData ApplyMigrationStep(GameSaveData data, int fromVersion) { if (fromVersion 1) { // 假设V2版本增加了玩家耐力值默认给100 foreach (var memento in data.mementos) { if (memento is PlayerMemento playerMem) { // 为旧的PlayerMemento添加默认耐力值 // 这里需要反射或更安全的方式一种做法是先将memento转为Json再反序列化为新类 } } data.dataVersion 2; } // ... 其他版本迁移 return data; }这种方法要求你保留所有历史版本的数据结构定义和迁移逻辑。5.2 部分保存与增量更新对于大型开放世界游戏每次全量保存所有实体状态开销巨大。可以采用“脏标记”机制。每个IOriginator维护一个bool isDirty标记。只有当状态发生改变时才将isDirty设为true。存档管理器保存时只为isDirty的对象创建备忘录保存后清除标记。同时维护一个全局的“基础存档”和多个“增量存档”。加载时先加载基础存档再按顺序应用增量存档。这类似于版本控制系统。5.3 异步保存与防卡顿文件 IO 和复杂的序列化尤其是 JSON 序列化大量数据可能造成主线程卡顿。优化策略使用JsonUtility.ToJson的重载它可以将对象序列化到System.Text.StringBuilder比返回字符串在内存分配上更高效。分帧处理如果可存档对象非常多不要在单帧内遍历所有对象并创建备忘录。可以将注册表分成多个批次每帧处理一批使用协程 (StartCoroutine) 或async/await来分散计算压力。异步文件写入使用File.WriteAllTextAsync.NET Standard 2.1 / C# 8.0 以上需在 Unity 2021.2 并启用兼容性设置或System.Threading.Tasks将文件写入操作放到后台线程。但要注意Unity API如Transform.position必须在主线程访问因此“捕获状态”CreateMemento仍需在主线程完成只有最后的字节写入文件可以异步。public async Task SaveGameAsync() { // 在主线程捕获状态 GameSaveData saveData CaptureGameState(); // 在后台线程序列化和写入注意JsonUtility不能在子线程用需提前在主线程序列化 string jsonString JsonUtility.ToJson(saveData); await Task.Run(() { File.WriteAllText(currentSaveFilePath, jsonString); }); Debug.Log(Save async complete.); }5.4 数据安全与防作弊如网络资料所述本地存档没有绝对安全。但我们可以增加作弊成本加密对序列化后的 JSON 字符串或二进制流进行 AES 等对称加密。密钥可以硬编码容易被反编译或由服务器下发在线游戏。这能防止普通玩家直接记事本修改。校验和在存档数据中加入一个校验和如 CRC32 或 MD5该校验和基于数据本身和某个盐值计算。加载时重新计算并比对不一致则说明数据被篡改。关键逻辑服务器校验对于在线游戏所有关键逻辑如抽奖结果、伤害计算都应在服务器进行客户端只发送操作指令。存档可以保存在服务器本地只存缓存。6. 常见问题排查与实战心得踩过无数坑后我总结了一些典型问题和处理技巧。6.1 问题排查速查表现象可能原因排查步骤与解决方案加载后对象状态未恢复1. Originator 未正确注册。2. OriginatorId 不匹配。3. Memento 序列化/反序列化失败。1. 检查RegisterOriginator是否在Awake/Start中调用且早于LoadGame。2. 打印存档和恢复时的OriginatorId进行比对。3. 检查Memento类是否标记为[System.Serializable]所有字段是否都可序列化避免UnityEngine.Object引用。存档文件为空或损坏1. 序列化过程出错如循环引用。2. 文件写入权限问题。3. 加密/解密逻辑错误。1. 先禁用加密将序列化后的 JSON 字符串打印出来 (Debug.Log)看是否完整。2. 检查Application.persistentDataPath路径确保可写。3. 逐步调试加密解密流程对比输入输出。存档/加载时游戏卡顿1. 一次性处理对象过多。2.JsonUtility序列化大型复杂结构慢。3. 文件 IO 阻塞主线程。1. 实现分帧异步保存。2. 考虑使用MessagePack等二进制序列化器。3. 使用异步文件 API (WriteAllTextAsync)。版本更新后旧存档无法加载存档数据结构发生不兼容变更。实现版本号和迁移系统见 5.1 节。WebGL 平台存档失败WebGL 对文件系统访问限制大Application.persistentDataPath实际是浏览器 IndexedDB。使用PlayerPrefs存储小量数据或使用UnityEngine.Networking.UnityWebRequest将存档数据发送到服务器。对于纯本地可使用PlayerPrefs存储序列化后的字符串注意大小限制。6.2 我的实战心得与技巧为 MonoBehaviour 使用[System.Serializable]的陷阱很多人以为给 MonoBehaviour 加上[System.Serializable]就能被JsonUtility序列化。其实不然JsonUtility只能序列化其字段不能序列化 MonoBehaviour 本身它是一个组件。我们的备忘录必须是纯粹的 C# 类 (System.Object)存储数据而不是组件引用。处理 Unity 特有类型Vector3,Quaternion,Color,AnimationCurve等都不能直接被JsonUtility序列化。需要像上面例子一样为它们创建可序列化的包装结构体 (SerializableVector3)。Unity 官方也有一个JsonVectorConverter的方案但自定义结构体更直观可控。字典Dictionary的序列化JsonUtility不支持直接序列化字典。常用解决方案有序列化前转换为两个并列的数组ListTKey和ListTValue。使用[Serializable]包装一个ListKeyValuePair。如果使用Newtonsoft.Json或MessagePack则原生支持字典。ScriptableObject 作为静态配置库这是管理物品、技能、任务等静态数据的绝佳方式。存档里只存ScriptableObject的GUID或自定义string ID加载时通过一个中央AssetManager根据 ID 加载对应的ScriptableObject来还原。这完美分离了动态存档数据和静态配置。在编辑器中调试存档在Editor文件夹下创建一个编辑器工具可以一键读取、解析、甚至修改存档文件内容这对于调试复杂的状态问题至关重要。你可以直接打印出存档的 JSON 内容直观查看哪里出了问题。最后记住没有银弹。备忘录模式提供了优秀的架构指导但具体实现细节需要根据你的项目需求不断调整。从简单的JsonUtility开始逐步迭代处理好版本兼容和性能问题你的游戏存档系统就能成为坚实可靠的后盾而不是项目后期的“技术债”。这套模式的价值会在你需要实现快速存档、关卡重玩、观战系统甚至回放功能时愈发凸显出来。