1. 项目概述:为什么本地草稿不是“存个字符串”那么简单?
在 React Native 项目里,用户写了一半的长文、填到一半的复杂表单、甚至刚拍完还没编辑的多张照片——这些内容如果一退出就清空,用户大概率会直接卸载应用。但“本地草稿”四个字背后,藏着远超AsyncStorage.setItem('draft', JSON.stringify(data))的系统性挑战。我带团队做过 7 个中大型 RN 应用,其中 4 个因草稿逻辑粗糙被用户投诉“写半天全没了”,最严重的一次导致次日留存率下跌 12%。真正棘手的从来不是“保存”,而是三个必须同时解决的硬骨头:断网/闪退后如何精准恢复?草稿长期不提交怎么自动清理?App 升级后旧格式草稿还能不能打开?这三件事单独看都不难,但叠加在一起,就会暴露 RN 本地存储的底层缺陷——比如AsyncStorage不支持事务回滚,SQLite在 iOS 后台被系统强制挂起时写入失败无提示,而MMKV虽快却缺乏版本迁移钩子。更现实的是,用户行为根本不可预测:有人写 3 分钟就提交,有人把草稿存 3 周;有人在 v2.1 版本写草稿,v2.3 才回来编辑,中间我们重构了整个数据模型。所以这个设计本质是给用户行为“兜底”的工程方案,不是功能模块,而是产品信任感的基础设施。如果你正在做内容类、表单类或创作类 RN 应用,或者正被“草稿丢失”问题反复困扰,这篇就是你该抄的作业——它不讲抽象理论,只列我在线上环境跑过 18 个月、支撑日均 200 万草稿操作的完整实现。
2. 整体架构设计:三层隔离 + 时间戳驱动的健壮模型
2.1 为什么拒绝“单表草稿库”?——从崩溃现场反推架构
去年我们一个金融类 App 在 iOS 16 上出现诡异问题:用户编辑合同时,App 在后台被系统杀掉,再切回前台时草稿内容错乱——金额字段变成 null,但附件列表还在。查日志发现,AsyncStorage的setItem是异步非原子的:先写入主体数据,再写入附件元信息,中间被中断就产生脏数据。这逼我们放弃所有“简单存取”思路,转向分层防御架构。最终落地的是三层物理隔离 + 时间戳驱动模型,每层解决一类风险:
第一层:草稿元数据表(Metadata Layer)
用 SQLite 存储轻量元信息:id(UUID)、type(如 'contract'/'article')、version(当前 App 版本号,如 '2.3.1')、last_modified(毫秒时间戳)、status('active'/'expired'/'migrated')、size_bytes(预估占用空间)。关键设计是所有字段不可为空且带默认值,哪怕写入中途崩溃,这条记录也至少能保证status='active'和last_modified=0,后续可被清理脚本识别。第二层:草稿内容表(Content Layer)
用 MMKV 存储实际内容,Key 格式为draft_content_${uuid}。选择 MMKV 而非 SQLite 的核心原因是:写入延迟低于 0.5ms,且支持内存映射,即使 App 被强杀,已写入内存的数据页大概率被内核刷盘。我们实测在 iPhone 12 上,连续写入 100 条草稿,强杀后数据完整率 99.97%(对比 AsyncStorage 为 92.3%)。这里有个反直觉技巧:内容不存纯 JSON,而是序列化为 Protocol Buffer 二进制(用protobufjs),体积缩小 40%,且天然规避 JSON 解析失败风险——曾有用户输入非法 Unicode 字符导致JSON.parse()报错,草稿直接变空对象。第三层:版本迁移影子表(Migration Shadow Layer)
新增一张draft_migration_log表,字段仅含draft_id、from_version、to_version、migration_time。每次草稿被读取时,若metadata.version与当前 App 版本不匹配,触发迁移函数并记录日志。这层看似冗余,却是定位迁移失败的唯一依据——上线后我们靠它发现 3 个隐藏 Bug:v2.2 到 v2.3 的日期格式转换漏了时区处理,导致部分草稿的last_modified变成 1970 年。
提示:三层不是为了炫技,而是让故障可定位。当用户说“草稿不见了”,我们查
metadata表看是否被标记为expired;查migration_log看是否卡在某次升级;最后才查MMKV是否真丢失。这种分离让 80% 的问题能在 2 分钟内定界。
2.2 时间戳为何是核心驱动力?——用真实场景解释设计逻辑
所有草稿策略都围绕两个时间戳展开:last_modified(最后编辑时间)和created_at(创建时间)。它们不是装饰字段,而是业务规则的执行引擎:
过期策略:我们定义“30 天未编辑的草稿自动归档”。注意不是
created_at > 30 days,而是last_modified < now - 30 days。因为用户可能创建草稿后放着不管,但第 25 天突然编辑了一次,这时last_modified更新,过期计时器重置。这个细节让运营同学少处理 70% 的“误报过期”工单。恢复优先级:当用户同时有多个草稿,按
last_modified DESC排序,最新编辑的排第一。但加了个柔性规则:若某草稿last_modified在 2 小时内,且status='active',则强制置顶——避免用户刚写完切出去回消息,回来发现草稿被其他旧草稿盖住。迁移时机判断:版本迁移不在 App 启动时批量执行,而是在草稿被首次读取时触发。这样既避免启动白屏(RN 启动白屏问题常源于同步 IO),又保证用户只迁移自己用到的草稿,冷数据永远不消耗 CPU。
这套时间驱动逻辑,让我们把“草稿管理”从被动存储变成主动服务。它不依赖用户点击“保存按钮”,而是默默根据行为数据做决策——这才是移动端体验该有的样子。
3. 核心细节解析:恢复、过期、迁移三大模块的魔鬼参数
3.1 恢复模块:如何做到“闪退后内容零丢失”?
恢复的终极目标不是“能读出来”,而是“读出来的和闪退前一模一样”。这要求我们解决三个断点:写入断点、读取断点、状态断点。
写入断点防护(Write Atomicity):
MMKV 本身不保证多 key 写入的原子性。比如草稿含content和attachments两个 key,写入content成功但attachments失败,就会产生不一致。我们的解法是单 key 存储聚合对象:将内容和附件元信息合并为一个对象,再序列化为 Protobuf。结构如下:message DraftData { string content = 1; // 主体文本(已 base64 编码) repeated Attachment attachments = 2; int64 last_modified = 3; } message Attachment { string id = 1; // 附件 UUID string filename = 2; int64 size_bytes = 3; string mime_type = 4; }这样只需一次
MMKV.setString(key, protobufBytes),天然原子。实测在 1000 次模拟强杀测试中,数据一致性达 100%。读取断点防护(Read Consistency):
用户可能在草稿编辑页停留很久,期间 App 被系统回收内存,再次进入时 RN 重新初始化。此时若直接读 MMKV,可能拿到旧数据(因 JS 层缓存未更新)。我们在useEffect中加入双重校验:useEffect(() => { const loadDraft = async () => { // 第一步:检查 MMKV 中是否存在该 key const raw = mmkv.getString(`draft_content_${draftId}`); if (!raw) return; // 第二步:反序列化后验证时间戳是否合理(防时钟回拨) const data = decodeDraft(raw); if (data.last_modified > Date.now() + 5 * 60 * 1000) { // 允许 5 分钟误差 console.warn('Draft timestamp invalid, skipping'); return; } // 第三步:与 metadata 表比对 status const meta = await getDraftMeta(draftId); if (meta.status !== 'active') return; setDraft(data); }; loadDraft(); }, [draftId]);状态断点防护(Status Synchronization):
最隐蔽的坑是:用户编辑草稿时,网络请求提交成功,但回调函数没执行完 App 就崩溃了,导致草稿仍显示为“未提交”。我们引入状态机 + 本地事务日志:每次草稿状态变更(如editing -> submitting -> submitted)前,先写一条日志到 SQLite 的draft_transaction_log表,包含draft_id、from_status、to_status、timestamp、is_committed(布尔值)。提交成功后,再更新is_committed=true。App 启动时扫描未完成的日志,对is_committed=false的记录发起幂等查询(调用GET /drafts/{id}/status),确认真实状态后修正本地元数据。这个设计让状态不一致率从 0.8% 降到 0.003%。
实操心得:别信“MMKV 很快所以不用管原子性”。我们曾因忽略附件分离存储,在灰度发布时出现 12% 的草稿附件丢失,回滚后加了聚合存储才解决。快不是目的,一致才是底线。
3.2 过期模块:不是删数据,而是做用户预期管理
“过期”常被理解为DELETE FROM drafts WHERE last_modified < ?,但这会激怒用户——他们可能只是忘了提交,不是想删除。我们的策略是三级渐进式过期,每级对应不同用户心智:
第一级:静默归档(Silent Archiving)
过期阈值设为 30 天(last_modified < now - 30 days),但不过期,而是将status改为'archived',并添加archived_at字段。前端在草稿列表页,archived草稿默认折叠,标题旁加灰色小字“30 天未编辑”。用户点击展开才能看到内容。这招让过期草稿的恢复率提升 4 倍——因为用户根本没意识到它“过期”了,只是觉得“藏得深”。第二级:软删除(Soft Deletion)
若草稿archived_at < now - 90 days,则status='deleted',但数据仍在 MMKV 和 SQLite 中。前端完全不展示,但提供“恢复已删除草稿”入口(在设置页底部小字链接)。点击后拉取deleted状态的草稿列表,用户可勾选恢复。这个入口每月被点击约 2000 次,其中 63% 的恢复操作发生在删除后 7 天内。第三级:硬清理(Hard Cleanup)
真正删除只在两个时机:① 用户手动点击“永久删除”;② App 启动时检测到磁盘空间不足(freeSpace < 100MB),则按archived_at ASC清理最老的 20%deleted草稿。绝不自动清理 active 草稿,这是红线。
关键参数计算过程:30 天阈值来自用户行为分析。我们埋点统计了 50 万条草稿的生命周期,发现 92.7% 的草稿在创建后 30 天内被提交或删除,而 31-60 天提交的仅占 4.2%,且多为误操作(如用户点错按钮)。所以 30 天是平衡“保留价值”和“存储成本”的黄金点。
3.3 版本迁移模块:如何让 v1.0 草稿在 v3.0 里正常打开?
版本迁移最怕“一刀切”——v2.0 升 v3.0 时,把所有旧草稿批量转成新格式,结果迁移脚本出错,全量草稿变砖。我们的方案是按需迁移 + 向下兼容 + 熔断机制。
按需迁移(On-Demand Migration):
如前所述,迁移只在草稿被读取时触发。具体流程:- 读取
metadata.version,若等于当前 App 版本,直接返回内容; - 若小于当前版本,加载对应迁移器(如
migrate_v2_1_to_v2_2.ts); - 执行迁移函数,传入原始数据和目标版本;
- 迁移成功后,更新
metadata.version和migration_log。
- 读取
向下兼容(Backward Compatibility):
每次数据模型变更,我们坚持只增不减字段。例如 v2.2 新增is_pinned: boolean字段,v2.1 的草稿读取时,该字段自动设为false,而非报错。Protobuf 的optional字段特性完美支持这点——未定义字段默认为类型零值(string 为空串,int 为 0)。熔断机制(Circuit Breaker):
迁移函数包裹在 try-catch 中,且设置超时(Promise.race([migrate(), timeout(5000)]))。若超时或抛错,记录错误日志,并将草稿status设为'migration_failed',前端显示友好提示:“此草稿格式较旧,已为您备份,稍后工程师会优化”。同时,该草稿仍以旧格式可读(因我们保留了 v2.1 的解码器),用户能复制内容到新草稿。上线后,熔断触发率 0.0012%,全部是低端安卓机内存不足导致,无一例数据丢失。
注意:迁移器必须独立部署。我们把每个迁移器编译成单独 JS 文件,放在
assets/migrations/目录。这样 v3.0 的包里只含 v2.x→v3.0 的迁移器,不污染主 bundle。实测减少首屏加载时间 120ms。
4. 实操过程:从零搭建可运行的草稿系统(含完整代码)
4.1 环境准备与依赖安装
我们放弃react-native-sqlite-storage(API 繁琐且 Android 兼容性差),改用@nozbe/watermelondb—— 它基于 SQLite,但提供声明式 API 和自动索引,且对 RN 支持极佳。MMKV 选用react-native-mmkv,它是官方维护的封装,比社区版稳定。
# 安装核心依赖 npm install @nozbe/watermelondb react-native-mmkv npx pod-install # iOS 需要 # 安装 WatermelonDB 的原生依赖(Android) # android/app/build.gradle dependencies { implementation 'com.facebook.soloader:soloader:0.10.1' }初始化 MMKV(在index.js或App.tsx顶层):
import { MMKV } from 'react-native-mmkv'; export const draftStorage = new MMKV({ id: 'draft_storage', encryptionKey: 'your-32-byte-encryption-key-here', // 生产环境必填 });WatermelonDB 初始化(创建database/index.ts):
import { Database, Model, Q } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { field, relation } from '@nozbe/watermelondb/decorators'; // 定义草稿元数据表 export class DraftMetadata extends Model { static table = 'draft_metadata'; static associations = { draft_contents: { type: 'has_many', foreignKey: 'draft_id' }, }; @field('type') type!: string; @field('version') version!: string; @field('last_modified') lastModified!: number; @field('status') status!: 'active' | 'archived' | 'deleted' | 'migration_failed'; @field('archived_at') archivedAt!: number; @field('size_bytes') sizeBytes!: number; } // 定义迁移日志表 export class DraftMigrationLog extends Model { static table = 'draft_migration_log'; @field('draft_id') draftId!: string; @field('from_version') fromVersion!: string; @field('to_version') toVersion!: string; @field('migration_time') migrationTime!: number; } // 创建数据库实例 export const database = new Database({ adapter: new SQLiteAdapter({ dbName: 'draft_db', schema: appSchema({ version: 1, tables: [ tableSchema({ name: 'draft_metadata', columns: [ { name: 'type', type: 'string' }, { name: 'version', type: 'string' }, { name: 'last_modified', type: 'number' }, { name: 'status', type: 'string' }, { name: 'archived_at', type: 'number' }, { name: 'size_bytes', type: 'number' }, ], }), tableSchema({ name: 'draft_migration_log', columns: [ { name: 'draft_id', type: 'string' }, { name: 'from_version', type: 'string' }, { name: 'to_version', type: 'string' }, { name: 'migration_time', type: 'number' }, ], }), ], }), }), modelClasses: [DraftMetadata, DraftMigrationLog], });4.2 核心草稿管理器实现(含恢复、过期、迁移)
创建services/draftManager.ts,这是整个系统的中枢:
import { Q, Query, Collection } from '@nozbe/watermelondb'; import { draftStorage } from '../storage/mmkv'; import { database } from '../database'; import { DraftMetadata, DraftMigrationLog } from '../database'; import { encodeDraft, decodeDraft } from '../utils/protobuf'; import { v4 as uuidv4 } from 'uuid'; // 草稿管理器单例 class DraftManager { private metadataCollection: Collection<DraftMetadata>; private migrationLogCollection: Collection<DraftMigrationLog>; constructor() { this.metadataCollection = database.collections.get('draft_metadata'); this.migrationLogCollection = database.collections.get('draft_migration_log'); } // 创建新草稿 async create<T>(type: string, initialData: T): Promise<string> { const id = uuidv4(); const now = Date.now(); // 1. 写入 MMKV(原子操作) const encoded = encodeDraft({ content: JSON.stringify(initialData), attachments: [], last_modified: now, }); draftStorage.set(`draft_content_${id}`, encoded); // 2. 写入元数据(WatermelonDB 事务) await this.metadataCollection.create(draft => { draft._raw.id = id; draft.type = type; draft.version = __APP_VERSION__; // 全局变量,构建时注入 draft.lastModified = now; draft.status = 'active'; draft.archivedAt = 0; draft.sizeBytes = encoded.length; }); return id; } // 读取草稿(含迁移) async get<T>(id: string): Promise<T | null> { try { // 1. 获取元数据 const meta = await this.metadataCollection.find(id); if (meta.status === 'deleted') return null; // 2. 读取原始内容 const raw = draftStorage.getString(`draft_content_${id}`); if (!raw) return null; // 3. 检查版本并迁移 let decoded = decodeDraft(raw); if (meta.version !== __APP_VERSION__) { decoded = await this.migrateDraft(id, decoded, meta.version, __APP_VERSION__); } // 4. 更新元数据的时间戳和版本 await meta.update(draft => { draft.lastModified = Date.now(); draft.version = __APP_VERSION__; }); return JSON.parse(decoded.content) as T; } catch (error) { console.error('Failed to get draft', id, error); return null; } } // 版本迁移核心函数 private async migrateDraft( id: string, data: any, fromVersion: string, toVersion: string ): Promise<any> { // 按版本链路调用迁移器 const migrationPath = this.getMigrationPath(fromVersion, toVersion); for (const [from, to] of migrationPath) { const migrator = await this.loadMigrator(from, to); data = migrator(data); // 记录迁移日志 await this.migrationLogCollection.create(log => { log.draftId = id; log.fromVersion = from; log.toVersion = to; log.migrationTime = Date.now(); }); } return data; } // 过期检查与归档(每日启动时调用) async checkExpiry() { const now = Date.now(); const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000; const ninetyDaysAgo = now - 90 * 24 * 60 * 60 * 1000; // 归档 30 天未编辑的 active 草稿 const toArchive = await this.metadataCollection .query(Q.where('status', 'active'), Q.where('last_modified', Q.lt(thirtyDaysAgo))) .fetch(); for (const draft of toArchive) { await draft.update(d => { d.status = 'archived'; d.archivedAt = now; }); } // 软删除 90 天未编辑的 archived 草稿 const toDelete = await this.metadataCollection .query(Q.where('status', 'archived'), Q.where('archived_at', Q.lt(ninetyDaysAgo))) .fetch(); for (const draft of toDelete) { await draft.update(d => { d.status = 'deleted'; }); } } // 加载迁移器(动态 import) private async loadMigrator(from: string, to: string) { try { const module = await import(`../migrations/${from}_to_${to}`); return module.default; } catch (e) { throw new Error(`No migrator found for ${from} → ${to}`); } } private getMigrationPath(from: string, to: string): Array<[string, string]> { // 简化版路径查找,实际项目用拓扑排序 const path: Array<[string, string]> = []; let current = from; while (current !== to) { const next = this.getNextVersion(current); if (!next || next > to) break; path.push([current, next]); current = next; } return path; } private getNextVersion(version: string): string | undefined { // 实现版本递增逻辑,如 '2.1.0' → '2.1.1' return undefined; // 此处省略具体实现 } } export const draftManager = new DraftManager();4.3 在组件中使用草稿管理器(React Hook 封装)
创建hooks/useDraft.ts,让业务组件无感知地使用:
import { useState, useEffect, useCallback } from 'react'; import { draftManager } from '../services/draftManager'; interface UseDraftResult<T> { draft: T | null; isLoading: boolean; save: (data: T) => Promise<void>; discard: () => Promise<void>; restore: () => Promise<void>; } export function useDraft<T>(id: string | null, type: string): UseDraftResult<T> { const [draft, setDraft] = useState<T | null>(null); const [isLoading, setIsLoading] = useState(true); // 加载草稿 const loadDraft = useCallback(async () => { if (!id) return; setIsLoading(true); const data = await draftManager.get<T>(id); setDraft(data); setIsLoading(false); }, [id]); // 保存草稿 const save = useCallback(async (data: T) => { if (!id) { // 新建草稿 const newId = await draftManager.create(type, data); // 更新路由或状态,让组件知道新 ID console.log('New draft created:', newId); return; } // 更新现有草稿 const encoded = encodeDraft({ content: JSON.stringify(data), attachments: [], // 实际项目需从上下文获取 last_modified: Date.now(), }); draftStorage.set(`draft_content_${id}`, encoded); await draftManager.metadataCollection.find(id).update(meta => { meta.lastModified = Date.now(); meta.sizeBytes = encoded.length; }); }, [id, type]); // 丢弃草稿(软删除) const discard = useCallback(async () => { if (!id) return; await draftManager.metadataCollection.find(id).update(meta => { meta.status = 'deleted'; }); }, [id]); // 恢复已删除草稿 const restore = useCallback(async () => { if (!id) return; await draftManager.metadataCollection.find(id).update(meta => { meta.status = 'active'; meta.archivedAt = 0; }); }, [id]); useEffect(() => { loadDraft(); }, [loadDraft]); return { draft, isLoading, save, discard, restore }; } // 在组件中使用示例 function ArticleEditor({ draftId }: { draftId?: string }) { const { draft, isLoading, save, discard } = useDraft<ArticleDraft>(draftId, 'article'); if (isLoading) return <LoadingSpinner />; return ( <View> <TextInput value={draft?.title} onChangeText={text => save({ ...draft!, title: text })} /> <Button title="保存草稿" onPress={() => save(draft!)} /> <Button title="丢弃" onPress={discard} /> </View> ); }5. 常见问题与排查技巧实录:线上踩过的 12 个坑
5.1 启动白屏与草稿加载冲突
现象:RN 启动时白屏超过 3 秒,日志显示MMKV getString耗时 2.1 秒。
根因:MMKV 默认在主线程执行 I/O,而草稿列表页需批量读取 20+ 个草稿,阻塞 UI 线程。
解决方案:
- 对草稿列表页,改用
MMKV.getStringAsync()(RN 0.69+ 支持),它在后台线程执行; - 对单个草稿详情页,保持同步读取(因只读 1 个,耗时 < 5ms);
- 添加加载骨架屏,避免白屏感。
实测:iOS 上白屏时间从 2100ms 降至 320ms,Android 从 3400ms 降至 480ms。
5.2 iOS 后台草稿写入失败
现象:用户在编辑页切到微信,5 分钟后回来,发现刚输入的内容没了。
根因:iOS 会在 App 进入后台 30 秒后挂起进程,此时MMKV.setString()调用虽返回 true,但数据未真正落盘。
解决方案:
- 在
AppState.addEventListener('change')中监听background状态,立即触发draftStorage.flush()强制刷盘; - 同时在
useEffect清理函数中调用flush(),确保组件卸载前数据安全。
useEffect(() => { const handleAppStateChange = (state: AppStateStatus) => { if (state === 'background') { draftStorage.flush(); // 立即刷盘 } }; AppState.addEventListener('change', handleAppStateChange); return () => { AppState.removeEventListener('change', handleAppStateChange); draftStorage.flush(); // 组件销毁前再刷一次 }; }, []);5.3 Android 低内存机型草稿丢失
现象:三星 Galaxy A12 用户反馈草稿频繁丢失,集中在内存 < 2GB 的设备。
根因:Android 系统在内存紧张时,会杀死后台进程并清空MMKV内存映射页,而MMKV的flush()在低内存下可能失败。
解决方案:
- 启用
MMKV的ENABLE_FSYNC选项(Android 专属):export const draftStorage = new MMKV({ id: 'draft_storage', enableMultiProcess: true, enableFsync: true, // 关键!强制 fsync() }); - 同时在写入后增加校验:
draftStorage.set(key, value); // 等待 10ms 确保 fsync 完成 await new Promise(resolve => setTimeout(resolve, 10)); const verified = draftStorage.getString(key); if (verified !== value) { console.error('MMKV write verification failed'); }
5.4 版本迁移器热更新失败
现象:v2.5 发布后,部分用户 v2.4 的草稿无法打开,报错“Cannot find module '../migrations/2.4_to_2.5'”。
根因:React Native 的 Metro 打包器会 Tree-shake 未引用的文件,动态import()的迁移器被误删。
解决方案:
- 在
metro.config.js中添加assetExts排除迁移器目录:resolver: { assetExts: ['bin', 'txt', 'jpg', 'png'], // 不处理 .ts 文件 } - 更可靠的做法:将所有迁移器打包进主 bundle,用对象字典代替动态 import:
import * as migrations from '../migrations'; const MIGRATORS = { '2.1_to_2.2': migrations.v2_1_to_2_2, '2.2_to_2.3': migrations.v2_2_to_2_3, };
5.5 草稿列表页性能崩塌
现象:草稿超过 500 条时,列表页滚动卡顿,FPS 降至 10。
根因:WatermelonDB 的collection.query().fetch()默认返回所有字段,而draft_metadata表含content大字段(虽未存,但 ORM 会尝试加载关联),导致内存暴涨。
解决方案:
- 使用
onlyFields()限定查询字段:const drafts = await this.metadataCollection .query(Q.where('status', Q.notIn(['deleted']))) .fetch(); // 改为 const drafts = await this.metadataCollection .query(Q.where('status', Q.notIn(['deleted']))) .onlyFields(['id', 'type', 'last_modified', 'status']) .fetch(); - 列表页只显示摘要,详情页再加载完整内容。
5.6 多端登录草稿同步冲突
现象:用户在手机写草稿,同时在 iPad 编辑同一草稿,最后提交时内容覆盖。
解决方案:这不是本地草稿问题,而是需要服务端协同。我们在草稿元数据中增加client_id(设备唯一标识)和revision(乐观锁版本号),提交时校验revision,冲突则提示“其他设备正在编辑”,并提供合并工具。本地草稿系统只负责revision的本地维护。
5.7 测试覆盖率陷阱
现象:单元测试 100% 通过,但线上仍有草稿丢失。
根因:测试用jest.mock('react-native-mmkv')模拟了完美 I/O,但真实设备有磁盘满、权限拒绝等异常。
解决方案:
- 增加集成测试:在真机上运行,用
adb shell模拟磁盘满(adb shell 'dd if=/dev/zero of=/sdcard/fill bs=1M count=5000'); - 测试强杀:
adb shell am kill com.yourapp后检查草稿完整性。
5.8 附件大文件导致草稿体积爆炸
现象:用户拍照后草稿体积达 20MB,App 启动变慢。
解决方案:
- 附件不存草稿内,只存元信息(URL、尺寸、缩略图);
- 真实文件存
RNFS.DocumentDirectoryPath,草稿中只存相对路径; - 草稿
size_bytes字段只统计元信息大小,不计入附件。
5.9 时间戳时区错乱
现象:用户在纽约编辑草稿,飞到东京后last_modified显示为 1970 年。
根因:Date.now()返回本地时间戳,但跨时区设备系统时间可能不同步。
解决方案:
- 所有时间戳统一用
Date.now()(毫秒数),它本质是 UTC 时间