BLOG · #Engineering

把一个数据库随 Flutter 应用一起发布:离线目录与即时搜索

如何把一份庞大的只读目录放到设备上并即时搜索:把预构建的 SQLite 文件作为资源打包、把数据版本和结构版本分开、用 FTS 做多语言搜索、以及把重查询挪出 UI isolate。

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 '%...%',后者用不上索引,会随数据量线性退化。

有意思的是多语言匹配。用户会用中文名、英文名、代码、拼音,或者拼音首字母去搜同一只标的,而且期待这五种都能用。这不是分词器问题,而是一个数据问题。在构建期就把这些变体生成好,作为可搜索的列存起来:

列示例
symbol00700
name_zh腾讯控股
name_enTencent Holdings
pinyintengxunkonggu
initialstxkg

这样一次带前缀匹配的 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 月)核对。


上一篇:Flutter 图表 · 下一篇:工程结构与 add-to-app