Cocos Creator 本地存储管理器实战:加密防篡改与缓存设计

Cocos Creator 本地存储管理器实战:加密防篡改与缓存设计 1. 为什么要自己写一个本地存储管理器做Cocos Creator游戏开发的早晚会遇到同一个痛点存档往哪儿放。新手阶段用sys.localStorage存几个键值对跑起来没问题可真到了项目中期数据一多、结构一变、需要打包上线时问题就全冒出来了。第一sys.localStorage本身能力太弱。它就是浏览器localStorage的那套封装只能存字符串数字、布尔值、对象全部得自己手动JSON.stringify再JSON.parse。存的时候忘记转一次取的时候忘记转一次线上数据全是[object Object]。第二没有任何版本概念。你游戏发了一个版本存档结构是{level: 3, gold: 100}下个版本需要加一个inventory字段老用户进来直接读取失败开局就崩。第三明文裸奔。单机游戏的存档一旦用明文保存玩家用工具一改金币、体力随便调排行榜和商店体系直接报废。我见过太多小团队因为存档被破解被迫重做离线校验。所以我在新项目里从零写了一个本地存储管理器。核心诉求就三个加密防篡改、内存缓存、类型安全。这三个词不是互相独立的它们互相咬合。先有类型安全才能保证加密层面拿到的是结构正确的数据先有缓存才能把频繁读取从磁盘IO中解放出来同时缓存层又是加密解密逻辑唯一的出入口。这篇文章不做理论空谈直接把我项目中这套存储管理器的完整设计思路、关键代码和踩坑记录分享出来。代码基于Cocos Creator 3.82.x也适用API差异不大适合已经过了会用cc.sys.localStorage阶段、想为项目搭建更可靠存储层的开发者参考。2. 整体设计思路先分层再谈加密和缓存动手写之前我把需求拆了一下。存储管理器不是简单地封装一个类它至少要处理四类问题数据的读写入口、序列化反序列化、加密解密、以及缓存策略。如果把这些逻辑全塞进一个类里功能看起来齐全了但后续每加一个存储字段都要改这个大类风险极高。我最终的分层是这样的接口层StorageManager对外暴露统一的API例如setItemT、getItemT、removeItem、clear调用方不需要关心底层是加密还是普通存储。序列化层Serializer处理对象和字符串的相互转换同时承担类型校验和默认值合并的职责。加密层CryptoHelper负责所有字符串的加密和解密。存储适配层StorageAdapter封装底层sys.localStorage或文件系统未来想换成SQLite或者服务器远程存储只改这一层就行。这个结构的核心思想是单向依赖接口层依赖序列化层序列化层依赖加密层加密层依赖存储适配层。谁都不许反向调。2.1 接口设计要满足什么接口层是整个存储管理器的门面我对外暴露的方法不多大概就这么几个export interface IStorageManager { getItemT(key: string, defaultValue: T): T; setItemT(key: string, value: T): void; removeItem(key: string): void; hasItem(key: string): boolean; clear(): void; }看到getItemT这里的泛型没有这是整个类型安全设计的关键入口。调用方如果写let gold storageManager.getItemnumber(gold, 0)拿到的类型就是number编译阶段就能拦截大量低级错误。如果你实在想省事不传泛型参数编译器也能自动推断但那样就失去了很多保护力。2.2 为什么单独拆一层StorageAdapter不拆这一层的坏处我在老项目里感受太深了。当时直接把加密逻辑和localStorage操作写在一起后来想在PC端改成文件存储才发现代码里到处是cc.sys.localStorage的直接调用改一个底层的读写方式要动二十多个文件。拆出StorageAdapter之后底层逻辑收敛在一个位置。例如判断当前平台是否支持localStorage不支持的平台比如某些小游戏环境就改用内存存储模拟一份这个逻辑现在只存在于StorageAdapter内部export class StorageAdapter { private static _memoryMap: Mapstring, string new Map(); private static _supported: boolean | null null; static isSupported(): boolean { if (this._supported ! null) { return this._supported; } try { const testKey __storage_test__; cc.sys.localStorage.setItem(testKey, 1); cc.sys.localStorage.removeItem(testKey); this._supported true; } catch (e) { this._supported false; } return this._supported; } static getItem(key: string): string | null { if (this.isSupported()) { const value cc.sys.localStorage.getItem(key); return value null ? null : value; } return this._memoryMap.has(key) ? this._memoryMap.get(key)! : null; } static setItem(key: string, value: string): void { if (this.isSupported()) { cc.sys.localStorage.setItem(key, value); } else { this._memoryMap.set(key, value); } } static removeItem(key: string): void { if (this.isSupported()) { cc.sys.localStorage.removeItem(key); } else { this._memoryMap.delete(key); } } }这段代码看起来平平无奇但里面有个细节值得说明isSupported用了缓存标志位因为try-catch在部分安卓WebView里是有性能开销的每一帧调一次肯定不划算。标志位初始化一次之后后续所有读写路径都直接走判断结果。3. 类型安全层比你想的要复杂一点点类型安全不是说用了TypeScript泛型就够了。泛型只是在编译期给你把类型信息带到函数里运行时的数据是不是你期望的结构它管不了。真正要落地类型安全得在序列化层做三件事默认值合并、类型校验、以及结构修正。3.1 默认值合并解决老版本存档的兼容问题游戏存档最头疼的问题就是版本迭代。你上个月存的存档结构是{ selectedHero: 1, unlockedLevels: [1,2,3] }这个月新增了audioSettings或者更麻烦你重构了数据结构把unlockedLevels: [1,2,3]改成了unlockedLevels: {1: true, 2: true, 3: true}老存档拿到手里直接傻眼。我的方案是每个存储项必须有默认值获取时永远与默认值做深度合并。调用方定义默认值序列化层负责把实际存档里的字段和默认值做一层递归合并缺失的补上类型错的抛弃多余的字段保留。function deepMergeT(defaultVal: T, raw: any): T { if (defaultVal null || typeof defaultVal ! object) { // 基础类型直接校验类型不对就用默认值 return typeof raw typeof defaultVal ? raw as T : defaultVal; } if (Array.isArray(defaultVal)) { if (!Array.isArray(raw)) { return defaultVal; } return (raw as Arrayany).map((item, index) { const defaultItem defaultVal[Math.min(index, defaultVal.length - 1)]; return deepMerge(defaultItem, item); }) as unknown as T; } if (typeof defaultVal object) { if (typeof raw ! object || raw null) { return defaultVal; } const result: any { ...raw }; for (const key of Object.keys(defaultVal as any)) { result[key] deepMerge((defaultVal as any)[key], (raw as any)[key]); } return result as T; } return defaultVal; }这个deepMerge不能直接用Object.assign或者展开运算符原因很简单那只能做浅合并。你的存档对象里嵌套了一个equipment对象里面又有weapon: { id: 1, level: 3 }这种深层结构浅合并补不上内层新增的字段。3.2 类型校验别让脏数据进入内存类型校验这一层很多人会忽略。他们默认能取出来并且merge过就没问题实际上merge之后字段值可能是NaN、可能是undefined、可能是一个超范围的数字。我举个例子你的存档里playerLevel这个字段被玩家的各种操作写到99999数值类型没毛病但逻辑上完全失真。所以我在序列化层做了一个简单但有用的东西支持自定义校验器。export interface StorageSchemaT { key: string; defaultValue: T; validator?: (value: T) boolean; } export class Serializer { static decodeT(schema: StorageSchemaT, rawString: string | null): T { if (rawString null || rawString ) { return schema.defaultValue; } try { const rawObject JSON.parse(rawString); const merged deepMerge(schema.defaultValue, rawObject); if (schema.validator) { return schema.validator(merged) ? merged : schema.defaultValue; } return merged; } catch (e) { console.warn(存储数据解析失败返回默认值: ${schema.key}, e); return schema.defaultValue; } } }有了validator这个钩子存储项的业务合法性就有了兜底。比如玩家等级必须在1~100之间就是一个validator: (v) v 1 v 100的事。超范围直接回退默认值比在游戏里到处判断要省心得多。3.3 为什么类型安全要先于加密做这个问题我设计时纠结过。从代码执行顺序看肯定是先解密拿到原文再走deepMerge和校验类型安全在后。但这里说的先不是执行顺序而是结构设计上的优先级。加密层不关心数据长什么样它输入什么字符串就加密什么字符串。类型安全层是承上启下的核心枢纽上面接口层的泛型和下面存储层的字符串全靠序列化层来转换。一个惨痛教训是老项目里我先写了加密后来加类型安全时发现加密解密出的字符串经过JSON.parse之后直接传给业务层脏数据全跑到了逻辑代码里。最后不得不在业务层补各种if—else判断越补越乱。所以先设计好数据的结构约束再考虑如何加密保护这个顺序不能反。4. 加密层实现不是拿个算法跑一遍就完事了加密是存储管理器里面最容易被做浮于表面的部分。很多人的理解是把字符串用XX算法跑一遍就完了实际落地时要考虑的问题远不止这些。4.1 算法选型为什么用XXTEA而不是AESCocos Creator的纯前端/纯客户端场景下选加密算法首先要考虑两件事实现的体积和引入成本。AES-256-CBC是很好的算法密码学上比XXTEA安全得多。但问题在于Cocos Creator的常规Javascript运行环境没有内置crypto模块你要么引入一个完整的加密库体积不小要么自己实现一个AES容易写错。在实际游戏存档场景中绝大部分威胁来自普通玩家用存档修改器改数值而不是国家级密码分析攻击。XXTEA在防随手改存档这个目标上性价比极高。XXTEA的算法原理是对称分组加密密钥128位分组长度可变实现极其紧凑大约100行代码就能搞定。我建议不要让每个项目自己实现XXTEA直接用网上成熟的纯TypeScript实现同时注意把算法跑一遍测试向量验证正确性。当然如果你做的是强联网竞技游戏存档里带大量货币和排名数据用XXTEA就不够了。那种场景的最佳实践是客户端只做轻度混淆核心校验全部走服务器本地加密防不住真正想改的人。4.2 密钥管理这是最容易翻车的地方密钥管理加密环节最容易翻车的就是这里。常见做法是把密钥硬编码在代码里例如const SECRET_KEY mySecretKey123;这种做法在打包之后用解包工具翻一翻JS文件密钥就漏了。稍微好一点的做法是混淆存储把密钥拆成几段通过动态拼接还原让静态搜索没法直接找到完整字符串。function getSecretKey(): string { const part1 coco; const part2 storage; const part3 2024; return ${part1}_${part2}_${part3}; }级别再高一点可以给密钥加盐即存数据时附带一段随机数参与加密运算这样即使两条明文完全一样的记录密文也不一样能对抗已知明文攻击同时密文无法直接用修改后重放的方式篡改。XXTEA天然支持在明文里带随机参数实现不算复杂。4.3 防篡改在密文里加完整性校验单纯加密只是让数据不可读还不能证明数据没被改过。真正防止篡改需要给数据加一个消息认证码MAC最朴素的做法是加密后把明文的哈希值附带上解密后重新计算哈希对比一下不一致就说明数据被改过直接回退默认值。export class CryptoHelper { private static readonly KEY: string your-custom-key-with-salt; static encrypt(plainText: string): string { const checksum this.simpleHash(plainText); const payload JSON.stringify({ d: plainText, c: checksum }); const encrypted XXTEA.encryptToBase64(payload, this.KEY); return encrypted; } static decrypt(cipherText: string): string | null { try { const payload XXTEA.decryptFromBase64(cipherText, this.KEY); if (!payload) return null; const parsed JSON.parse(payload); const checksum this.simpleHash(parsed.d); if (checksum ! parsed.c) { console.warn(存档完整性校验失败可能已被篡改); return null; } return parsed.d; } catch (e) { console.warn(解密失败数据可能已损坏或篡改); return null; } } private static simpleHash(input: string): string { let hash 5381; for (let i 0; i input.length; i) { hash ((hash 5) hash) ^ input.charCodeAt(i); } return (hash 0).toString(16); } }这里用的是simpleHash不是加密安全哈希比如SHA-256。如果需求还没上到对抗专业玩家的程度simpleHash足够防走查级别的篡改了。但你要真想做好完整性建议在项目里引用一个可以算SHA-256的库或者用Cocos自带的AssetManager里某个底层依赖碰巧提供的摘要函数替代掉这个简易哈希。4.4 性能顾虑加密频率不能失控加密算法再轻量也是要消耗CPU的。存一次playerLevel就加密一次这个频率在游戏里可能每分钟触发好几十次一旦存档体积大了会明显卡顿。所以千万不要直接在接口层的setItem里每次都走加密。缓存层在这里就发挥了关键作用——先写内存定期再加密落盘。下面进入缓存机制的设计。5. 缓存机制读写分离才是性能关键缓存的思路一句话就能讲明白读的时候先读内存内存没有才去读解密后的磁盘数据写的时候先写内存标记脏数据然后择机批量落盘。这叫Write-Back策略游戏存档特别吃这一套因为单条数据写入太频繁了不可能每次都走解密-改JSON-加密-写入整条。5.1 全量缓存还是逐条缓存我的经验是全量缓存比逐条缓存更适合游戏存档场景。游戏存档本质上是一个大JSON对象里面几十个字段。逐条缓存意味着维护一个Mapkey是存储项的名字value是解析后的对象读的时候还要判断这条数据在内存里是不是最新的逻辑复杂度很高。全量缓存就简单了启动时一次性把所有存储数据解密、解析、合并成一个大的内存对象_cache之后所有读写都直接操作这个对象只有需要持久化时才把整个对象序列化、加密、写入。export class StorageManager implements IStorageManager { private _cache: Recordstring, any {}; private _dirtyKeys: Setstring new Set(); private _persistTimer: number | null null; private readonly _schemas: Mapstring, StorageSchemaany new Map(); constructor(schemas: StorageSchemaany[]) { for (const schema of schemas) { this._schemas.set(schema.key, schema); } this.loadFromDisk(); } getItemT(key: string, defaultValue: T): T { if (!(key in this._cache)) { const schema this._schemas.get(key); this._cache[key] schema ? schema.defaultValue : defaultValue; } return this._cache[key] as T; } setItemT(key: string, value: T): void { this._cache[key] value; this._dirtyKeys.add(key); this.schedulePersist(); } }注意看getItem里的key in this._cache判断。如果缓存里没有这个key意味着这是新写入的存储字段直接按默认值做初始化避免了undefined进入游戏逻辑的问题。5.2 脏标记与延迟落盘避免频繁加密_dirtyKeys这个Set是整个缓存机制的核心。只有通过setItem写过的key才会被标记只有包含脏key的存储项才需要持久化。但我不想每次setItem都立刻落盘而是把落盘操作推迟到当前帧末或者一个很短的定时器里。private schedulePersist(): void { if (this._persistTimer) { return; } // 使用 setTimeout 模拟一个短延迟批处理 this._persistTimer setTimeout(() { this.persist(); this._persistTimer null; }, 1000) as unknown as number; } private persist(): void { if (this._dirtyKeys.size 0) { return; } const dataToSave: Recordstring, any {}; for (const key of this._dirtyKeys) { if (key in this._cache) { dataToSave[key] this._cache[key]; } } const jsonString JSON.stringify(dataToSave); const encrypted CryptoHelper.encrypt(jsonString); StorageAdapter.setItem(__save_data__, encrypted); this._dirtyKeys.clear(); }延迟1秒落盘的策略意味着玩家连续改100次金币实际写盘可能只发生1次而且只在玩家停顿下来时。代价是如果游戏在这一秒内被强杀脏数据会丢失。所以游戏切后台、或者收到全局gamePause事件时必须强制调用一次persist()把缓冲的脏数据刷到磁盘。这个逻辑我写成flush()方法在gameManager层面做生命周期绑定。5.3 启动时加载一次解密后续全走内存每次游戏启动只解密一次这个我很笃定。loadFromDisk做的事从适配层取出密文、解出明文、解析JSON、与每个schema的默认值合并最终填充到_cache里。这个过程的顺序不能乱因为合并依赖所有schemaschema没注册完就合并大概率会丢掉新字段。private loadFromDisk(): void { const raw StorageAdapter.getItem(__save_data__); if (!raw) { return; } const decrypted CryptoHelper.decrypt(raw); if (decrypted null) { // 完整性校验失败放弃脏存储全部用默认值 return; } try { const parsed JSON.parse(decrypted); for (const schema of this._schemas.values()) { if (parsed[schema.key] ! undefined) { // 用默认值做结构合并 this._cache[schema.key] deepMerge(schema.defaultValue, parsed[schema.key]); } } } catch (e) { console.warn(存档解析失败所有存储项回退默认值, e); } }这里有个很重要的决策点如果整个存储文件被判定为篡改我是只丢弃那一项还是全部回退当前实现是全部回退理由很直接如果敌方攻击者已经能改密文了再单独信任里面看起来完好的项没有意义。安全策略上宁可全部归零也不给一个半可信的状态。6. 完整接入示例一个带存档功能的玩家系统有了前面四层的设计下面封装一个实际的玩家存档系统把代码串起来。这是最贴近游戏业务的部分也是很多网友问你的管理器怎么接入现有项目的答案所在。6.1 定义存储Schema假设玩家存档涉及金币、当前关卡、已解锁英雄列表、以及设置项。我用一张表做类型约束字段类型默认值说明goldnumber0金币后续做validator限制非负currentLevelnumber1当前关卡限制范围1~999unlockedHeroesnumber[][1]已解锁英雄ID必须包含初始英雄settingsobject{ music: true, sound: true }声音开关配置对应的Schema定义export const PlayerSchemas: StorageSchemaany[] [ { key: gold, defaultValue: 0, validator: (v: number) typeof v number v 0 Number.isFinite(v), }, { key: currentLevel, defaultValue: 1, validator: (v: number) Number.isInteger(v) v 1 v 999, }, { key: unlockedHeroes, defaultValue: [1], validator: (v: number[]) Array.isArray(v) v.includes(1), }, { key: settings, defaultValue: { music: true, sound: true }, validator: (v: any) v typeof v.music boolean typeof v.sound boolean, }, ];有了这张表业务层再也不会收到gold为负数、currentLevel为0.5、unlockedHeroes为空数组初始英雄永远在除非被改数据。6.2 初始化管理器在游戏启动场景比如GameManager的onLoad初始化import { StorageManager } from ./core/StorageManager; import { PlayerSchemas } from ./schemas/PlayerSchemas; export class GameManager extends Component { public static storage: StorageManager; onLoad() { GameManager.storage new StorageManager(PlayerSchemas); // 注册切后台强制落盘 game.on(Game.EVENT_HIDE, this.onGameHide, this); } private onGameHide() { GameManager.storage.flush(); } }EVENT_HIDE这个事件很关键移动端玩家切后台第一个触发它趁系统还没回收进程把脏数据立刻刷到磁盘能最大程度避免回档。6.3 业务层的读写示例// 读取金币 const gold GameManager.storage.getItemnumber(gold, 0); // 增加金币 GameManager.storage.setItemnumber(gold, gold 100); // 读取设置 const settings GameManager.storage.getItemany(settings, { music: true, sound: true }); settings.music false; GameManager.storage.setItemany(settings, settings);这里值得注意setItem接收的是一个对象引用。我直接把外部对象写进缓存好处是写入快、零拷贝坏处是外部代码如果后续修改了settings.music其实改的就是缓存里的对象写进_cache的时机比你想的更早。换句话说就算不调setItem也可能已经被改了。为了避免这个隐式变更导致的坑我建议在setItem内部做一次浅拷贝再存。不过拷贝会有性能开销量大时权衡一下。现阶段这个项目数据量不算大我用浅拷贝setItem的写法会更稳妥setItemT(key: string, value: T): void { if (typeof value object value ! null) { this._cache[key] Array.isArray(value) ? { ...value } : { ...(value as any) }; } else { this._cache[key] value; } this._dirtyKeys.add(key); this.schedulePersist(); }6.4 接入Android打包的注意事项Cocos Creator打包APK时sys.localStorage底层在各Android WebView里行为有差异。我踩过的坑是部分国产ROM的WebView在App被强杀后localStorage的写入可能丢失而且无法通过commit同步保证。缓解方案是不要把sys.localStorage当唯一存储如果存档很关键把相同密文写到文件路径下双写双读。用jsb.fileUtils或者assetManager提供的用户数据路径拼一个存档文件写入同样的密文字符串读的时候优先读文件文件缺失再读localStorage。这样多一层冗余回档概率显著下降。7. 常见问题与排查技巧实录篇幅有限不可能覆盖所有问题但下面这几个是我在项目里真实遇到、并且分享给同事解决过的典型问题直接给你做成速查表。现象可能原因排查/解决思路解密后大量字段是undefined合并逻辑只处理了schema中的key新key不在schema注册表里检查是否所有存储字段都注册了StorageSchema漏注册的字段不会进入deepMerge存档能被修改后不报错没有完整性校验只有加密参考4.3加入simpleHash或SHA-256校验篡改后decrypt返回null回退默认值写存档特别卡每帧多次setItem都触发加密和IO检查是否用了延迟落盘脏标记避免同步落盘必要时把持久化放到下一帧老版本存档更新后读取失败默认值结构不完整老字段缺失导致合并出错给新字段在默认值里赋值并用deepMerge补全嵌套结构切后台后存档丢失没有监听EVENT_HIDE在游戏生命周期中绑定flush()强制落盘部分Android机存储写入异常WebView的localStorage不稳定改为双写文件优先或换用原生的UserDefault接口加密库引入后包体暴增引用了全功能的crypto库用XXTEA这种轻量实现替代或者按需引入tree-shake友好的库测试时发现同一明文加密结果一样密钥固定且无盐加密前在明文里拼一个随机字符串或者生成随机IV、随密文一起存储7.1 调试技巧加密数据可视化本地存储管理器里最痛苦的就是排查加密后的数据日志打出来全是一堆密文。我的做法是调试模式下提供一个debugDecryptAll()方法手动调用后把解密明文打印到控制台并且把validator校验失败的具体值也一并输出。这样排查存档问题能省一半时间。发布版本时这个接口一定要用宏或者环境变量包起来别漏到线上。// 仅在 DEBUG 模式暴露调试接口 if (DEBUG) { (globalThis as any).__storageDebug { getPlainText: () { const raw StorageAdapter.getItem(__save_data__); if (!raw) return (没有存档); return JSON.parse(CryptoHelper.decrypt(raw) || {}); }, getRawCipher: () { return StorageAdapter.getItem(__save_data__); }, }; }Cocos Creator的DEBUG是构建时宏release包里它会变成false被摇树掉。这样线上包就不会泄露调试能力而本地开发时又能快速查数据。7.2 缓存一致性问题处理缓存时最烦人的就是外部直接修改了底层存储而内存缓存不知道。在纯本地单机场景里这只发生在极少数情况比如开了两个游戏窗口可以暂不处理。但一旦以后扩展到多个场景共享存储或服务器推送存档变更就得引入版本号机制每次存储变更时_cacheVersion加1外部检查到版本号变了主动刷新缓存。这个机制现在不用写但我建议在StorageManager里预留一个version字段将来扩展成本会小很多。8. 按需扩展把管理器做成可适配多环境的存储方案写到这里这套本地存储管理器的核心已经完整了。最后聊一聊如果项目规模变大这套东西还能怎么扩展。第一个方向是增加版本迁移机制。现在的deepMerge能补字段但能补不等于能迁移。比如某天你想把金币从数字改成字符串或者改成一个大整数这就需要写一个版本迁移函数。我会在StorageSchema里加一个version: number每个schema单独维护版本号读取时如果版本号对不上就执行该schema注册的migrate函数做数据转换再做后续的合并和校验。第二个方向是支持异步存储。目前的StorageAdapter是纯同步的这在单机存档场景完全够用。如果未来接了云存档StorageAdapter需要改成返回Promise的异步接口setItem的写入流程会从set内存Scheduled persist变成set内存发起异步保存。缓存层和接口层基本不用动这就是分层设计最大的好处。第三个方向是独立存储项与全局存档分离。现在我们所有数据都放在一个大JSON里文件体积大了之后哪怕只改一个字段也要全量加密写盘。更优做法是拆多个命名空间比如player_save、settings_save分开存互不影响。这个改动只需要在StorageAdapter里通过不同key区分命名空间其它层逻辑保持不变。这三个扩展方向如果一开始就急着写代码会变得很臃肿。我的经验是先把核心跑通再按需求增量加。现在的管理器已经能处理加密、缓存和类型安全这三件核心事且每一层都独立可测试这就是一个足够健康的起点。踩过存档被改、回档、线上解析崩溃这些坑之后我心里最深的体会是存储层不像画面和玩法那样显性它出问题往往是用户玩了几个小时才暴露的一旦出事修复成本非常高。所以值得在最开始就投入足够的设计精力而不是等项目上线后再亡羊补牢。