Cocos Creator本地存储管理器:加密缓存与类型安全实战
2026/9/15 6:24:36 网站建设 项目流程

做游戏开发的都知道,本地存档这件事,看着简单,做起来全是坑。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 文件,反编译之后所有字符串都能被搜出来。所以密钥策略要务实一点,目标是不让玩家一眼挖出来,而不是理论上的不泄露。我实际用的是「分段拼接 + 字符变换」的混淆方式,把密钥拆成三段,在代码不同位置拼接,再做一次简单的字符偏移。效果就是搜索keysecretaes这些关键词,直接搜不到完整密钥。

注意:如果做的是强联网游戏,存档的安全底线应该在服务端校验,客户端加密只是第一道防线,不要把客户端加密当成账本系统级别的安全方案。

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 = 'q7Zt@v5N'; 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#9pL@x2Qw8Zv'); } 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,先deleteset,让它重新排到队尾;超出容量限制时,删除队首的 key。

export class LRUCache<K, V> { private map = new Map<K, 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)的调用方只要求"我更新了,下次再读要拿到新值",并不要求立刻落盘。所以写入路径设计成两步:

  1. 同步更新内存缓存,保证后续get能立刻读到最新值。
  2. 把 key 加入一个待写队列,用一个短延时合并写入,比如 500ms 内的多次更新合并成一次磁盘写入,也可以手动调用flush()强制落盘。

用时间窗口合并写入的核心优势是:高频更新(比如游戏中每几秒自动存档一次)不会每次都触发加密 + IO,而是等玩家停留在一个安全点(切场景、弹商店、进入战斗结算)才真正写磁盘。掉线、闪退最多丢失最后几百毫秒内的存档变更,在移动端游戏的可接受范围内。

实际实现里我维护了一个tickSet的 Set,记录需要落盘的 key,然后每帧检查时间戳:

private pendingKeys = new Set<string>(); 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 StorageEnvelope<T> { version: number; data: T; savedAt: number; }

每次管理器初始化时读取version,如果小于当前代码声明的SCHEMA_VERSION,走一遍迁移函数表。每个版本对应一个(oldData) => newData的迁移函数,迁移完再写回。缓存层的命中也要带上版本判断,否则旧缓存返回给新代码,同样可能崩溃。

版本迁移这里最容易出问题的点是:迁移函数必须处理"跨多个版本"的情况。玩家可能半年没打开游戏,版本直接从 v1 跳到 v5,如果迁移逻辑只处理 v1→v2,一读 v1 存档就炸了。所以迁移路径要写成循环,逐版本升级,直到当前版本。

const migrations: Record<number, (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 { get<T>(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; } } set<T>(key: string, value: T): void { this.cache.set(key, value); this.pendingKeys.add(key); } }

调用方写storage.get<PlayerInfo>('player', DEFAULT_PLAYER)时,返回值就自动是PlayerInfo类型,字段名打错了编译器直接报错,不用等线上爆炸。默认值fallback也很关键,它保证了读取失败(存档损坏、版本不兼容、首次运行)时不会返回undefined让下游逻辑到处判空。

4.2 类型守卫、默认值与业务解耦

泛型接口基本解决了类型问题,但还有两个细节值得注意。

第一个是接口返回类型和实际数据结构的校验get<T>只是做了断言,如果存档被外部工具改过、字段类型对不上(比如gold被改成字符串),运行期还是会出问题。所以对于复杂的数据结构,我推荐写一个类型守卫函数:

function isPlayerInfo(data: unknown): data is PlayerInfo { if (typeof data !== 'object' || data === null) return false; const d = data as Record<string, 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 LRUCache<string, unknown>(64); private pendingKeys = new Set<string>(); 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: Record<number, (data: any) => any> = { 1: this.migrateV1ToV2, 2: this.migrateV2ToV3, }; get<T>(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; } } set<T>(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 场景中的接入方式

上面这个类的接入,我一般是把它挂在一个常驻节点上,跟随游戏主循环驱动自动落盘。在GameManagerupdate里调用就行:

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 打出来的包体大约 40KB(gzip 后),对游戏包来说可以接受。但要确认构建目标的 ES 版本兼容性,Cocos Creator 3.x 的构建配置里如果选了较老的 JavaScript 目标,crypto-js 的某些语法可能需要 polyfill。

6.2 数据损坏的三级防线:捕获、校验、降级

存档数据损坏是必然会遇到的,代码写得再小心,玩家的操作环境千奇百怪——断电阻断、清理工具误删、同步工具半途退出。所以我设计了三级防线:

  • 第一级:解析捕获JSON.parsedecrypt都可能抛异常,全部 try-catch,异常时返回默认值。
  • 第二级:结构校验validator函数检查字段类型和必需字段,避免"能解析但是坏数据"混进游戏逻辑。
  • 第三级:版本迁移失败降级。如果迁移链断裂(比如找不到对应版本的迁移函数),宁可丢弃这份存档返回默认值,也不要把旧数据硬塞给新代码,否则运行期崩溃更难看。

这三级的核心思想是:存储层要对数据持有怀疑态度。每一份从磁盘读出来的数据,都不值得默认信任。很多开发者只做了第一级 try-catch,觉得"捕获了异常就安全了",但结构合法、版本落后的数据照样能造成线上 bug,第二三级防线同样要有。

6.3 加密不是银弹:什么该存、什么不该存

最后想泼一盆冷水,关于加密的边界。本地存储加密解决的是"防止普通玩家改掉自己的存档",但阻挡不了专业工具型玩家。就算你用了 AES-256,攻击者依然可以定位到sys.localStorage.setItem的调用位置,hook 掉你的加密函数,直接注入任意存档数据。这是客户端存储架构的天花板。

所以我在项目里的划分原则是:

  • 允许本地信任的数据(存加密):玩家设置、音效配置、课程进度、单机游戏进度。
  • 必须在服务端校验的数据(不依赖本地加密):货币余额、抽卡结果、排行榜、内购凭证、在线 PvP 的匹配数据。

每一类数据的信任边界要在需求阶段就定清楚。本地加密存档做得再好,也不应该成为"反作弊"的最终手段。把需要服务端校验的数据全部放在服务端,本地加密只承担用户体验层面的职责(记住配置、快速恢复进度),这套方案在实用性和安全性上才真正站得住脚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询