Unity游戏开发:构建基于Json序列化与AES加密的健壮存档系统 1. 项目概述为什么我们需要一个健壮的存档系统在Unity游戏开发中存档系统是连接玩家与游戏世界的桥梁它直接决定了玩家的游戏体验和游戏数据的长期价值。一个简陋的、不稳定的存档系统轻则导致玩家进度丢失、挫败感倍增重则可能引发数据被篡改、游戏平衡被破坏等严重问题。尤其是在当下单机游戏也常常具备在线排行榜、云存档同步等特性数据的安全性和可靠性变得前所未有的重要。我见过太多项目在初期使用PlayerPrefs草草了事后期却不得不面对数据膨胀难以管理、无法存储复杂对象、数据明文存储易被修改等一系列头疼问题。因此构建一个基于Json序列化并辅以AES加密的存档系统几乎成了中大型Unity项目的标配。这套方案的核心价值在于结构化存储与数据安全。Json提供了人类可读、机器易解析的灵活格式而AES加密则为这份灵活套上了一层坚固的盔甲防止内存修改器如Cheat Engine或简单的文件编辑轻易得手。这个系统适合所有希望提升项目专业度、保障玩家数据安全的Unity开发者。无论你是在做一款RPG、模拟经营还是独立解谜游戏一个可靠的存档系统都是你项目基石的一部分。接下来我将拆解这套系统的完整实现从设计思路到每一行代码的考量并分享我在实际项目中踩过的坑和总结的技巧。2. 系统核心架构与设计思路拆解2.1 告别PlayerPrefs为何选择Json序列化PlayerPrefs是Unity初学者最常接触的存储方式它简单易用适合存储少量简单的键值对比如音效开关、语言设置。但它有几个致命的缺陷使其无法胜任核心游戏进度的存档工作数据类型局限本质上只支持int,float,string三种类型。你想存一个玩家的背包列表ListItem、一个角色的复杂状态类或者一个嵌套的关卡数据结构PlayerPrefs无能为力。结构化缺失所有数据都是扁平的键值对缺乏层次关系。管理大量相关数据时命名会变得极其混乱如Player_Level,Player_Exp,Player_Item_1_ID...。性能与容量数据量稍大时读写效率低下且在某些平台如WebGL有存储容量限制。安全性为零数据通常以明文形式存储在注册表或特定格式的文件中玩家可以轻易找到并修改。Json序列化完美地解决了前三个问题。它将一个复杂的C#对象比如你的GameSaveData类转换成一个结构化的文本字符串。这个字符串清晰地反映了对象的层次结构可以轻松存储数组、列表、字典、嵌套类等复杂数据。在Unity中我们通常使用Newtonsoft.Json即Json.NET或Unity 2020.1后内置的UnityEngine.JsonUtility来进行序列化操作。注意JsonUtility虽然轻量且与Unity集成好但它不支持字典(Dictionary)等复杂类型的直接序列化且功能相对有限。对于复杂的存档系统我强烈推荐使用功能更强大的Newtonsoft.Json通过Unity的Package Manager安装Newtonsoft Json包它提供了更灵活的配置、更好的错误处理和更广泛的类型支持。2.2 安全第一AES加密的必要性与原理浅析解决了结构化存储接下来就是安全。为什么是AESAES高级加密标准是一种对称加密算法已成为全球加密数据的黄金标准。对称加密意味着加密和解密使用同一把密钥其特点是速度快、安全性高非常适合用来加密像存档文件这样可能较大的数据块。其工作流程可以简单类比为序列化将你的GameSaveData对象转换为Json字符串明文。加密使用一个你预先定义好的“密钥”(Key)和“初始化向量”(IV)通过AES算法将Json字符串明文转换为一堆完全无法看懂的乱码密文。存储将密文通常是Base64编码后的字符串写入硬盘。读取读取文件得到密文用同样的Key和IV解密得到Json字符串再反序列化为GameSaveData对象。这里的关键在于Key和IV的保管。它们就像是保险箱的密码必须妥善存放不能硬编码在脚本里否则反编译一下就暴露了。一个常见的实践是将Key和IV拆分成多个部分动态组合或者与设备的一些唯一标识符如SystemInfo.deviceUniqueIdentifier进行运算后生成增加破解难度。但请注意对于坚定的破解者客户端没有任何秘密是完全安全的我们的目标是提高修改门槛防止99%的普通玩家或简单作弊工具轻易得手。2.3 系统模块划分一个完整的存档系统通常包含以下几个核心模块数据模型层定义SaveData类包含所有需要持久化的游戏数据。序列化/反序列化层负责将数据模型与Json字符串互相转换。加密/解密层在序列化后、写入文件前进行加密在读取文件后、反序列化前进行解密。文件IO层处理文件的读写操作决定存档路径如Application.persistentDataPath。管理层对外提供简洁的API如SaveGame(),LoadGame(),DeleteSave()并可能包含多存档位、自动存档等高级功能。3. 核心实现从数据模型到文件落地3.1 定义可序列化的存档数据模型这是整个系统的基石。你的数据模型设计得好后续的序列化和管理都会轻松很多。using System; using System.Collections.Generic; using UnityEngine; // 使用 Serializable 特性确保可以被序列化 [System.Serializable] public class GameSaveData { // 基础玩家信息 public string playerName; public int playerLevel; public float currentExp; public DateTime lastSaveTime; // DateTime 可以被 Newtonsoft.Json 正确处理 // 复杂数据结构背包物品列表 [System.Serializable] public class InventoryItem { public string itemId; public string itemName; public int count; public bool isEquipped; } public ListInventoryItem inventory new ListInventoryItem(); // 复杂数据结构关卡进度字典 // 注意JsonUtility 无法直接序列化 Dictionary但 Newtonsoft.Json 可以。 // 如果使用 JsonUtility可以考虑用 [Serializable] 的类包装 ListKeyValuePair。 public Dictionarystring, LevelProgress levelProgress new Dictionarystring, LevelProgress(); // 玩家位置等Unity特定类型 // Vector3, Quaternion 等不是原生可序列化类型需要转换 [System.Serializable] public class SerializableVector3 { public float x, y, z; public SerializableVector3(Vector3 vec) { x vec.x; y vec.y; z vec.z; } public Vector3 ToVector3() { return new Vector3(x, y, z); } } public SerializableVector3 playerPosition; // 构造函数或初始化方法 public GameSaveData() { playerName Traveler; playerLevel 1; currentExp 0; lastSaveTime DateTime.Now; playerPosition new SerializableVector3(Vector3.zero); } } [System.Serializable] public class LevelProgress { public bool isUnlocked; public bool isCompleted; public float bestClearTime; public int starsEarned; }实操心得版本兼容性考虑在GameSaveData中加入一个int saveVersion字段。未来游戏更新存档结构可能改变通过版本号可以在加载旧存档时进行数据迁移和升级避免崩溃。Unity类型处理Vector3,Color,Quaternion等Unity引擎类型默认不能被直接序列化为Json。像上面那样为它们创建可序列化的包装类SerializableVector3是标准做法。也可以使用Newtonsoft.Json的转换器JsonConverter来更优雅地处理。避免循环引用如果Player类引用Weapon而Weapon又引用了Player序列化时会进入死循环。使用[JsonIgnore]特性Newtonsoft.Json可以忽略特定属性。3.2 实现AES加密解密工具类我们将创建一个静态工具类来封装AES操作确保密钥管理相对安全。using System; using System.IO; using System.Security.Cryptography; using System.Text; public static class AesEncryptionUtility { // 关键密钥和IV。绝对不要直接这样硬编码在发布版本中 // 这里仅为演示。实际项目中应从更安全的方式获取或派生。 private static readonly string DefaultKey Your32ByteLongEncryptionKey!!; // 必须是32字节256位 private static readonly string DefaultIV Your16ByteLongInitVec; // 必须是16字节128位 /// summary /// 使用AES加密字符串 /// /summary /// param nameplainText明文/param /// param namekey密钥32字节/param /// param nameiv初始化向量16字节/param /// returnsBase64编码的加密后字符串/returns public static string Encrypt(string plainText, string key null, string iv null) { key key ?? DefaultKey; iv iv ?? DefaultIV; // 参数检查 if (string.IsNullOrEmpty(plainText)) throw new ArgumentNullException(nameof(plainText)); if (key.Length ! 32) throw new ArgumentException(Key must be 32 bytes for AES-256., nameof(key)); if (iv.Length ! 16) throw new ArgumentException(IV must be 16 bytes for AES-128 CBC mode., nameof(iv)); using (Aes aesAlg Aes.Create()) { aesAlg.Key Encoding.UTF8.GetBytes(key); aesAlg.IV Encoding.UTF8.GetBytes(iv); // 使用CBC模式和PKCS7填充这是常见且安全的组合 aesAlg.Mode CipherMode.CBC; aesAlg.Padding PaddingMode.PKCS7; ICryptoTransform encryptor aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV); using (MemoryStream msEncrypt new MemoryStream()) { using (CryptoStream csEncrypt new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write)) { using (StreamWriter swEncrypt new StreamWriter(csEncrypt)) { swEncrypt.Write(plainText); } } // 将加密后的字节数组转换为Base64字符串便于作为文本存储 return Convert.ToBase64String(msEncrypt.ToArray()); } } } /// summary /// 使用AES解密字符串 /// /summary /// param namecipherTextBase64编码的密文/param /// param namekey密钥32字节/param /// param nameiv初始化向量16字节/param /// returns解密后的明文/returns public static string Decrypt(string cipherText, string key null, string iv null) { key key ?? DefaultKey; iv iv ?? DefaultIV; if (string.IsNullOrEmpty(cipherText)) throw new ArgumentNullException(nameof(cipherText)); if (key.Length ! 32) throw new ArgumentException(Key must be 32 bytes for AES-256., nameof(key)); if (iv.Length ! 16) throw new ArgumentException(IV must be 16 bytes for AES-128 CBC mode., nameof(iv)); try { byte[] buffer Convert.FromBase64String(cipherText); using (Aes aesAlg Aes.Create()) { aesAlg.Key Encoding.UTF8.GetBytes(key); aesAlg.IV Encoding.UTF8.GetBytes(iv); aesAlg.Mode CipherMode.CBC; aesAlg.Padding PaddingMode.PKCS7; ICryptoTransform decryptor aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV); using (MemoryStream msDecrypt new MemoryStream(buffer)) { using (CryptoStream csDecrypt new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read)) { using (StreamReader srDecrypt new StreamReader(csDecrypt)) { return srDecrypt.ReadToEnd(); } } } } } catch (FormatException) { throw new Exception(Cipher text is not a valid Base64 string.); } catch (CryptographicException ex) { // 密钥错误或数据被篡改会抛出此异常 throw new Exception(Decryption failed. The key might be incorrect or the data has been corrupted., ex); } } // 一个简单的但并非绝对安全的密钥生成示例将设备ID与固定盐值结合 public static (string derivedKey, string derivedIV) GenerateKeyFromDevice(string salt MyGameSalt) { string deviceId SystemInfo.deviceUniqueIdentifier; // 注意WebGL等平台可能不稳定 using (var sha256 SHA256.Create()) { // 生成密钥 byte[] keyBytes sha256.ComputeHash(Encoding.UTF8.GetBytes(deviceId salt KeyPart)); string derivedKey Convert.ToBase64String(keyBytes).Substring(0, 32); // 取前32字符 // 生成IV byte[] ivBytes sha256.ComputeHash(Encoding.UTF8.GetBytes(salt deviceId IVPart)); string derivedIV Convert.ToBase64String(ivBytes).Substring(0, 16); // 取前16字符 return (derivedKey, derivedIV); } } }注意事项密钥管理是核心安全风险上述代码中的DefaultKey和DefaultIV绝不能出现在最终发布的游戏版本中。攻击者可以通过反编译你的DLL轻易找到它们。GenerateKeyFromDevice方法提供了一种思路但它也不是万无一失设备ID可能重复或为空。更复杂的方案可以结合服务器下发、代码混淆等手段。模式与填充我们选择了CipherMode.CBC密码分组链接模式和PaddingMode.PKCS7这是实践中非常常见和安全的组合。不要使用不安全的模式如ECB。错误处理Decrypt方法中的CryptographicException异常需要妥善处理这通常意味着存档文件损坏或被非法修改。3.3 构建存档管理器SaveManager这是对外提供服务的单例类整合了序列化、加密和文件操作。using System; using System.IO; using Newtonsoft.Json; // 使用Newtonsoft.Json using UnityEngine; public class SaveManager : MonoBehaviour { public static SaveManager Instance { get; private set; } // 存档文件名和路径 private const string SAVE_FILE_NAME game_save.dat; private string SaveFilePath Path.Combine(Application.persistentDataPath, SAVE_FILE_NAME); // 当前存档数据内存中的副本 public GameSaveData CurrentSaveData { get; private set; } // 加密密钥在实际项目中应从安全的地方获取 private string _encryptionKey; private string _encryptionIV; private void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); return; } Instance this; DontDestroyOnLoad(this.gameObject); // 通常存档管理器常驻场景 InitializeEncryptionKeys(); LoadGame(); // 游戏启动时尝试加载存档 } private void InitializeEncryptionKeys() { // 示例从安全的地方获取密钥。这里演示动态生成。 // 重要生产环境需要更复杂的方案例如将密钥分段存储、与服务器验证等。 var keys AesEncryptionUtility.GenerateKeyFromDevice(MyGameSalt2024); _encryptionKey keys.derivedKey; _encryptionIV keys.derivedIV; // 作为备选可以检查生成的密钥长度是否正确 if (_encryptionKey.Length ! 32 || _encryptionIV.Length ! 16) { Debug.LogError(Generated encryption keys are of incorrect length!); // 可以回退到一个硬编码的、经过混淆的密钥但这会降低安全性。 } } /// summary /// 保存游戏数据 /// /summary /// param namesaveData要保存的数据如果为null则保存当前数据/param /// returns是否保存成功/returns public bool SaveGame(GameSaveData saveData null) { try { // 1. 确定要保存的数据 GameSaveData dataToSave saveData ?? CurrentSaveData; if (dataToSave null) { dataToSave new GameSaveData(); // 如果没有数据创建一个新的 } dataToSave.lastSaveTime DateTime.Now; // 2. 序列化为Json字符串 // 使用 Newtonsoft.Json可以配置忽略空值、美化格式等 JsonSerializerSettings settings new JsonSerializerSettings { Formatting Formatting.Indented, // 美化输出便于调试正式发布可改为 None NullValueHandling NullValueHandling.Ignore, // 如果需要处理循环引用 // ReferenceLoopHandling ReferenceLoopHandling.Ignore }; string jsonString JsonConvert.SerializeObject(dataToSave, settings); // 3. 加密Json字符串 string encryptedString AesEncryptionUtility.Encrypt(jsonString, _encryptionKey, _encryptionIV); // 4. 写入文件 File.WriteAllText(SaveFilePath, encryptedString); Debug.Log($游戏存档成功保存至{SaveFilePath}); // 5. 更新内存中的数据副本 CurrentSaveData dataToSave; return true; } catch (Exception e) { Debug.LogError($保存游戏失败{e.Message}\n{e.StackTrace}); return false; } } /// summary /// 加载游戏数据 /// /summary /// returns加载的存档数据如果失败返回null或新数据/returns public GameSaveData LoadGame(bool createIfNotExist true) { // 检查存档文件是否存在 if (!File.Exists(SaveFilePath)) { Debug.LogWarning(存档文件不存在。); if (createIfNotExist) { CurrentSaveData new GameSaveData(); SaveGame(); // 创建并保存一个新存档 Debug.Log(已创建新的存档文件。); } else { CurrentSaveData null; } return CurrentSaveData; } try { // 1. 读取加密文件 string encryptedString File.ReadAllText(SaveFilePath); // 2. 解密字符串 string jsonString AesEncryptionUtility.Decrypt(encryptedString, _encryptionKey, _encryptionIV); // 3. 反序列化为对象 JsonSerializerSettings settings new JsonSerializerSettings { // 如果存档结构有变化可以在这里添加错误处理或类型转换 Error (sender, args) { // 处理反序列化错误例如字段不匹配 Debug.LogWarning($反序列化错误{args.ErrorContext.Error.Message}); args.ErrorContext.Handled true; // 标记为已处理继续反序列化其他部分 } }; CurrentSaveData JsonConvert.DeserializeObjectGameSaveData(jsonString, settings); // 4. 版本迁移检查示例 if (CurrentSaveData ! null) { // 假设我们添加了 saveVersion 字段 // if (CurrentSaveData.saveVersion CURRENT_SAVE_VERSION) { MigrateSaveData(CurrentSaveData); } } Debug.Log($游戏存档从 {SaveFilePath} 加载成功。); return CurrentSaveData; } catch (CryptographicException ex) { // 解密失败可能是密钥错误或文件被篡改 Debug.LogError($存档解密失败文件可能已损坏或被修改{ex.Message}); // 可以在这里给玩家一个提示或者尝试加载一个备份文件 if (createIfNotExist) { CurrentSaveData new GameSaveData(); SaveGame(); } else { CurrentSaveData null; } return CurrentSaveData; } catch (Exception e) { Debug.LogError($加载游戏失败{e.Message}\n{e.StackTrace}); if (createIfNotExist) { CurrentSaveData new GameSaveData(); SaveGame(); } else { CurrentSaveData null; } return CurrentSaveData; } } /// summary /// 删除存档文件 /// /summary public void DeleteSave() { if (File.Exists(SaveFilePath)) { File.Delete(SaveFilePath); CurrentSaveData null; Debug.Log(存档文件已删除。); } else { Debug.LogWarning(尝试删除不存在的存档文件。); } } /// summary /// 获取存档文件的路径可用于显示给玩家或备份 /// /summary public string GetSaveFilePath() { return SaveFilePath; } /// summary /// 检查存档是否存在 /// /summary public bool DoesSaveFileExist() { return File.Exists(SaveFilePath); } // 示例在游戏退出时自动保存可选 private void OnApplicationQuit() { if (CurrentSaveData ! null) { SaveGame(); } } // 对于移动平台还需要监听 OnApplicationPause 事件 private void OnApplicationPause(bool pauseStatus) { if (pauseStatus CurrentSaveData ! null) // 应用进入后台 { SaveGame(); } } }4. 高级功能与优化实践4.1 多存档位与存档槽管理一个完整的游戏通常支持多个存档槽。我们可以通过修改文件命名规则和SaveManager来轻松实现。public class SaveManager : MonoBehaviour { // ... 其他代码 ... private const string SAVE_FILE_PREFIX save_slot_; private const string SAVE_FILE_EXTENSION .dat; private int _currentSlot 0; // 当前选中的存档槽 public void SetCurrentSlot(int slotIndex) { if (slotIndex 0) slotIndex 0; _currentSlot slotIndex; } private string GetSaveFilePathForSlot(int slotIndex) { string fileName ${SAVE_FILE_PREFIX}{slotIndex}{SAVE_FILE_EXTENSION}; return Path.Combine(Application.persistentDataPath, fileName); } public bool SaveGameToSlot(int slotIndex, GameSaveData data) { int previousSlot _currentSlot; _currentSlot slotIndex; // 临时替换文件路径逻辑这里需要重构更好的做法是将文件路径作为参数传递。 // 为了清晰我们创建一个新的方法或重构内部逻辑。 // 此处示意实际需要调整 SaveGame 内部使用 GetSaveFilePathForSlot(_currentSlot) bool result SaveGame(data); _currentSlot previousSlot; return result; } public GameSaveData LoadGameFromSlot(int slotIndex, bool createIfNotExist false) { // 临时切换槽位并加载 string tempFilePath GetSaveFilePathForSlot(slotIndex); // 同样需要重构 LoadGame 以接受文件路径参数。 // 简化的做法是将文件读取、解密、反序列化的核心逻辑抽离成一个私有方法接受文件路径。 // 这里为了示例我们假设有一个 LoadFromSpecificPath 方法。 return LoadFromSpecificPath(tempFilePath, createIfNotExist); } public SaveFileInfo GetSaveFileInfo(int slotIndex) { string path GetSaveFilePathForSlot(slotIndex); SaveFileInfo info new SaveFileInfo(); info.slotIndex slotIndex; info.exists File.Exists(path); if (info.exists) { try { // 注意为了效率可以只读取和解析文件头部分信息而不是整个文件。 // 一种常见做法是在存档时额外保存一个小的、未加密或不同方式加密的摘要文件包含缩略图、时间、角色名等。 string encryptedString File.ReadAllText(path); string jsonString AesEncryptionUtility.Decrypt(encryptedString, _encryptionKey, _encryptionIV); var tempData JsonConvert.DeserializeObjectGameSaveData(jsonString); info.playerName tempData.playerName; info.playerLevel tempData.playerLevel; info.lastSaveTime tempData.lastSaveTime; // 可以生成一个游戏场景的缩略图Base64字符串存储在这里 } catch { info.isCorrupted true; } } return info; } public class SaveFileInfo { public int slotIndex; public bool exists; public bool isCorrupted; public string playerName; public int playerLevel; public DateTime lastSaveTime; public Texture2D thumbnail; // 存档缩略图 } }4.2 自动存档与存档点设计除了手动保存合理的自动存档机制能极大提升体验。定时存档在非关键流程如安全区定时保存避免长时间游戏丢失进度。可以使用InvokeRepeating或协程实现但频率不宜过高如每5-10分钟。事件驱动存档场景切换时在加载新场景前自动保存。玩家死亡/任务完成时保存关键节点。游戏退出/切到后台时如上面代码中的OnApplicationQuit和OnApplicationPause。存档点设计在关卡设计中明确“存档点”位置如篝火、电话亭。当玩家触发时调用SaveManager.Instance.SaveGame()并可以伴随一个UI提示和音效。4.3 性能优化与内存管理避免频繁的完整序列化/反序列化如果只更新一小部分数据如玩家金币频繁进行整个GameSaveData的Json序列化和文件IO是浪费的。可以考虑差分存档维护一个“脏数据”列表只将变化的部分序列化并附加到主存档文件或另一个差分文件中。加载时合并。二进制格式对于极其庞大的、结构固定的数据如开放世界地图状态Protobuf或MessagePack等二进制序列化库比Json更高效体积更小。可以混合使用核心元数据用Json海量状态数据用二进制。异步保存File.WriteAllText是同步操作在写入大量数据时可能引起卡顿。可以使用File.WriteAllTextAsync.NET 4.x及以上或StreamWriter配合async/await进行异步文件写入避免阻塞主线程。存档压缩对于较大的存档可以在加密前使用System.IO.Compression.GZipStream进行压缩减少磁盘占用。但需要权衡CPU时间和IO时间。5. 实战中遇到的坑与解决方案5.1 版本迭代与存档兼容性这是维护线上游戏时最头疼的问题之一。你的GameSaveData类在版本1.1增加了一个新字段public string newField;但玩家加载的是1.0版本的存档反序列化时这个字段会是默认值null这通常是可接受的。但如果删除或重命名字段或者更改了字段类型直接反序列化就会失败。解决方案永远不删除字段将过时的字段标记为[Obsolete]并保留在类中。使用版本号在GameSaveData根节点添加public int saveVersion;。每次存档结构有不兼容变更时递增这个版本号。实现迁移函数在LoadGame方法中根据加载出来的saveVersion调用对应的迁移方法MigrateFromV1ToV2(data)将旧数据结构转换为新结构。private GameSaveData MigrateSaveData(GameSaveData oldData, int fromVersion, int toVersion) { GameSaveData migratedData oldData; for (int v fromVersion; v toVersion; v) { switch (v) { case 1: migratedData MigrateFromV1ToV2(migratedData); break; case 2: migratedData MigrateFromV2ToV3(migratedData); break; // ... 其他版本迁移 } } migratedData.saveVersion toVersion; return migratedData; } private GameSaveData MigrateFromV1ToV2(GameSaveData v1Data) { // 假设V1没有playerNameV2新增了。 if (string.IsNullOrEmpty(v1Data.playerName)) { v1Data.playerName Hero; // 给一个默认值 } // 假设V1的inventory是数组V2改成了List但Newtonsoft.Json通常能处理。 // 如果需要复杂转换在这里进行。 return v1Data; }5.2 加密密钥的安全存储进阶硬编码是死路一条动态生成也有局限。这里提供几个进阶思路密钥分割与混淆将密钥字符串分割成多个部分分散在不同的脚本、资源文件甚至AssetBundle中。运行时再拼接起来。可以结合简单的位运算或字符串操作进行混淆。与环境变量/注册表结合PC平台将部分密钥信息存储在系统环境变量或注册表中需要玩家读写权限。服务器验证适用于有在线功能的游戏客户端启动时从服务器获取一个“令牌”或加密种子与本地存储的片段结合生成最终密钥。即使客户端被破解没有服务器的响应也无法生成正确密钥。这是相对安全的方法但增加了网络依赖。使用Unity的PlayerPrefs加密Unity自带的PlayerPrefs在某些平台有简单的加密。可以将AES密钥的一部分用PlayerPrefs存储键名要混淆但这仍然不是绝对安全。核心原则没有绝对安全的客户端存储。我们的目标是提高攻击成本让修改存档变得麻烦从而保护大多数玩家的游戏体验和游戏的公平性对于有竞争元素的游戏。5.3 处理Unity特殊类型与引用如之前所述Vector3,Color,Transform引用等不能直接序列化。对于值类型Vector3, Quaternion, Color等创建可序列化的包装类如SerializableVector3并提供与原生类型的转换方法。对于UnityEngine.Object的引用如对某个Prefab, Material的引用不要直接保存引用。因为场景中的实例ID (instanceID) 在每次运行时都可能不同。应该保存一个逻辑标识符比如一个字符串ID或GUID在加载时根据这个ID去资源管理器或配置表中查找并重新赋值。// 错误做法 // public GameObject equippedWeaponPrefab; // 运行时引用序列化会丢失或出错 // 正确做法 public string equippedWeaponId; // 如 weapon_sword_01 // 加载时GameObject weaponPrefab ResourceManager.LoadWeapon(equippedWeaponId);5.4 存档文件损坏与异常处理网络下载、磁盘错误、游戏崩溃都可能导致存档文件损坏。完整性校验在保存时可以计算整个GameSaveData或加密后数据的哈希值如MD5或SHA256并将其一起保存可以存在另一个文件或追加在密文后。加载时重新计算并比对不一致则说明文件可能损坏。备份系统实现自动备份。每次成功保存时将上一次的存档文件复制为backup.dat。当主存档加载失败时尝试加载备份文件并提示玩家。清晰的错误提示捕获CryptographicException,JsonSerializationException等异常并向玩家展示友好的提示如“存档文件已损坏正在尝试从备份恢复”或“无法读取存档可能已被其他程序修改”。5.5 WebGL平台的特别注意事项WebGL的存储Application.persistentDataPath基于浏览器的IndexedDB有其特殊性存储空间限制不同浏览器限制不同通常至少50MB但可能更大。对于大型存档要留意。异步操作WebGL上的文件操作本质是异步的。Unity的File.WriteAllText在WebGL下会通过Emscripten同步模拟但可能效率不高。对于大量数据考虑分帧处理或使用UnityEngine.Networking.UnityWebRequest上传到服务器如果需要。密钥生成SystemInfo.deviceUniqueIdentifier在WebGL上可能不稳定或每次不同。考虑使用浏览器的本地存储 (PlayerPrefs) 来保存一个首次运行时生成的GUID作为设备标识用于派生密钥。构建一个健壮的Unity存档系统远不止是调用几个API那么简单。它涉及到数据模型设计、序列化方案选型、安全攻防、性能考量、平台适配和长期维护。从简单的PlayerPrefs升级到这套JsonAES的方案是项目走向专业化的重要一步。我个人的体会是在项目早期就搭建好这个框架并随着开发进程不断丰富GameSaveData的内容远比后期重构要轻松得多。最后一个小技巧在开发阶段可以通过一个调试命令如按F5来输出当前存档的解密后Json文本到控制台这对于调试存档内容异常方便。