Unity游戏开发:构建基于Json序列化与AES加密的健壮存档系统

Unity游戏开发:构建基于Json序列化与AES加密的健壮存档系统

1. 项目概述:为什么我们需要一个健壮的存档系统?

在Unity游戏开发中,存档系统是连接玩家与游戏世界的桥梁,它直接决定了玩家的游戏体验和游戏数据的长期价值。一个简陋的、不稳定的存档系统,轻则导致玩家进度丢失、挫败感倍增,重则可能引发数据被篡改、游戏平衡被破坏等严重问题。尤其是在当下,单机游戏也常常具备在线排行榜、云存档同步等特性,数据的安全性和可靠性变得前所未有的重要。

我见过太多项目在初期使用PlayerPrefs草草了事,后期却不得不面对数据膨胀难以管理、无法存储复杂对象、数据明文存储易被修改等一系列头疼问题。因此,构建一个基于Json序列化并辅以AES加密的存档系统,几乎成了中大型Unity项目的标配。这套方案的核心价值在于:结构化存储数据安全。Json提供了人类可读、机器易解析的灵活格式,而AES加密则为这份灵活套上了一层坚固的盔甲,防止内存修改器(如Cheat Engine)或简单的文件编辑轻易得手。

这个系统适合所有希望提升项目专业度、保障玩家数据安全的Unity开发者。无论你是在做一款RPG、模拟经营还是独立解谜游戏,一个可靠的存档系统都是你项目基石的一部分。接下来,我将拆解这套系统的完整实现,从设计思路到每一行代码的考量,并分享我在实际项目中踩过的坑和总结的技巧。

2. 系统核心架构与设计思路拆解

2.1 告别PlayerPrefs:为何选择Json序列化?

PlayerPrefs是Unity初学者最常接触的存储方式,它简单易用,适合存储少量简单的键值对,比如音效开关、语言设置。但它有几个致命的缺陷,使其无法胜任核心游戏进度的存档工作:

  1. 数据类型局限:本质上只支持int,float,string三种类型。你想存一个玩家的背包列表(List<Item>)、一个角色的复杂状态类,或者一个嵌套的关卡数据结构?PlayerPrefs无能为力。
  2. 结构化缺失:所有数据都是扁平的键值对,缺乏层次关系。管理大量相关数据时,命名会变得极其混乱(如Player_Level,Player_Exp,Player_Item_1_ID...)。
  3. 性能与容量:数据量稍大时,读写效率低下,且在某些平台(如WebGL)有存储容量限制。
  4. 安全性为零:数据通常以明文形式存储在注册表或特定格式的文件中,玩家可以轻易找到并修改。

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加密的必要性与原理浅析

解决了结构化存储,接下来就是安全。为什么是AES?AES(高级加密标准)是一种对称加密算法,已成为全球加密数据的黄金标准。对称加密意味着加密和解密使用同一把密钥,其特点是速度快、安全性高,非常适合用来加密像存档文件这样可能较大的数据块。

其工作流程可以简单类比为:

  1. 序列化:将你的GameSaveData对象转换为Json字符串(明文)。
  2. 加密:使用一个你预先定义好的“密钥”(Key)和“初始化向量”(IV),通过AES算法将Json字符串(明文)转换为一堆完全无法看懂的乱码(密文)。
  3. 存储:将密文(通常是Base64编码后的字符串)写入硬盘。
  4. 读取:读取文件得到密文,用同样的Key和IV解密,得到Json字符串,再反序列化为GameSaveData对象。

这里的关键在于Key和IV的保管。它们就像是保险箱的密码,必须妥善存放,不能硬编码在脚本里(否则反编译一下就暴露了)。一个常见的实践是:将Key和IV拆分成多个部分,动态组合,或者与设备的一些唯一标识符(如SystemInfo.deviceUniqueIdentifier)进行运算后生成,增加破解难度。但请注意,对于坚定的破解者,客户端没有任何秘密是完全安全的,我们的目标是提高修改门槛,防止99%的普通玩家或简单作弊工具轻易得手。

2.3 系统模块划分

一个完整的存档系统通常包含以下几个核心模块:

  1. 数据模型层:定义SaveData类,包含所有需要持久化的游戏数据。
  2. 序列化/反序列化层:负责将数据模型与Json字符串互相转换。
  3. 加密/解密层:在序列化后、写入文件前进行加密;在读取文件后、反序列化前进行解密。
  4. 文件IO层:处理文件的读写操作,决定存档路径(如Application.persistentDataPath)。
  5. 管理层:对外提供简洁的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 List<InventoryItem> inventory = new List<InventoryItem>(); // 复杂数据结构:关卡进度字典 // 注意:JsonUtility 无法直接序列化 Dictionary,但 Newtonsoft.Json 可以。 // 如果使用 JsonUtility,可以考虑用 [Serializable] 的类包装 List<KeyValuePair>。 public Dictionary<string, LevelProgress> levelProgress = new Dictionary<string, 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 name="plainText">明文</param> /// <param name="key">密钥(32字节)</param> /// <param name="iv">初始化向量(16字节)</param> /// <returns>Base64编码的加密后字符串</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 name="cipherText">Base64编码的密文</param> /// <param name="key">密钥(32字节)</param> /// <param name="iv">初始化向量(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); } } }

注意事项

  • 密钥管理是核心安全风险:上述代码中的DefaultKeyDefaultIV绝不能出现在最终发布的游戏版本中。攻击者可以通过反编译你的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 name="saveData">要保存的数据,如果为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.DeserializeObject<GameSaveData>(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.DeserializeObject<GameSaveData>(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分钟)。
  • 事件驱动存档
    • 场景切换时:在加载新场景前自动保存。
    • 玩家死亡/任务完成时:保存关键节点。
    • 游戏退出/切到后台时:如上面代码中的OnApplicationQuitOnApplicationPause
  • 存档点设计:在关卡设计中明确“存档点”位置(如篝火、电话亭)。当玩家触发时,调用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),这通常是可接受的。但如果删除或重命名字段,或者更改了字段类型,直接反序列化就会失败。

解决方案

  1. 永远不删除字段:将过时的字段标记为[Obsolete]并保留在类中。
  2. 使用版本号:在GameSaveData根节点添加public int saveVersion;。每次存档结构有不兼容变更时,递增这个版本号。
  3. 实现迁移函数:在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没有playerName,V2新增了。 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升级到这套Json+AES的方案,是项目走向专业化的重要一步。我个人的体会是,在项目早期就搭建好这个框架,并随着开发进程不断丰富GameSaveData的内容,远比后期重构要轻松得多。最后一个小技巧:在开发阶段,可以通过一个调试命令(如按F5)来输出当前存档的解密后Json文本到控制台,这对于调试存档内容异常方便。