文章目录
- 本地优先架构实践:SQLite + Repository 模式 + 自动备份
- 为什么选择本地优先
- 数据库设计
- Schema:7 张表
- 索引策略
- Repository 模式
- 自动备份:5 行代码的数据安全
- 数据导出/导入
- JSON 导出
- JSON 导入(最复杂的部分)
- CSV 导出
- 局限性
- 总结
本地优先架构实践:SQLite + Repository 模式 + 自动备份
不联网、不注册、不上云。你的财务数据只存在你手机上。
为什么选择本地优先
做"本地优先"(Local-First)不是因为技术限制,而是产品定位的刻意选择:
1. 隐私
财务数据是最敏感的个人信息之一。你有多少存款、你的房子值多少、你的信用卡欠多少——这些信息不应该存在于任何第三方服务器上。WorthBase 不主动发起任何网络请求。
2. 离线可用
地铁里、飞机上、山区旅行——任何地方都能打开 App 查看和更新数据。不依赖网络状态。
3. 数据所有权
你的数据是你的。不存在"服务商停止运营后数据丢失"的问题。数据就是一个.db文件,你可以随时复制、备份、迁移。
数据库设计
Schema:7 张表
-- 账户表(8 种类型)CREATETABLEaccounts(idTEXTPRIMARYKEY,nameTEXTNOTNULL,typeTEXTNOTNULL,-- wechat/alipay/bank_card/cash/fund/credit_card/loan/othericonTEXT,sort_orderINTEGERDEFAULT0,deleted_atTEXT,-- 软删除,不物理删除created_atTEXT,updated_atTEXT);-- 余额快照表(追加式,不修改历史)CREATETABLEbalance_snapshots(idTEXTPRIMARYKEY,account_idTEXTNOTNULLREFERENCESaccounts(id),balanceREALNOTNULL,snapshot_dateTEXTNOTNULL);-- 实物资产表(8 种分类,4 种分摊方式)CREATETABLEassets(idTEXTPRIMARYKEY,nameTEXTNOTNULL,categoryTEXTNOTNULL,-- vehicle/real_estate/electronics/digital/home/luxury/precious_metal/otherpurchase_dateTEXTNOTNULL,purchase_priceREALNOTNULL,amortization_typeTEXTNOTNULL,expected_lifespan_monthsINTEGER,residual_valueREAL,valuation_trackingINTEGERDEFAULT0,current_valuationREAL,statusTEXTDEFAULT'active',-- active/retired/soldsell_dateTEXT,sell_priceREAL,weight_gramsREAL,image_pathTEXT,created_atTEXT,updated_atTEXT);-- 经常性支出(保险、停车费等,按月生效)CREATETABLErecurring_expenses(idTEXTPRIMARYKEY,asset_idTEXTREFERENCESassets(id),nameTEXTNOTNULL,amountREALNOTNULL,effective_fromTEXTNOTNULL,effective_toTEXT,-- null = 持续生效ended_reasonTEXT);-- 一次性维护记录(维修、保养)CREATETABLEmaintenance_records(idTEXTPRIMARYKEY,asset_idTEXTREFERENCESassets(id),nameTEXTNOTNULL,amountREALNOTNULL,dateTEXTNOTNULL,amortizeINTEGERDEFAULT0-- 是否纳入月均分摊);-- 估值历史(可选追踪)CREATETABLEvaluation_history(idTEXTPRIMARYKEY,asset_idTEXTREFERENCESassets(id),valuationREALNOTNULL,recorded_dateTEXTNOTNULL);-- 应用设置(KV 存储)CREATETABLEsettings(keyTEXTPRIMARYKEY,valueTEXTNOTNULL);索引策略
针对高频查询场景建索引:
-- 余额快照:按账户+日期查询(最新余额)CREATEINDEXidx_snapshots_account_dateONbalance_snapshots(account_id,snapshot_date);-- 余额快照:按日期查询(趋势图数据)CREATEINDEXidx_snapshots_dateONbalance_snapshots(snapshot_date);-- 经常性支出:按生效区间查询(当月有哪些支出生效)CREATEINDEXidx_recurring_effectiveONrecurring_expenses(effective_from,effective_to);Repository 模式
每个表对应一个 Repository 对象,封装所有 SQL 操作:
// src/db/account-repository.ts (简化)exportconstAccountRepository={asyncgetAll():Promise<Account[]>{...},asynccreate(account:Account):Promise<void>{...},asyncupdate(id:string,updates:Partial<Account>):Promise<void>{...},asyncsoftDelete(id:string):Promise<void>{...},// SET deleted_at = now// 聚合查询:获取所有账户的最新余额asyncgetAllLatestBalances():Promise<Map<string,number>>{constresult=awaitdb.getAllAsync(`SELECT bs.account_id, bs.balance FROM balance_snapshots bs INNER JOIN ( SELECT account_id, MAX(snapshot_date) as max_date FROM balance_snapshots GROUP BY account_id ) latest ON bs.account_id = latest.account_id AND bs.snapshot_date = latest.max_date INNER JOIN accounts a ON bs.account_id = a.id WHERE a.deleted_at IS NULL`);returnnewMap(result.map(r=>[r.account_id,r.balance]));},};没有 ORM,没有 query builder,纯 SQL。对于 7 张表的项目来说,ORM 引入的抽象层比它解决的问题更多。
自动备份:5 行代码的数据安全
本地优先 App 最大的风险是数据丢失——手机丢了、App 被卸载、数据库文件损坏。
我的解决方案极其简单:
// app/_layout.tsx:58-66useEffect(()=>{constsubscription=AppState.addEventListener('change',(nextAppState)=>{constprev=appStateRef.current;appStateRef.current=nextAppState;// App 退到后台 → 自动备份if(prev==='active'&&(nextAppState==='background'||nextAppState==='inactive')){BackupService.createBackup();}});return()=>subscription.remove();},[]);BackupService.createBackup()做的事情就是把 SQLite.db文件原封不动地复制到backups/目录,文件名带时间戳。最多保留 3 份,旧的自动删除。
没有后台任务、没有定时器、没有推送通知。App 退后台就备份,就这么朴素。
数据导出/导入
JSON 导出
用户手动触发,生成结构化 JSON 文件:
{"version":1,"exportDate":"2026-07-12T08:00:00.000Z","accounts":[...],"balanceSnapshots":[...],"assets":[...],"recurringExpenses":[...],"maintenanceRecords":[...],"valuationHistory":[...],"settings":{...}}从 7 个 Repository 各取全量数据,打包成一个 JSON。通过expo-sharing调起系统分享面板,用户可以发到微信、邮件、云盘。
JSON 导入(最复杂的部分)
导入不是简单的"把 JSON 写进数据库"。核心挑战是ID 重映射:
- 清空所有 7 张表
- 逐条插入 accounts,记录
oldId → newId映射 - 逐条插入 assets,同样记录映射
- 插入子表(balance_snapshots、recurring_expenses 等),用映射表替换外键
- 如果父记录不存在(孤儿记录),静默跳过
整个过程在一个数据库事务内执行。任何一步失败,全部回滚。
// src/services/import-service.ts (简化)awaitdb.withTransactionAsync(async()=>{// 清空所有表for(consttableofTABLES){awaitdb.execAsync(`DELETE FROM${table}`);}// 重建账户,记录 ID 映射constaccountIdMap=newMap<string,string>();for(constaccountofdata.accounts){constnewId=generateId();accountIdMap.set(account.id,newId);awaitAccountRepository.create({...account,id:newId});}// 重建子表,替换外键for(constsnapshotofdata.balanceSnapshots){constnewAccountId=accountIdMap.get(snapshot.accountId);if(!newAccountId)continue;// 孤儿记录,跳过awaitBalanceSnapshotRepository.create({...snapshot,accountId:newAccountId,});}// ... 其他表类似});CSV 导出
给人看的格式,方便在 Excel 里查看:
日期, 微信零钱, 支付宝余额, 招商储蓄卡, ..., 总计 2025-01-15, 3500, 12000, 45000, ..., 60500 2025-02-15, 4000, 11500, 46000, ..., 61500局限性
本地优先不是万能的:
| 局限 | 说明 | 缓解方案 |
|---|---|---|
| 无多端同步 | 换设备需要手动导出/导入 | JSON 文件 + 系统分享 |
| 设备丢失 = 数据丢失 | 如果没备份 | 自动备份到本地(退后台触发) |
| 图片未备份 | 资产照片存在文件系统中,.db备份不包含图片 | 已知问题,计划中 |
| 无加密 | SQLite 文件可以被直接读取 | 应用锁保护 App 访问 |
总结
本地优先架构的核心原则:简单胜过完备。
- 不用云同步,用文件导出/导入
- 不用后台任务,用 AppState 监听
- 不用 ORM,用纯 SQL
- 不用复杂加密,用应用锁
对于一个个人财务 App 来说,这些"简单方案"已经够用了。
🔗源码: github.com/callmebg/worthbase | db/ | services/
CSDN 标签:
SQLiteReact Native数据安全本地存储架构
掘金话题:前端数据库React Native架构