English version: Shipping a database with your Flutter app。这是交易类 App 架构的 Flutter 实现篇,原理见本地优先。
本地优先那篇给出的设计(发一份快照,按版本同步增量)在 Flutter 里有一套具体而略微琐碎的实现。这里把它讲清楚。
选择存储
sqflite 给你的是 SQLite 加原生 SQL。drift 建在它之上(或使用自带引擎),补上了类型化查询、迁移和一等公民级的 isolate 支持。对一个结构会持续演进的目录来说,光是 drift 的迁移能力就值回票价;而如果你只有几个键值设置项,那么用 shared_preferences 而不用别的才是对的。
值得避开的错误是:因为文档型存储看起来更简单就选了它,然后发现自己需要在几十万行上做带排序的前缀搜索。那是一个关系型问题,有成熟答案,而且 SQLite 本来就在设备上。
打包快照,只拷贝一次
把预构建的数据库作为资源发布,首次启动时拷贝到应用的文档目录。必须拷贝,因为资源是只读的,而 SQLite 需要一个可写文件。
Future<File> ensureCatalog() async {
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'catalog.db'));
final shippedVersion = 812; // 重新切快照时递增
if (await file.exists() && await _dataVersion(file) >= shippedVersion) {
return file; // 用户那份更新,保留它
}
final bytes = await rootBundle.load('assets/db/catalog.db');
await file.writeAsBytes(bytes.buffer.asUint8List(), flush: true);
return file;
}
中间那个比较,是最容易写错的地方。应用更新之后你不能无条件覆盖:一个已经同步了几个月增量的用户,手里那份目录可能比你刚发布的这份更新。只有当打包的快照确实更靠前时才拷贝,其余交给增量同步。
另一个细节是,这次拷贝发生在首次启动,而那恰恰是你最不希望出现长时间阻塞的时刻。拷贝几十兆需要真实的时间,所以把它放到 UI isolate 之外,并展示一个有进度的首次运行状态,而不是一个卡住的启动页。
两个版本,绝不混用
按原理篇的要求,把这两者分开:
- 数据版本:内容有多新鲜。它在服务端发布变更时前进,驱动增量同步。存在一张你自己控制的元数据表里。
- 结构版本:表的形状。它在你的应用发生变化时前进,驱动迁移。SQLite 的
user_version,或者 drift 的schemaVersion,是它天然的归属。
把两者混在一起会产生两种失败之一:一次应用更新丢掉了已同步的目录、重新装回快照;或者某次迁移永远不会执行,因为那个版本号正忙着追踪数据。
@override
MigrationStrategy get migration => MigrationStrategy(
onUpgrade: (m, from, to) async {
if (from < 3) await m.addColumn(instruments, instruments.board);
// 数据版本不动,目录内容得以保留
},
);
搜索:FTS,加上你真正要搜的那些列
在几十万行上做即时搜索,正是 SQLite 全文搜索的用途。在主表旁边建一张 FTS5 表来查询,而不要用 LIKE '%...%',后者用不上索引,会随数据量线性退化。
有意思的是多语言匹配。用户会用中文名、英文名、代码、拼音,或者拼音首字母去搜同一只标的,而且期待这五种都能用。这不是分词器问题,而是一个数据问题。在构建期就把这些变体生成好,作为可搜索的列存起来:
| 列 | 示例 |
|---|---|
symbol | 00700 |
name_zh | 腾讯控股 |
name_en | Tencent Holdings |
pinyin | tengxunkonggu |
initials | txkg |
这样一次带前缀匹配的 FTS 查询就覆盖了所有输入方式,而排序只是“按哪一列命中”加上一个随目录下发的热度分。把这件事放在构建期而不是设备上,既省掉了启动开销,也让各平台行为完全一致。
把查询挪出 UI isolate
一次大表查询会阻塞运行它的那个 isolate;如果那是 UI isolate,就意味着打字时掉帧。而这是最糟的位置,因为边输入边搜索恰恰是响应速度被评判的时刻。
drift 支持把数据库跑在后台 isolate 里,这是干净的答案(drift 2.x,NativeDatabase.createInBackground):
final db = AppDatabase(NativeDatabase.createInBackground(file));
然后对输入做防抖(每个按键发一次查询是白干),取消已被取代的查询,并用 LIMIT 把结果集控制住。没有人会翻过前面几十条匹配,为了显示二十条而取回一千行是纯粹的成本。
只同步增量,不同步整个世界
快照就位之后,客户端只问变了什么:
final since = await meta.dataVersion();
final delta = await api.catalogChanges(since: since);
await db.transaction(() async {
await db.upsertAll(delta.changed);
await db.deleteAll(delta.removed);
await meta.setDataVersion(delta.version); // 必须在同一个事务里
});
事务边界就是这里全部的正确性论证:把行的写入和水位线的推进做成原子的,否则一次发生在两者之间的崩溃会让你永久跳过一段区间。把增量表达成写入和删除标记,这样重复应用一段有重叠的区间是无害的。
什么留在服务端
目录可以安全地放在本地,因为它是公开的、变化缓慢的、只读的。余额、持仓和订单状态一样都不是。你可以缓存它们用于显示,但它们是远端事实的一次渲染:回到前台时刷新,并且永远不让一个本地缓存的数字参与任何决策。
用户自己的数据(自选列表、备注)属于第三类:小、归用户所有、且在设备上写入。给它单独的表和单独的双向同步,而不要把写入混进被复制的目录里。这样大的那份复制就能保持单向,也就没有冲突。
参考
API 与渲染器相关断言按 Flutter 3.47.0 / Dart 3.13(2026 年 8 月)核对。
drift及其 Isolates 指南——NativeDatabase.createInBackground、DriftIsolate、computeWithDatabase;裸 SQL 的替代方案是sqflite。- SQLite,FTS5 Extension;The LIKE optimization——为什么
LIKE '%…%'用不上索引;PRAGMA user_version——schema 版本的天然归宿。 - M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan,“Local-first software: You own your data, in spite of the cloud”,Ink & Switch / Onward! 2019——术语的出处及其背后的七条理想。
上一篇:Flutter 图表 · 下一篇:工程结构与 add-to-app