拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Cocos Creator本地存储管理器:加密缓存与类型安全实战

做游戏开发的都知道本地存档这件事看着简单做起来全是坑。Cocos Creator 的sys.localStorage确实封装了浏览器和原生端的 localStorage但你真拿它当数据存储主力用很快会发现几个绕不开的问题直接明文存玩家用个工具就能改存档排行榜和道具系统直接崩溃每次读写都走磁盘高频操作卡顿TypeScript 写出来的代码取回来的数据全是any一个字段名打错线上事故就来了。这篇文章我想聊的就是我自己在项目里沉淀的一套本地存储管理器方案核心解决三件事加密防修改、防破解、缓存读写性能优化、类型安全让 TypeScript 在存储这件事上真正有用。不搞虚的直接说思路、给代码、讲踩过的坑适合那些已经用 Cocos Creator 做过一到两个完整项目、对存档系统开始有更高要求的开发者。1. 为什么游戏需要自己的本地存储管理器1.1 直接裸用 localStorage 的三个致命问题先说第一个问题明文存储等于把存档敞开了给玩家看。Cocos Creator 打包成 Web 版之后localStorage 里存的东西用浏览器 DevTools 的 Application 面板一眼就能看到数值改一改刷新页面就生效。单机游戏还好如果是带排行榜、带内购校验的游戏这就是个随时能被捅破的口子。原生端稍微好一点但 Android 的 shared preferences 存在 XML 文件里Root 过的设备照样能翻出来改。第二个问题不是安全而是性能。localStorage 的底层是同步磁盘 IO每次setItem都会有实际的写入开销。游戏里如果有个高频保存的逻辑比如每帧记录位置、记录飘字动画的状态直接写 localStorage 必然掉帧。更麻烦的是localStorage 的存储上限一般是 5MB 到 10MB不同平台不一致一旦塞满setItem会直接抛 QuotaExceededError导致游戏崩溃。第三个问题纯粹是工程体验问题。localStorage.getItem返回的是string | null你存对象得 JSON.stringify读回来再 JSON.parse。所有字段都是隐式的没有编译期检查。项目大了之后负责 UI 的同事和负责逻辑的同事对同一个 key 的字段名理解不一致线上就出现undefined传播排查起来极其浪费时间。1.2 一个完备的存储管理器应该管什么把上面三个问题翻译成需求其实是清晰的三层结构加密层负责序列化、加密、完整性校验。对外暴露的是一套save/load接口内部自动处理 JSON 序列化和 AES 加解密。缓存层负责内存缓存和写回策略。读请求优先命中 Map写请求先更新内存再按策略异步刷回磁盘避免高频写入卡顿。类型安全层负责泛型推断、默认值兜底、数据版本迁移。让storage.get(xxx, fallback)自动返回你期望的类型而不是unknown。这三层不是可选项是相辅相成的。没有缓存层加密层再快也会被磁盘 IO 拖死没有类型安全层加密和缓存只解决了能存和存得快没解决存得对。下面我分别拆开讲。2. 加密层设计不是越复杂越好2.1 加密算法选型AES-256-CBC 就够了本地存档加密最常见的误区是一上来就想用特别复杂的算法什么国密 SM4、RSA 非对称甚至有人想自己做一套混淆算法。我的建议是用成熟的对称加密 AES-256-CBC不要自研算法。原理很简单存档加密的目的是提高篡改门槛而不是构建一个理论上不可破解的系统。AES 是业界验证过的标准算法Cocos Creator 的 JavaScript 引擎可以直接跑纯 JS 的 crypto-js 库也可以跑原生扩展没必要自己发明。CBC 模式下每次加密同一段明文只要 IV 不同密文就不同这能防止玩家通过对比两次存档差异来推断明文结构。CBC 的密文长度会比明文多一个块16 字节的 padding存储空间代价可以接受。密钥管理这里要注意一个事实纯前端环境的密钥不可能绝对隐藏。Cocos Creator 打包后的代码是 JS 文件反编译之后所有字符串都能被搜出来。所以密钥策略要务实一点目标是不让玩家一眼挖出来而不是理论上的不泄露。我实际用的是「分段拼接 字符变换」的混淆方式把密钥拆成三段在代码不同位置拼接再做一次简单的字符偏移。效果就是搜索key、secret、aes这些关键词直接搜不到完整密钥。注意如果做的是强联网游戏存档的安全底线应该在服务端校验客户端加密只是第一道防线不要把客户端加密当成账本系统级别的安全方案。2.2 加密库封装crypto-js 的 Cocos 适配Cocos Creator 3.x 项目里接 crypto-js 很直接npm 安装后直接 import 就行。我封装了一个CryptoUtil类统一管理密钥、IV 和算法细节import CryptoJS from crypto-js; export class CryptoUtil { // 分段拼接密钥实际项目中可以把三段存放在不同模块 private static readonly KEY_PART1 x9fK#m2L; private static readonly KEY_PART2 q7Ztv5N; private static readonly KEY_PART3 c3Rp!w8H; private static getKey(): string { return CryptoUtil.KEY_PART1 CryptoUtil.KEY_PART3 CryptoUtil.KEY_PART2; } private static getIv(): CryptoJS.lib.WordArray { // IV 固定 16 字节与密钥来源分离 return CryptoJS.enc.Utf8.parse(s1Tv#9pLx2Qw8Zv); } public static encrypt(plainText: string): string { const key CryptoJS.enc.Utf8.parse(this.getKey()); const encrypted CryptoJS.AES.encrypt(plainText, key, { iv: this.getIv(), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); } public static decrypt(cipherText: string): string { const key CryptoJS.enc.Utf8.parse(this.getKey()); const decrypted CryptoJS.AES.decrypt(cipherText, key, { iv: this.getIv(), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); } }有几个细节值得说明。IV 和密钥要分离存放如果有人只搜到一个不至于整套加密体系崩盘。CryptoUtil内部用enc.Utf8.parse把字符串转成 WordArray很多同学在这步直接用字符串传参crypto-js 会默认按 Utf8 处理说不上错但显式解析更可靠避免某些平台上编码行为不一致。实际测试下来AES-256-CBC 加密一段 2KB 的存档数据在普通中端 Android 手机上耗时约 1-3ms几乎无感。这个成本完全值得换来的是一道明确的防篡改门槛。2.3 完整性校验防篡改还有最后一道防线加密能防偷看但不能完全防篡改——如果攻击者不知道密钥他可以整段替换密文比如把自己的存档备份覆盖回去实现SL 大法。所以我额外加了一层校验值在序列化后的明文里追加一个基于字段内容计算的 CRC32 或 MD5再整体加密。明文数据 { data: { level: 10, gold: 9999, items: [...] }, checksum: f8a2c1c4... }加密前先计算checksum解密后重新计算比对。不匹配就直接丢弃数据走默认值恢复流程。配置型工具我一般用 CRC32因为它的计算量比 MD5 小一个数量级存档型数据用 MD5 也就几微秒的开销可以用 MD5 减少碰撞可能性。这一步做完玩家改单个字段的行为会被拦下来——改一个数字校验值对不上整个存档作废。副作用是修改的成本变成必须找到校验算法门槛已经比直接明文改高了不少。3. 缓存层设计读快、写稳、不爆内存3.1 接一层 LRU 内存缓存只有加密没有缓存实际用起来会发现问题每次读存档都要走一次「读取密文 → AES 解密 → JSON 解析」虽然耗时也就是几毫秒但游戏逻辑频繁访问比如战斗流程里反复读角色属性累加起来还是有感觉。所以我在存储管理器内部维护了一个 LRU 缓存。实现不复杂JavaScript 的Map天然保留插入顺序用它就能实现一个够用的 LRU每次访问某个 key先delete再set让它重新排到队尾超出容量限制时删除队首的 key。export class LRUCacheK, V { private map new MapK, V(); constructor(private capacity: number) {} get(key: K): V | undefined { if (!this.map.has(key)) return undefined; const value this.map.get(key)!; // 重新插入让 key 排到队尾表示最近被使用 this.map.delete(key); this.map.set(key, value); return value; } set(key: K, value: V): void { if (this.map.has(key)) { this.map.delete(key); } if (this.map.size this.capacity) { // Map.keys().next() 拿到的是最早插入的 key const oldestKey this.map.keys().next().value; if (oldestKey ! undefined) { this.map.delete(oldestKey); } } this.map.set(key, value); } has(key: K): boolean { return this.map.has(key); } delete(key: K): void { this.map.delete(key); } clear(): void { this.map.clear(); } }容量我一般设 64 个 key单 key 数据体积不超过 200KB 的情况下内存开销控制在十几 MB 以内很安全。关键热数据玩家基础信息、当前关卡状态常驻缓存冷数据用完之后自然被淘汰。这里面有一个工程陷阱不要为了省内存把容量设太小。比如容量只有 10每次切换场景就把上一关的数据淘汰掉结果下一关回来又得重新解密读磁盘性能比以前还差。64 是个比较稳的起步值实际项目可以根据单条数据体积调整。3.2 写回策略内存先行磁盘异步缓存层解决的不只是读写也一样。StorageManager.set(playerInfo, data)的调用方只要求我更新了下次再读要拿到新值并不要求立刻落盘。所以写入路径设计成两步同步更新内存缓存保证后续get能立刻读到最新值。把 key 加入一个待写队列用一个短延时合并写入比如 500ms 内的多次更新合并成一次磁盘写入也可以手动调用flush()强制落盘。用时间窗口合并写入的核心优势是高频更新比如游戏中每几秒自动存档一次不会每次都触发加密 IO而是等玩家停留在一个安全点切场景、弹商店、进入战斗结算才真正写磁盘。掉线、闪退最多丢失最后几百毫秒内的存档变更在移动端游戏的可接受范围内。实际实现里我维护了一个tickSet的 Set记录需要落盘的 key然后每帧检查时间戳private pendingKeys new Setstring(); private lastFlushTime 0; private static readonly FLUSH_INTERVAL_MS 500; update(now: number): void { if (this.pendingKeys.size 0) return; if (now - this.lastFlushTime StorageManager.FLUSH_INTERVAL_MS) return; this.flush(); this.lastFlushTime now; } flush(): void { for (const key of this.pendingKeys) { const value this.cache.get(key); if (value ! undefined) { const encrypted CryptoUtil.encrypt(JSON.stringify(value)); sys.localStorage.setItem(this.getStorageKey(key), encrypted); } } this.pendingKeys.clear(); }注意flush()里应该逐个处理 key不要因为一个 key 序列化失败就把整个写队列丢掉。实际项目中我见过因为某次存档数据里混入了一个 circular reference循环引用对象JSON.stringify直接抛异常然后整个写队列被 try-catch 吞掉其他 key 的存档就默默丢了。这个坑要提前防住。3.3 缓存失效与版本管理缓存不能只有新增和更新失效逻辑同样重要。本地存储容易犯的错误是游戏版本升级后旧存档格式和新代码不兼容读取时报错然后所有数据归零。这不是玩家的错是缓存/存档结构没有做版本管理的错。我的做法是在写入的数据外层包一层结构interface StorageEnvelopeT { version: number; data: T; savedAt: number; }每次管理器初始化时读取version如果小于当前代码声明的SCHEMA_VERSION走一遍迁移函数表。每个版本对应一个(oldData) newData的迁移函数迁移完再写回。缓存层的命中也要带上版本判断否则旧缓存返回给新代码同样可能崩溃。版本迁移这里最容易出问题的点是迁移函数必须处理跨多个版本的情况。玩家可能半年没打开游戏版本直接从 v1 跳到 v5如果迁移逻辑只处理 v1→v2一读 v1 存档就炸了。所以迁移路径要写成循环逐版本升级直到当前版本。const migrations: Recordnumber, (data: any) any { 1: (data) ({ ...data, playerName: data.name ?? , version: 2 }), 2: (data) ({ ...data, inventory: data.items ?? [], version: 3 }), // ... }; migrate(rawData: any): any { let current rawData; while (current.version StorageManager.SCHEMA_VERSION) { const migrator migrations[current.version]; if (!migrator) { // 没有迁移函数只能丢弃该存档 return null; } current migrator(current); } return current; }4. 类型安全设计TypeScript 泛型的正确用法4.1 用泛型救回编译期检查存储管理器对外暴露的接口目标很简单读出来的数据应该是有类型的而不是unknown。Cocos Creator 3.x 本身就是 TypeScript 项目如果存储层还是随手JSON.parse那类型系统就形同虚设。我设计了一套带默认值的泛型接口export class StorageManager { getT(key: string, fallback: T): T { // 先查内存缓存 if (this.cache.has(key)) { return this.cache.get(key) as T; } try { const encrypted sys.localStorage.getItem(this.getStorageKey(key)); if (encrypted null) return fallback; const rawJson CryptoUtil.decrypt(encrypted); const envelope JSON.parse(rawJson); const migrated this.migrate(envelope); if (migrated null) return fallback; const data migrated.data as T; this.cache.set(key, data); return data; } catch (e) { return fallback; } } setT(key: string, value: T): void { this.cache.set(key, value); this.pendingKeys.add(key); } }调用方写storage.getPlayerInfo(player, DEFAULT_PLAYER)时返回值就自动是PlayerInfo类型字段名打错了编译器直接报错不用等线上爆炸。默认值fallback也很关键它保证了读取失败存档损坏、版本不兼容、首次运行时不会返回undefined让下游逻辑到处判空。4.2 类型守卫、默认值与业务解耦泛型接口基本解决了类型问题但还有两个细节值得注意。第一个是接口返回类型和实际数据结构的校验。getT只是做了断言如果存档被外部工具改过、字段类型对不上比如gold被改成字符串运行期还是会出问题。所以对于复杂的数据结构我推荐写一个类型守卫函数function isPlayerInfo(data: unknown): data is PlayerInfo { if (typeof data ! object || data null) return false; const d data as Recordstring, unknown; return typeof d.name string typeof d.level number Array.isArray(d.items); }然后在get内部可选地传入校验函数校验不过就丢弃数据走默认值流程。这层保护对线上稳定性很有意义——不信任任何来自磁盘的数据。第二个是默认值和业务逻辑解耦。很多开发者习惯把默认值写死在get的调用处比如get(player, { name: , level: 1, items: [] })。短时间没问题但一旦默认结构发生改变所有调用点都要改。我的做法是把默认值集中定义在一个DefaultData常量文件里业务层只传配置名export const DefaultData { player: { name: NewPlayer, level: 1, items: [] as string[] }, settings: { bgm: 100, sfx: 100, vibration: true }, }; // 调用处 const player storage.get(player, DefaultData.player);这样默认值的维护成本降下来了而且各个模块之间不会出现同一份数据默认值不一致的问题。5. 完整实现StorageManager 核心代码5.1 类结构总览前面几节把加密、缓存、类型安全分开讲了现在串起来看一个完整的最小实现。一个够用的StorageManager其实只有五个核心部分LRU 缓存实例、待写队列、泛型读写接口、加密工具引用、版本迁移表。import { sys } from cc; import { CryptoUtil } from ./CryptoUtil; import { LRUCache } from ./LRUCache; interface StorageEnvelope { version: number; savedAt: number; data: unknown; } export class StorageManager { private static instance: StorageManager; public static getInstance(): StorageManager { if (!StorageManager.instance) { StorageManager.instance new StorageManager(); } return StorageManager.instance; } private cache new LRUCachestring, unknown(64); private pendingKeys new Setstring(); private lastFlushTime 0; private static readonly SCHEMA_VERSION 3; private static readonly STORAGE_PREFIX game_save_; private static readonly FLUSH_INTERVAL_MS 500; private readonly migrations: Recordnumber, (data: any) any { 1: this.migrateV1ToV2, 2: this.migrateV2ToV3, }; getT(key: string, fallback: T, validator?: (data: unknown) data is T): T { const cached this.cache.get(key); if (cached ! undefined) { return cached as T; } try { const storageKey this.getStorageKey(key); const encrypted sys.localStorage.getItem(storageKey); if (encrypted null) return fallback; const json CryptoUtil.decrypt(encrypted); const envelope JSON.parse(json) as StorageEnvelope; const migrated this.migrate(envelope); if (migrated null) return fallback; if (validator !validator(migrated.data)) return fallback; this.cache.set(key, migrated.data); return migrated.data as T; } catch (e) { return fallback; } } setT(key: string, value: T): void { this.cache.set(key, value); this.pendingKeys.add(key); this.scheduleFlush(); } remove(key: string): void { this.cache.delete(key); this.pendingKeys.delete(key); sys.localStorage.removeItem(this.getStorageKey(key)); } update(now: number): void { this.flushIfNeeded(now); } flush(): void { for (const key of this.pendingKeys) { try { const value this.cache.get(key); if (value undefined) continue; const envelope: StorageEnvelope { version: StorageManager.SCHEMA_VERSION, savedAt: Date.now(), data: value, }; const plain JSON.stringify(envelope); const encrypted CryptoUtil.encrypt(plain); sys.localStorage.setItem(this.getStorageKey(key), encrypted); } catch (e) { console.warn([StorageManager] flush key ${key} failed:, e); } } this.pendingKeys.clear(); } private scheduleFlush(): void { // 用法一在游戏主循环里调用 update(dt) // 用法二在场景切换、应用切后台等时机手动调用 flush() } private flushIfNeeded(now: number): void { if (this.pendingKeys.size 0) return; if (now - this.lastFlushTime StorageManager.FLUSH_INTERVAL_MS) return; this.flush(); this.lastFlushTime now; } private getStorageKey(key: string): string { return StorageManager.STORAGE_PREFIX key; } private migrate(envelope: StorageEnvelope): StorageEnvelope | null { let current envelope; while (current.version StorageManager.SCHEMA_VERSION) { const migrator this.migrations[current.version]; if (!migrator) return null; current migrator(current); } return current; } private migrateV1ToV2(old: any): any { return { ...old, version: 2, data: { ...old.data, playerName: old.data.name ?? , }, }; } private migrateV2ToV3(old: any): any { return { ...old, version: 3, data: { ...old.data, inventory: old.data.items ?? [], }, }; } }5.2 在 Cocos Creator 场景中的接入方式上面这个类的接入我一般是把它挂在一个常驻节点上跟随游戏主循环驱动自动落盘。在GameManager的update里调用就行import { _decorator, Component } from cc; import { StorageManager } from ./StorageManager; ccclass(GameManager) export class GameManager extends Component { update(deltaTime: number) { StorageManager.getInstance().update(Date.now()); } onApplicationPause() { // 移动端切后台时强制落盘防止杀进程丢失数据 StorageManager.getInstance().flush(); } onApplicationDestroy() { StorageManager.getInstance().flush(); } }接入点有三个主循环的 update处理时间窗口合并写盘、应用切后台强制落盘、应用销毁最终落盘。这三个点补上大部分闪退和杀进程场景下的丢档问题都能兜住。游戏内的使用方式就非常清爽了const player StorageManager.getInstance().get(player, DefaultData.player, isPlayerInfo); player.gold 100; StorageManager.getInstance().set(player, player);读的时候带一个默认值和校验函数写的时候更新缓存加入待写队列。页面刷新后数据恢复不用关心加密、不用关心 JSON 解析、不用关心版本迁移这些全部被封装在管理器内部。6. 实操中的坑版本、平台与加密细节6.1 Cocos Creator 打包 APK 后的存储差异Cocos Creator 在不同平台的 localStorage 实现不一样Web 端用的是浏览器 localStorage原生 Android 端在 3.x 里有自己的实现。坑点在于Web 的 localStorage 和 Android 的原生存储不是同一套数据源也就是说同一个包浏览器调试时存的档打包成 APK 后读不到这是正常现象不是代码问题调试时别慌。Android 打包后还有一个常见的坑sys.localStorage在某些低端机型上写入可能会抛异常特别是存储空间不足时。所以我在flush()里的 try-catch 不是摆设不要相信setItem永远成功。写失败的情况要记录下来并考虑数据降级方案——比如内存缓存保留数据下次启动再尝试写一次。打包 APK 时还要注意 crypto-js 的体积和兼容性。crypto-js 打出来的包体大约 40KBgzip 后对游戏包来说可以接受。但要确认构建目标的 ES 版本兼容性Cocos Creator 3.x 的构建配置里如果选了较老的 JavaScript 目标crypto-js 的某些语法可能需要 polyfill。6.2 数据损坏的三级防线捕获、校验、降级存档数据损坏是必然会遇到的代码写得再小心玩家的操作环境千奇百怪——断电阻断、清理工具误删、同步工具半途退出。所以我设计了三级防线第一级解析捕获。JSON.parse和decrypt都可能抛异常全部 try-catch异常时返回默认值。第二级结构校验。validator函数检查字段类型和必需字段避免能解析但是坏数据混进游戏逻辑。第三级版本迁移失败降级。如果迁移链断裂比如找不到对应版本的迁移函数宁可丢弃这份存档返回默认值也不要把旧数据硬塞给新代码否则运行期崩溃更难看。这三级的核心思想是存储层要对数据持有怀疑态度。每一份从磁盘读出来的数据都不值得默认信任。很多开发者只做了第一级 try-catch觉得捕获了异常就安全了但结构合法、版本落后的数据照样能造成线上 bug第二三级防线同样要有。6.3 加密不是银弹什么该存、什么不该存最后想泼一盆冷水关于加密的边界。本地存储加密解决的是防止普通玩家改掉自己的存档但阻挡不了专业工具型玩家。就算你用了 AES-256攻击者依然可以定位到sys.localStorage.setItem的调用位置hook 掉你的加密函数直接注入任意存档数据。这是客户端存储架构的天花板。所以我在项目里的划分原则是允许本地信任的数据存加密玩家设置、音效配置、课程进度、单机游戏进度。必须在服务端校验的数据不依赖本地加密货币余额、抽卡结果、排行榜、内购凭证、在线 PvP 的匹配数据。每一类数据的信任边界要在需求阶段就定清楚。本地加密存档做得再好也不应该成为反作弊的最终手段。把需要服务端校验的数据全部放在服务端本地加密只承担用户体验层面的职责记住配置、快速恢复进度这套方案在实用性和安全性上才真正站得住脚。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门