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

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

- Canonical (HTML): https://kaguc.com/blog/flutter-offline-store-zh/
- Date: 2026-08-28


*English version: [Shipping a database with your Flutter app](/blog/flutter-offline-store/)。这是[交易类 App 架构](/blog/trading-app-architecture-zh/)的 Flutter 实现篇，原理见[本地优先](/blog/local-first-sync-zh/)。*

[本地优先那篇](/blog/local-first-sync-zh/)给出的设计（发一份快照，按版本同步增量）在 Flutter 里有一套具体而略微琐碎的实现。这里把它讲清楚。

## 选择存储

`sqflite` 给你的是 SQLite 加原生 SQL。`drift` 建在它之上（或使用自带引擎），补上了类型化查询、迁移和一等公民级的 isolate 支持。对一个结构会持续演进的目录来说，光是 drift 的迁移能力就值回票价；而如果你只有几个键值设置项，那么用 `shared_preferences` 而不用别的才是对的。

值得避开的错误是：因为文档型存储看起来更简单就选了它，然后发现自己需要在几十万行上做带排序的前缀搜索。那是一个关系型问题，有成熟答案，而且 SQLite 本来就在设备上。

## 打包快照，只拷贝一次

把预构建的数据库作为资源发布，首次启动时拷贝到应用的文档目录。必须拷贝，因为资源是只读的，而 SQLite 需要一个可写文件。

```dart
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`，是它天然的归属。

把两者混在一起会产生两种失败之一：一次应用更新丢掉了已同步的目录、重新装回快照；或者某次迁移永远不会执行，因为那个版本号正忙着追踪数据。

```dart
@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`）：

```dart
final db = AppDatabase(NativeDatabase.createInBackground(file));
```

然后对输入做防抖（每个按键发一次查询是白干），取消已被取代的查询，并用 `LIMIT` 把结果集控制住。没有人会翻过前面几十条匹配，为了显示二十条而取回一千行是纯粹的成本。

## 只同步增量，不同步整个世界

快照就位之后，客户端只问变了什么：

```dart
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`](https://pub.dev/packages/drift) 及其 [Isolates](https://drift.simonbinder.eu/isolates/) 指南——`NativeDatabase.createInBackground`、`DriftIsolate`、`computeWithDatabase`；裸 SQL 的替代方案是 [`sqflite`](https://pub.dev/packages/sqflite)。
- SQLite，[FTS5 Extension](https://www.sqlite.org/fts5.html)；[The LIKE optimization](https://www.sqlite.org/optoverview.html)——为什么 `LIKE '%…%'` 用不上索引；[`PRAGMA user_version`](https://www.sqlite.org/pragma.html)——schema 版本的天然归宿。
- M. Kleppmann, A. Wiggins, P. van Hardenberg, M. McGranaghan，["Local-first software: You own your data, in spite of the cloud"](https://www.inkandswitch.com/local-first/)，Ink & Switch / Onward! 2019——术语的出处及其背后的七条理想。

---

*上一篇：[Flutter 图表](/blog/flutter-chart-rendering-zh/) · 下一篇：[工程结构与 add-to-app](/blog/flutter-project-structure-zh/)*

