本节摘要:状态活在内存里,应用一杀就归零。本节把轻记账的账单与设置落到磁盘:键值存储管偏好设置,嵌入式数据库管结构化账单,给出完整的读写代码与启动时装载流程。仓库模式的红利在本节兑现——持久化只换掉仓库实现,控制器与界面一行不改。
本地持久化的选项听着很多(键值、文件、关系库、对象库),决策却能压缩成一个标准:数据是"一整块配置"还是"会增删查改的记录集"?
一整块配置——主题开关、同步频率、上次打开的页面——键值存储(shared_preferences 插件)最合适:一个 key 对一个 value,整存整取,没有查询需求,为其上数据库是杀鸡用牛刀。
记录集——账单会不断增加、按月份查、按分类算、单条删除——就该上嵌入式数据库。记录集被塞进键值里(比如把整个列表转 JSON 存成一个 key)是初学者高发反模式:每次改动全量重写,几百条还行,几千条就会在保存时卡住界面(第 2 章的同步计算警告再次应验)。
| 方案 | 数据形状 | 典型容量 | 代价 | 轻记账用它存 |
|---|---|---|---|---|
| shared_preferences | 一块一值的配置 | 几十条键 | 无查询能力 | 主题、预算、同步开关 |
| sqflite(关系库) | 表与行,要按条件查 | 数万行以上 | 要写 SQL 与迁移 | 账单主表 |
| hive 或对象库 | 对象直存,按盒取 | 中小规模 | 弱于复杂查询 | 可替换 sqflite 的备选 |
移动端三者的 API 风格差异不小,但通过仓库模式隔离后,上层感受不到差别——这正是上一节坚持"数据来源唯一入口"的回报时刻。
以"自动同步"开关与"月预算"为例。注意插件返回的是 Future——读写在磁盘上,别在 build 里同步等它:
import 'package:shared_preferences/shared_preferences.dart'; class SettingsRepository { static const _kAutoSync = 'auto_sync'; static const _kBudget = 'monthly_budget'; Future<void> setAutoSync(bool on) async { final sp = await SharedPreferences.getInstance(); await sp.setBool(_kAutoSync, on); } Future<bool> autoSync() async { final sp = await SharedPreferences.getInstance(); return sp.getBool(_kAutoSync) ?? true; // 缺省值:首次安装也有合理行为 } Future<void> setBudget(double value) async { final sp = await SharedPreferences.getInstance(); await sp.setDouble(_kBudget, value); } }
两个工程习惯:键名常量化(散落的字符串键是排障灾难);每次读取都带缺省值(首次安装的空存储是常态而不是异常)。设置页把开关与仓库接起来后,重启应用的开关状态依然是上次的样子——持久化的最小闭环就通了。
sqflite 是移动端关系库的主流封装。轻记账的账单表按"启动建表、按月查询、增删改走标准语句"组织:
import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; class DbBillRepository implements BillRepository { Database? _db; Future<Database> _open() async { if (_db != null) return _db!; final dir = await getDatabasesPath(); _db = await openDatabase( p.join(dir, 'light_ledger.db'), version: 1, onCreate: (db, v) => db.execute(''' CREATE TABLE bills( id TEXT PRIMARY KEY, title TEXT NOT NULL, amount REAL NOT NULL, category TEXT NOT NULL, createdAt TEXT NOT NULL, synced INTEGER NOT NULL DEFAULT 0 )'''), ); return _db!; } @override Future<List<Bill>> loadAll() async { final db = await _open(); final rows = await db.query('bills', orderBy: 'createdAt DESC'); return rows.map(Bill.fromMap).toList(); } @override Future<void> save(Bill bill) async { final db = await _open(); await db.insert('bills', bill.toMap()..['synced'] = bill.synced ? 1 : 0, conflictAlgorithm: ConflictAlgorithm.replace); } @override Future<void> delete(String id) async { final db = await _open(); await db.delete('bills', where: 'id = ?', whereArgs: [id]); } }
接口细节说明:主键用业务 id(同步场景下本地生成的 id 必须全局唯一,第 5 章的网络合并靠它去重);时间存 ISO 字符串,排序与跨端序列化都省事;synced 标志位是给第 5 章同步逻辑埋的伏笔——离线记的账先落盘标记为未同步,联网后补传。数据库操作全部返回 Future,sqflite 内部有执行队列,不需要再包一层同步锁。
按月查询是账单页的真实需求,SQL 的 where 与聚合一次完成,比把全表读进内存再过滤省一个数量级的内存与时间:
Future<List<Bill>> loadMonth(int year, int month) async { final db = await _open(); final prefix = '$year-${month.toString().padLeft(2, '0')}'; final rows = await db.query('bills', where: 'createdAt LIKE ?', whereArgs: ['$prefix%'], orderBy: 'createdAt DESC'); return rows.map(Bill.fromMap).toList(); }
何时读盘? 不要在 main 里同步等数据库(拖慢启动),用"异步初始化 + 骨架屏"的标准姿势:界面先渲染出占位结构,状态层发出装载请求,数据到达后无缝替换。Riverpod 里是一个带 loading 态的 AsyncNotifier:
final billStoreProvider = AsyncNotifierProvider<BillStore, List<Bill>>(BillStore.new); class BillStore extends AsyncNotifier<List<Bill>> { @override Future<List<Bill>> build() async { final repo = ref.watch(billRepositoryProvider) as DbBillRepository; return repo.loadAll(); // 数据到达时,watch 它的界面自动换掉骨架屏 } }
表结构要改怎么办? version 号加一,onUpgrade 里写迁移脚本——例如新版本要给账单加"备注"列:
onUpgrade: (db, oldV, newV) async { if (oldV < 2) { await db.execute('ALTER TABLE bills ADD COLUMN remark TEXT'); } },
迁移纪律一条:只前进,不后退(老版本用户升级后不能丢数据),每条迁移写成幂等的小步骤,测试机库里塞一份上一版本的真实数据验证升级路径。发布前跑一次"旧库升级"演练,比上线后回滚省十倍的命。
数据在本地站稳了。但界面还只会"压栈"——下一章的最后一节,把页面流转升级成可寻址的路由表。