存储与文件
移动端存储的第一原则:浏览器那套" localStorage 一把梭"的心智模型在这里会翻车。RN 世界里没有统一的存储 API,你面对的是若干条原生通道的集合——有毫秒级同步的(MMKV),有异步慢速的(AsyncStorage),有走系统钥匙串的(Keychain/Keystore),有正经关系型数据库的(SQLite),还有备份语义完全不同的两类目录(documents 会被云备份、cache 不会)。本章先把选型地图立起来,再逐个通道给出可直接落地的写法。
基线:React Native 0.88 / Expo SDK 58 / TypeScript。版本号仅在正文标注真正核实过的库(react-native-mmkv 4.3.2),其余库不钉版本。
存储选型地图
| 需求 | 方案 | 一句话理由 |
|---|---|---|
| 会话内 UI 状态 | Zustand(内存态) | 不落盘就别付 IO 的钱,见 ../zustand/04-中间件.md |
| 小型 KV 持久(配置、偏好、feature flag) | MMKV 4.x | JSI 同步读写,微秒级;加密现成 |
| 同上,但拒绝原生依赖/需要 Expo Go 里跑 | AsyncStorage | 纯异步社区库,兼容性好,速度换便利 |
| Query 缓存落盘(冷启动秒出上次数据) | TanStack Query persister + MMKV/AsyncStorage | 配合 05-数据获取与网络.md 的离线缓存讨论 |
| 结构化/大量/可查询的数据 | expo-sqlite(或 op-sqlite) | 离线写队列、FTS 全文搜索、部分更新 |
| 敏感小值(token、密钥) | expo-secure-store(Keychain/Keystore) | 磁盘 KV 存 token 是安全事故预备役 |
| 文件(导出、媒体、大附件) | expo-file-system / 社区 fs | 唯一"像文件系统"的通道 |
| 可回收缓存(图片、音频预下载) | cache 目录 + 自管过期 | 系统会清,别把用户数据放这 |
选型口诀:状态在内存,偏好在 MMKV,数据在 SQLite,秘密在钥匙串,文件分目录。下面按通道展开。
AsyncStorage:慢,但它是默认答案
@react-native-async-storage/async-storage 是社区维护的异步 KV 存储,全平台可用、不需要额外原生环境(这正是它相对 MMKV 的唯一护城河,在 Expo Go 里也能直接跑)。
// src/storage/async.ts —— API 全家桶速览
import AsyncStorage from '@react-native-async-storage/async-storage'
// 写 / 读 / 删,全部返回 Promise
await AsyncStorage.setItem('onboarded', 'true')
const onboarded = await AsyncStorage.getItem('onboarded') // 值是 string,类型自己序列化
await AsyncStorage.removeItem('onboarded')
// 全量 key 列表(慎用,见下文"大小限制心态")
const keys = await AsyncStorage.getAllKeys()
// 批量读写:一次消息往返处理 N 个 key,比循环单条快得多
const pairs = await AsyncStorage.multiGet(['theme', 'fontSize', 'locale'])
// pairs: [['theme', 'dark'], ['fontSize', null], ...] —— 缺失的 key 值为 null
await AsyncStorage.multiSet([
['theme', 'dark'],
['fontSize', '16'],
])
await AsyncStorage.multiRemove(['legacy.key1', 'legacy.key2'])它为什么慢
三个叠加因素:其一,每次读写都是 JS 线程到原生的一次异步往返(Promise 结算回到 JS 微任务队列),单条 API 的固定开销就是这个来回;其二,早期版本在 Android 主线程/iOS 主队列做过磁盘 IO,后来挪走了,但往返成本的结构没变——它天生是异步批量型存储;其三,值全是字符串,任何对象都得 JSON.stringify 一把再存,读回来 parse 一把,序列化成本算在 JS 线程头上。
结论:AsyncStorage 适合"低频读写 + 小值"。把它当同步配置表用(每次渲染都 await getItem)是最常见的滥用姿势。
大小限制心态
不要纠结"官方上限是多少字节"——平台实现和文件系统说了算,把它当没有上限但处处收费的存储:
- 别塞大 JSON blob。几百 KB 的序列化/反序列化在 JS 线程上就足以让一帧动画卡掉,几十 MB 的 blob 会直接把冷启动拖进 ANR/卡顿区间。
- 一次
getAllKeys/ 全量读取代价不可预估,调试面板用可以,业务路径别走。 - 值得存 DB/文件的那天,就是这条数据体积或访问模式越过"小配置"定义的那天。
MMKV 4.x:JSI 同步存储的现实形态
react-native-mmkv(npm 最新 4.3.2)封装了微信开源的腾讯 MMKV 内核,通过 JSI(JavaScript Interface)直接同步调用原生——新架构下 JSI 仍是一等公民,而 MMKV 从设计上就没依赖旧桥。装它要带一个 Nitro 运行时依赖:
# Expo 项目
npx expo install react-native-mmkv react-native-nitro-modules
npx expo prebuild
# 裸 RN
npm install react-native-mmkv react-native-nitro-modules(要求 RN 0.76+,0.88 基线天然满足。)
为什么快
两层原因叠加:mmap(内存映射文件)——MMKV 把存储文件直接映射进进程内存,“写磁盘"变成写内存页、由内核择机刷盘,省掉每次 read/write 系统调用;JSI 同步绑定——storage.get() 是普通函数调用直接落到 C++,没有"发消息、等 Promise 回来"的异步往返。同一份数据,MMKV 读出来是微秒级,AsyncStorage 要排一次线程间消息,官方基准口径是约 30 倍差距。对业务更实际的意义是:同步 API 意味着渲染路径里可以安全地读配置,不用 useEffect + 状态占位那一套。
注意:JSI 同步调用的代价是远程调试场景(Chrome DevTools 直连 JS 求值)下这些原生方法不可用——调试请用 Flipper / React DevTools。这是库 README 明写的限制,不是偶发 bug。
基础 API
// src/storage/mmkv.ts
import { createMMKV } from 'react-native-mmkv'
// 工厂函数;导出单例复用,别到处 new
export const storage = createMMKV({ id: 'app-kv' })
storage.set('user.name', 'Alex') // 字符串
storage.set('user.age', 21) // 数字
storage.set('beta.toggled', true) // 布尔
const name = storage.getString('user.name') // 'Alex',缺失返回 undefined
const age = storage.getNumber('user.age')
const flag = storage.getBoolean('beta.toggled')
storage.contains('user.name') // true
storage.getAllKeys() // 全量 key,同样比 AsyncStorage 便宜得多,但别养成依赖
storage.remove('user.name') // 返回是否真的删掉了
storage.clearAll()
// 对象没有原生支持:自己 JSON 化(和 AsyncStorage 时代同样的纪律)
storage.set('user', JSON.stringify({ username: 'Alex', age: 21 }))
const user = JSON.parse(storage.getString('user') ?? 'null')
// 二进制(token、图片缩略图)走 ArrayBuffer
storage.set('avatar', buffer) // ArrayBuffer
const buf = storage.getBuffer('avatar')还有配套的 React Hooks(useMMKVString('user.name') 这类,读写成对返回)适合让组件直接订阅某个 key——单个 key 的实时联动用它,成体系的状态仍归 Zustand。
加密
创建时或运行中都可以设置加密,密钥自己保管:
// 创建时指定(推荐,从第一天就加密,避免明文存量再迁移)
const secure = createMMKV({
id: 'secure-kv',
encryptionKey: '<运行时注入的密钥>',
encryptionType: 'AES-256', // 默认 AES-128,可显式升
})
// 对已有实例加/解密(全量改写文件)
storage.encrypt('hunter2') // AES-128
storage.encrypt('hunter2again', 'AES-256')
storage.decrypt() // 去掉加密
// 实例级管理
import { existsMMKV, deleteMMKV } from 'react-native-mmkv'
existsMMKV('secure-kv') // 实例是否存在
deleteMMKV('secure-kv') // 删除整个实例
密钥管理的老问题依旧:硬编码在 JS bundle 里的"加密"只是混淆。正经做法是把密钥本身放进 Keychain/Keystore(expo-secure-store,下文),或经原生侧下发——MMKV 负责"数据加密存储”,不负责"藏好密钥",这两段都得你自己闭环。
与 AsyncStorage 迁移,以及 zustand 适配
v3 及更早文档里常见的 createMMKVStorage 适配器在 v4 统一为 createMMKV(),迁移代码注意这个更名。老用户的数据搬家:
// src/storage/migrate.ts
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createMMKV } from 'react-native-mmkv'
const kv = createMMKV({ id: 'app-kv' })
export async function migrateFromAsyncStorage(): Promise<void> {
if (kv.contains('migrated.v1')) return // 幂等守卫
const keys = await AsyncStorage.getAllKeys()
const pairs = await AsyncStorage.multiGet(keys)
for (const [key, value] of pairs) {
kv.set(key, value) // 逐条灌入;值全是字符串,正好同构
}
kv.set('migrated.v1', true)
await AsyncStorage.clear() // 确认线上稳定一个版本后再清,回滚窗口留着
}顺带一提 MMKV 自己的实例合并能力:storage.importAllFrom(other) 把一个实例的全量键值导进另一个,多实例整合比手写遍历体面。
zustand persist 需要的 StateStorage 接口恰好三个方法,同步实现完全合法(zustand 允许返回非 Promise):
// src/storage/zustand-adapter.ts
import type { StateStorage } from 'zustand/middleware'
import { storage } from './mmkv'
export const mmkvStorage: StateStorage = {
setItem: (name, value) => storage.set(name, value),
getItem: (name) => storage.getString(name) ?? null, // zustand 约定缺失返回 null
removeItem: (name) => storage.remove(name),
}体积与回收
const size = storage.byteSize // 当前实例字节数
if (size >= 64 * 1024 * 1024) {
storage.trim() // 清理已删 key 的闲置空间、释放内存缓存
}MMKV 是单文件追加式存储,删 key 不会自动收缩文件,长期高频写入(尤其反复存放大值)要留意 byteSize,到量级就 trim()。把它当配置表而不是数据仓库,基本不会触及这条线。
测试
v4 在 Jest/Vitest 下会自动注入一个内存 mock 实例,createMMKV() 照常调用即可,不用手写原生桩。测试框架不同或需要精确控制存储内容时,再自行 mock——原则一句话:原生库进单测必须 mock,你的业务逻辑不应该依赖真实 mmap 文件。
Zustand persist 接 MMKV:移动端最常见组合完整写法
内存状态管理见 ../zustand/02-Store详解.md,中间件通论见 ../zustand/04-中间件.md,这里只写移动端落盘这一段。以主题/字号设置 store 为例:
// src/store/useSettingsStore.ts
import { create } from 'zustand'
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'
import { createMMKV } from 'react-native-mmkv'
const mmkv = createMMKV({ id: 'settings' })
const zustandStorage: StateStorage = {
setItem: (name, value) => mmkv.set(name, value),
getItem: (name) => mmkv.getString(name) ?? null,
removeItem: (name) => mmkv.remove(name),
}
interface SettingsState {
theme: 'light' | 'dark' | 'system'
fontSize: number
setTheme: (theme: SettingsState['theme']) => void
setFontSize: (size: number) => void
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
fontSize: 14,
setTheme: (theme) => set({ theme }),
setFontSize: (fontSize) => set({ fontSize }),
}),
{
name: 'settings', // MMKV 里的 key 名
storage: createJSONStorage(() => zustandStorage), // JSON 序列化 + 异步包装都在这层
// 只落盘纯数据:函数成员不落盘,actions 留在运行时定义
partialize: (state) => ({ theme: state.theme, fontSize: state.fontSize }),
},
),
)几个要点:createJSONStorage 负责 JSON.stringify/parse 和把同步 StateStorage 适配成 zustand 的持久层接口,你的适配器只管字符串进出;partialize 必写,不写会把整个 state(含函数)尝试序列化,靠函数被 JSON 丢弃碰运气不是工程;水合时机敏感时用 onRehydrateStorage 钩子(返回函数接收 (state, error)),冷启动首帧渲染闪默认值的问题在那一层解决。登出清场:useSettingsStore.persist.clearStorage() 即可删掉 MMKV 里对应 key。
SQLite:什么时候值得上数据库
当前最顺手的选项是 Expo 官方的 expo-sqlite(裸 RN 也能用),提供 async 与同步两档 API,以 async 为主即可。核心姿势(方法名以最新文档为准,下列为当前一代稳定用法):
// src/db/app.ts
import * as SQLite from 'expo-sqlite'
let dbPromise: Promise<SQLite.SQLiteDatabase> | null = null
export function getDb() {
if (!dbPromise) {
dbPromise = SQLite.openDatabaseAsync('app.db') // 懒开单例,重复调用返回同一个
}
return dbPromise
}
export async function initSchema(): Promise<void> {
const db = await getDb()
// execAsync 用于一次性跑多条建表语句;注意它不转义参数,别把用户输入拼进去
await db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS drafts (
id TEXT PRIMARY KEY,
body TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`)
}// src/db/drafts.ts —— 参数化读写
export async function saveDraft(id: string, body: string): Promise<void> {
const db = await getDb()
await db.runAsync(
`INSERT INTO drafts (id, body, updated_at) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET body = excluded.body, updated_at = excluded.updated_at`,
id,
body,
Date.now(),
) // runAsync 吃位置参数绑定,返回 { lastInsertRowId, changes }
}
export async function listDrafts(limit = 20): Promise<Draft[]> {
const db = await getDb()
// getAllAsync 可以带泛型;结果永远是全列平铺,投影写在 SELECT 里
return db.getAllAsync<Draft>('SELECT id, body, updated_at FROM drafts ORDER BY updated_at DESC LIMIT ?', limit)
}
export async function getDraft(id: string): Promise<Draft | null> {
const db = await getDb()
return db.getFirstAsync<Draft>('SELECT id, body, updated_at FROM drafts WHERE id = ?', id) // 无结果返回 null
}社区还有走 JSI 同步路线的 op-sqlite,读多写多的场景性能更强,选型时按"社区驱动库"评估维护投入即可,API 风格与上面大同小异。
值不值得上 DB 的判断线——三条占一条就开库:
- 本地优先(local-first):数据归属地在设备,网络只是同步通道。列表有几十上百条且会局部修改,KV 存整个数组的"读-改-写整坨"模式会随数据量线性变烂。
- 离线写队列:断网时用户的操作必须可重放。一张带
pending/synced状态列的表就是最简单的发件箱模式——本地先落库、回网后逐条 flush、失败留队,KV 做这套要靠字符串拼接脑补 schema。 - 全文搜索:SQLite FTS5 虚拟表 +
MATCH查询是免费能力,App 内历史消息/笔记搜索不必自己造倒排。
反过来,十几个配置项、Query 缓存、纯写一次读一次的 blob,都别用 DB——它带来的迁移/版本升级复杂度要值回票价。
文件系统:expo-file-system
Expo 的文件通道是 expo-file-system,目录与路径语义各平台通用,但 API 具体形态在 SDK 56 一代被重写过(官方口径是能力增强、Swift 化):上一代文档里满屏的 documentDir / cacheDir 字符串常量已经让位给"文件/目录对象化"的当前一代接口。本章刻意不钉 API 版本细节,落地前以官方文档核对当前签名——迁移老代码时最容易踩的就是拿旧字符串 API 配新版本库。
目录语义:备份行为是选型的核心
两个关键目录,差别不在大小而在会不会被云备份:
- documents 目录:会进 iCloud/Google 备份语义。放"用户产生、丢了会被告"的数据——导出报告、离线下载的剧集、草稿。
- cache 目录:不进备份,且系统空间紧张时可能被直接清空。放"丢了重新拉即可"的东西——接口缓存文件、图片缩略图、解压产物。
Warning:把可再生的大缓存(比如几百 MB 的预下载图片)放进 documents,等于强迫用户的 iCloud 替你备份——备份爆掉的不是你的服务器而是用户买的空间,苹果审核真的会因此拒。可再生数据进 cache,不可再生进 documents,这条纪律 iOS 侧尤其刚性。
Android 的分区存储(Scoped Storage)一句话带过:别试图直接写公共目录(/sdcard/DCIM 之类),应用专属目录随便用,要落到用户可见位置就走系统选择器/SAF 流程;权限面与相册读写联动见下一节和 06-表单与交互.md。
读写与下载
对象化 API 的心智模型:从 Paths 拿到目录引用,new File(dir, name) / new Directory(dir, name) 构造对象,再在对象上 create / write / read。文本读写示意:
// src/fs/report.ts —— 当前一代 API 风格示意,具体方法名以文档为准
import { Directory, File, Paths } from 'expo-file-system'
export function saveReport(json: object): File {
const dir = new Directory(Paths.document, 'reports')
dir.create({ intermediates: true }) // 目录不存在则逐级创建
const file = new File(dir, 'latest.json')
file.create()
file.write(JSON.stringify(json))
return file
}大文件分块:整读整写在移动端的失败模式是内存峰值与"写一半断电留下坏文件"。现实做法是按块读写(Uint8Array 进、Uint8Array 出),先写临时文件、完成后原子改名;服务端支持 Range 的下载同理分段落盘。下载任务直接用文件库的下载能力(原生侧落盘、带进度),fetch 读大响应的问题回看 05-数据获取与网络.md 的下载一节。
敏感键值:expo-secure-store
expo-secure-store 把值放进 iOS Keychain / Android Keystore 管辖的加密存储。API 与 AsyncStorage 同构,心智完全不同:
// src/storage/secure.ts
import * as SecureStore from 'expo-secure-store'
export async function saveToken(token: string): Promise<void> {
await SecureStore.setItemAsync('access_token', token, {
// 设备解锁且仅本机可用;换机不恢复——这正是安全语义要的
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
})
}
export async function readToken(): Promise<string | null> {
return SecureStore.getItemAsync('access_token')
}
export async function dropToken(): Promise<void> {
await SecureStore.deleteItemAsync('access_token')
}它贵、它小、它慢。iOS Keychain 的单值容量公开事实是 2KB 量级(更大直接写入失败),Android Keystore 后端也经安全通道绕行;所以:存 token、存加密密钥这类小值,别塞 JSON。把整个用户 profile 序列化进去,轻则写失败,重则每次解锁弹窗。判断标准回到选型口诀——不是"秘密"的数据放 MMKV(自己开加密),是"秘密"才进 secure store。
需要更高门槛时叠加生物识别:expo-local-authentication 提供硬件存在性检查(hasHardwareAsync)、是否已录入指纹/面容(isEnrolledAsync)、拉起系统认证(authenticateAsync({ promptMessage }))。常见组合是"读敏感值前先过生物认证",或把密钥的 Keychain 访问策略升级为需认证项——前者实现简单,后者更严谨,按合规要求选。
媒体文件与相册权限联动
文件与存储绕不开媒体权限,这里只点框架,流程细节在 06-表单与交互.md:Android 13+ 把存储权限细分成了 READ_MEDIA_IMAGES / READ_MEDIA_VIDEO / READ_MEDIA_AUDIO(老的 READ_EXTERNAL_STORAGE 在新 targetSdk 下不再管用),iOS 侧相册访问有"完全授权/仅选中项有限访问"两档。选择器(如 expo-image-picker)返回的 file:// URI 与保存目录的可用性受这些权限影响——上传流程(05-数据获取与网络.md 的 FormData 一节)拿到的 URI 在部分安卓厂商 ROM 上需要先拷贝进应用目录再上传,否则读到的是过期授权。
缓存策略实战
Feed 型 App 的三层缓存
内存(LRU)→ 磁盘(MMKV)→ 网络。目标是:热数据零 IO,冷数据秒回上次快照,磁盘兜住杀进程。
// src/cache/tiered.ts —— 三层缓存的最小可运行骨架
interface Entry<T> {
value: T
expiresAt: number
}
const MEM_CAP = 50
const mem = new Map<string, Entry<unknown>>() // Map 保持插入序 = LRU 的基础
function touch<T>(key: string, entry: Entry<T>): T {
mem.delete(key) // 命中即重插到队尾,最久未用的是队头
mem.set(key, entry)
return entry.value
}
async function getOrLoad<T>(key: string, fetcher: () => Promise<T>, ttlMs: number): Promise<T> {
const now = Date.now()
const cached = mem.get(key)
if (cached && cached.expiresAt > now) return touch(key, cached)
const diskRaw = storage.getString(`cache:${key}`) // MMKV 实例
if (diskRaw) {
const disk = JSON.parse(diskRaw) as Entry<T>
if (disk.expiresAt > now) {
mem.set(key, disk)
return disk.value // 磁盘命中:回灌内存
}
}
const fresh = await fetcher()
const entry: Entry<T> = { value: fresh, expiresAt: Date.now() + ttlMs }
storage.set(`cache:${key}`, JSON.stringify(entry))
if (mem.size >= MEM_CAP) {
const oldest = mem.keys().next().value
if (oldest !== undefined) mem.delete(oldest) // 超容量先赶队头
}
mem.set(key, entry)
return fresh
}
// 过期清理:冷启动后台跑一次
export function purgeExpiredCache(): void {
const now = Date.now()
for (const key of storage.getAllKeys()) {
if (!key.startsWith('cache:')) continue
const raw = storage.getString(key)
if (!raw) continue
const entry = JSON.parse(raw) as { expiresAt: number }
if (entry.expiresAt <= now) storage.remove(key)
}
if (storage.byteSize > 8 * 1024 * 1024) storage.trim()
}骨架里两个刻意的取舍:TTL 写进 value 一起存(而非 MMKV 的 key 元数据),磁盘条目自描述、清理不依赖额外索引;内存层上限硬编码——移动端内存是红线,LRU 容量按"存对象引用"的量级估,宁小勿大。
计数类:MMKV 读-改-写
UV/曝光计数这类"取出来加一再放回去":
// src/storage/counters.ts
import { storage } from './mmkv' // MMKV 实例
export function incrCounter(key: string, by = 1): number {
const next = (storage.getNumber(key) ?? 0) + by
storage.set(key, next) // 同 key 的 set 是覆盖写,累加结果直接替换旧值
return next
}MMKV 的同步 API 在单进程内串行于 JS 线程执行,读-改-写之间不会插入其他 JS 代码,业务上可当原子;但若开了 mode: 'multi-process'(App Extension/App Clips 共享数据的场景),进程间并发累加没有互斥保证——多进程写计数要走服务端或单进程代理。
坑清单
- MMKV 同 key 的
set是覆盖不是追加/合并。把"往数组 key 里 push 一条"写成storage.set('list', item),整张列表被单条数据覆盖,且因为没报错而潜伏很久。列表更新永远是:取出 → 内存里改 → 存回整个序列化结果(或直接换 SQLite)。 - AsyncStorage 读大对象卡 JS 线程。几百 KB 量级的 JSON 就能在低端机上感到卡顿——
stringify/parse的成本全在 JS 线程;别拿它当数据仓库,也别说"反正它是异步的",异步只躲开了 IO,躲不开序列化。 - iOS 备份塞爆用户 iCloud。documents 目录会被备份,把可再生缓存/大媒体放这里等于替用户备份(平台事实)。缓存进 cache 目录、必要时对单个文件设置"不参与备份"属性。
- Android 分区存储(SAF):targetSdk 36 下别幻想随意读写公共目录,应用专属目录随便用,公共媒体走选择器;细节回指
06-表单与交互.md。 - 密钥硬编码等于没加密:MMKV
encryptionKey或 secure store 里的兜底密钥写死在 JS bundle,只是把明文加密了一层"谁都能解密"的密文。密钥进 Keychain/Keystore 或服务端下发。 - secure store 的容量陷阱:Keychain 单值 2KB 量级,写大 JSON 会直接失败(iOS);它的设计用途是 token/密钥,不是迷你数据库。
- 原生库的单测必须 mock:MMKV 在 Jest/Vitest 下有自动 mock 实例;AsyncStorage 有内置 jest mock;expo-sqlite 在 node 环境不可用,要么注入假实现要么把它隔进集成测试。
- 文件 API 换代:expo-file-system 当前一代是对象化接口,旧文档里的字符串常量写法会随版本失效,升级依赖时同步过一遍文件读写代码(官方 SDK 56 一代有过重写)。
- 冷启动全量读盘:水合三四十个 KV 别排队
await串行读——AsyncStorage 用multiGet一次拿齐,MMKV 同步读无所谓,但把"读盘 → 再渲染"阻塞首屏的逻辑本身要重排。 - 登出忘清缓存:
clearAll()一把梭前先分清哪些 key 跨账号(设备标识、主题),哪些属于用户(草稿、缓存的 Feed)。按前缀分命名空间,登出只清用户前缀。
实战建议
存储层不要一次建全:从"一个 MMKV 实例 + zustand persist 适配器"起步(本章代码直接可用),等真出现"列表超过千条要局部改"或"要全文搜索"再引入 SQLite,出现"用户要求导出文件"再碰文件系统。每一层各守各的语义——内存归 Zustand、小 KV 归 MMKV(加密选项默认打开不心疼)、可查询数据归 SQLite、秘密归 secure store(只放小值)、文件按备份语义分 documents/cache——口诀再贴一次:状态在内存,偏好在 MMKV,数据在 SQLite,秘密在钥匙串,文件分目录。最后留一句工程审美:移动设备上大多数"存储 bug"其实是"选错通道 + 通道语义用反"(拿 KV 当数据库、拿 documents 当缓存、拿 AsyncStorage 存百 KB JSON),选型地图贴在 PR 模板里比本章管用。