Flutter 基础体系 · 第 59/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter SQLite 与 Drift:Schema、查询、事务、迁移和响应式数据

SQLite 是一个嵌入式关系型数据库:数据保存在应用本地文件中,使用 SQL 读写,不需要独立的数据库服务器。Flutter 可以直接调用 SQLite,也可以使用 Drift 这类类型安全的数据库工具。

Drift 的价值不在于“替代 SQLite”,而在于把 SQLite 的表结构、SQL 查询、Dart 类型、代码生成和响应式监听连接起来:

Dart 表定义
    │
    ├── 代码生成:表对象、数据类、查询 API、Schema 信息
    │
    ▼
Drift 查询层
    │
    ├── 类型检查、参数绑定、Stream 监听、事务管理
    │
    ▼
SQLite 执行器
    │
    └── SQLite 文件或 Web 上的 SQLite WASM

因此,理解 Drift 的前提不是只记住几个 API,而是理解以下关系:

  • Schema 决定数据库中有哪些表、列、类型、约束和索引;
  • 查询 把 SQL 的关系运算映射成 Drift 的 Dart API;
  • 事务 决定多条操作是整体成功还是整体回滚;
  • 迁移 决定旧版本数据库如何变成新版本;
  • 响应式数据 决定数据库变化如何传播到 Flutter UI。

1. SQLite、Schema 和 Drift 的职责边界

1.1 SQLite 是本地关系型数据库

SQLite 的核心数据模型是关系:

  • 数据存储在表中;
  • 表由列组成;
  • 每一行代表一条记录;
  • 主键用于唯一标识记录;
  • 外键用于表达表之间的引用关系;
  • SQL 用于筛选、连接、排序、聚合和修改数据。

例如,一个待办事项表可以抽象为:

id title completed created_at
1 学习 SQLite 0 2025-01-01
2 编写迁移 1 2025-01-02

SQLite 的事务具有原子性:事务中的操作要么全部提交,要么全部回滚。它通常允许多个读取者并发读取,但写入仍然受到 SQLite 的锁和事务机制约束。

SQLite 不是客户端—服务器数据库。应用进程直接打开数据库文件,因此以下问题由应用自己负责:

  • 数据库文件放在哪里;
  • 数据库文件是否需要备份;
  • 多个 isolate 是否共享同一个连接;
  • Schema 如何升级;
  • 写入失败后如何恢复。

1.2 Schema 是数据库结构的正式描述

Schema 是数据库结构的定义,包括:

  • 表名;
  • 列名及其类型;
  • 是否允许 NULL
  • 默认值;
  • 主键;
  • 唯一约束;
  • 外键;
  • 索引;
  • 触发器和视图等数据库对象。

例如,下面的 SQL 描述了一个 Schema:

CREATE TABLE todos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  completed INTEGER NOT NULL DEFAULT 0,
  created_at INTEGER NOT NULL
);

CREATE INDEX todos_created_at_idx
ON todos(created_at);

这里需要区分几个概念:

  • INTEGER PRIMARY KEY 在 SQLite 中具有特殊语义:它通常对应 rowid;
  • AUTOINCREMENT 并不是所有场景都需要,它会改变 SQLite 分配旧 ID 的行为,并增加额外开销;
  • NOT NULL 禁止列值为 NULL
  • DEFAULT 0 只在插入时没有提供该列值时生效;
  • 索引加快查找,但会增加写入和存储成本。

Schema 不是 UI 模型,也不是业务对象的全部定义。比如“标题不能为空字符串”不是 NOT NULL 能保证的;NOT NULL 只保证它不是 SQL 的 NULL,并不阻止 ''

1.3 Drift 是 SQLite 之上的类型安全层

Drift 通常通过 Dart 表定义生成数据库代码:

import 'package:drift/drift.dart';

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get title => text()();

  BoolColumn get completed =>
      boolean().withDefault(const Constant(false))();

  DateTimeColumn get createdAt => dateTime()();
}

这段代码对应的概念大致是:

CREATE TABLE todos (
  id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  completed INTEGER NOT NULL DEFAULT 0,
  created_at INTEGER NOT NULL
);

映射关系不是简单的字符串替换:

Drift 类型 SQLite 中的常见表示 Dart 读取类型
IntColumn INTEGER int
TextColumn TEXT String
BoolColumn 通常编码为 INTEGER bool
DateTimeColumn 由 Drift 编码为整数或文本 DateTime
BlobColumn BLOB Uint8List
RealColumn REAL double

SQLite 的类型系统采用动态类型和类型亲和性,不像 PostgreSQL 那样严格区分所有列值类型。Drift 在 Dart 层提供了更严格的 API,但最终仍然受到 SQLite 语义约束。


2. 建立一个可运行的 Drift 数据库

2.1 添加依赖和生成代码

一个原生平台项目通常需要:

dependencies:
  drift: ^x.y.z
  path: ^x.y.z
  path_provider: ^x.y.z

dev_dependencies:
  drift_dev: ^x.y.z
  build_runner: ^x.y.z

版本号应以当前 Drift 发布版本为准;driftdrift_dev 应保持兼容。

定义表后运行代码生成:

dart run build_runner build --delete-conflicting-outputs

开发期间也可以使用:

dart run build_runner watch --delete-conflicting-outputs

代码生成会产生数据库基类、表数据类、Companion 类型以及查询相关代码。生成文件不应该手工修改,因为下一次生成会覆盖它。

2.2 定义数据库类

import 'dart:io';

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

part 'app_database.g.dart';

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get title => text()();

  BoolColumn get completed =>
      boolean().withDefault(const Constant(false))();

  DateTimeColumn get createdAt => dateTime()();
}

@DriftDatabase(tables: [Todos])
class AppDatabase extends _$AppDatabase {
  AppDatabase(super.e);

  @override
  int get schemaVersion => 1;
}

Future<AppDatabase> openDatabase() async {
  final directory = await getApplicationDocumentsDirectory();
  final file = File(p.join(directory.path, 'app.sqlite'));

  final executor = NativeDatabase.createInBackground(file);
  return AppDatabase(executor);
}

关键点如下:

  1. @DriftDatabase(tables: [Todos]) 告诉 Drift 参与生成的表;
  2. schemaVersion 是数据库结构版本,不是应用版本;
  3. NativeDatabase.createInBackground 会在原生平台使用 SQLite 执行器,并将数据库工作放到后台执行环境,避免初始化和查询直接阻塞 Flutter UI isolate;
  4. 数据库文件必须放在应用可写目录中;
  5. AppDatabase 通常应作为应用级单例或由依赖注入容器管理,而不是每次页面构建都新建一个连接。

数据库关闭时应调用:

await database.close();

如果数据库对象与应用整个生命周期一致,可以在应用退出或测试清理阶段关闭。测试时尤其要关闭数据库,否则文件句柄或后台 isolate 可能导致测试进程无法正常结束。

2.3 Companion 为什么存在

Drift 将“数据库行对象”和“待写入值”区分开。

例如:

final todo = Todo(
  id: 1,
  title: '学习 SQLite',
  completed: false,
  createdAt: DateTime.now(),
);

Todo 表示一条已经从数据库读取出来的完整记录。

插入时通常使用 TodosCompanion

final id = await database.into(database.todos).insert(
  TodosCompanion.insert(
    title: '学习 SQLite',
    createdAt: DateTime.now(),
  ),
);

这里没有显式传入 completed,因此使用数据库定义的默认值 false

Companion 可以表达三种状态:

  • 未提供该列:插入时使用默认值,更新时不修改该列;
  • Value(value):明确写入一个值;
  • Value.absent():明确表示不提供该值。

这种设计避免了普通 Dart null 无法区分“写入 NULL”和“完全不更新”的问题。


3. 查询:从关系运算到 Drift API

3.1 插入、读取、更新和删除

Future<int> addTodo(AppDatabase db, String title) {
  return db.into(db.todos).insert(
        TodosCompanion.insert(
          title: title,
          createdAt: DateTime.now(),
        ),
      );
}

返回值是插入行的 ID。若违反唯一约束、非空约束或其他 SQLite 约束,操作会抛出数据库异常,而不是返回一个普通失败值。

读取全部记录:

Future<List<Todo>> loadTodos(AppDatabase db) {
  return (db.select(db.todos)
        ..orderBy([
          (t) => OrderingTerm(
                expression: t.createdAt,
                mode: OrderingMode.desc,
              ),
        ]))
      .get();
}

筛选未完成事项:

Future<List<Todo>> loadOpenTodos(AppDatabase db) {
  return (db.select(db.todos)
        ..where((t) => t.completed.equals(false))
        ..orderBy([
          (t) => OrderingTerm(
                expression: t.createdAt,
                mode: OrderingMode.desc,
              ),
        ]))
      .get();
}

这大致对应:

SELECT *
FROM todos
WHERE completed = 0
ORDER BY created_at DESC;

这里的 where 不是在 Dart 内存中筛选列表,而是生成 SQL 条件,由 SQLite 执行。若先调用 get() 再用 Dart 的 where,数据库必须先把所有记录读入内存,通常会失去索引和数据库执行计划的优势。

更新:

Future<bool> markCompleted(
  AppDatabase db, {
  required int id,
  required bool completed,
}) {
  return (db.update(db.todos)..where((t) => t.id.equals(id))).write(
    TodosCompanion(
      completed: Value(completed),
    ),
  );
}

返回值表示受影响的行数。若 ID 不存在,返回 false,这与“更新成功”必须区分。

删除:

Future<int> deleteTodo(AppDatabase db, int id) {
  return (db.delete(db.todos)..where((t) => t.id.equals(id))).go();
}

删除操作必须带有正确的 where 条件。下面的代码会删除整张表中的所有记录:

await db.delete(db.todos).go();

这不是语法错误,而是合法且危险的 SQL 操作。

3.2 复合条件、分页和参数绑定

Future<List<Todo>> searchOpenTodos(
  AppDatabase db,
  String keyword,
) {
  return (db.select(db.todos)
        ..where(
          (t) =>
              t.completed.equals(false) &
              t.title.like('%$keyword%'),
        )
        ..limit(20, offset: 0))
      .get();
}

Drift 会使用参数绑定,而不是简单地把参数拼接进 SQL,因此可以避免常见的 SQL 注入问题。但 LIKE '%keyword%' 仍然可能无法使用普通的前缀索引,且关键词中的 %_ 具有 SQL 通配符语义。如果业务要求字面匹配,需要额外转义。

分页的稳定性依赖排序条件。只使用:

ORDER BY created_at DESC

当多行 created_at 相同时,不同页面之间可能出现顺序变化。更稳定的排序是:

ORDER BY created_at DESC, id DESC

在 Drift 中可以写成多个 OrderingTerm。其中 id 作为唯一的次级排序键,使分页顺序确定。

3.3 连接查询和自定义 SQL

假设增加分类表:

class Categories extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get name => text().unique()();
}

待办表可以引用分类:

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get title => text()();

  IntColumn get categoryId => integer().nullable()();

  BoolColumn get completed =>
      boolean().withDefault(const Constant(false))();

  DateTimeColumn get createdAt => dateTime()();
}

连接查询:

Future<List<(Todo, Category?)>> loadTodosWithCategory(AppDatabase db) {
  final query = db.select(db.todos).join([
    leftOuterJoin(
      db.categories,
      db.categories.id.equalsExp(db.todos.categoryId),
    ),
  ]);

  return query.map((row) {
    final todo = row.readTable(db.todos);
    final category = row.readTableOrNull(db.categories);
    return (todo, category);
  }).get();
}

leftOuterJoin 的含义是:即使待办没有分类,也保留待办记录;分类部分为 null。如果改成内连接,没有分类的待办会被过滤掉。

对于复杂聚合、窗口函数或 Drift API 不容易表达的 SQL,可以使用自定义查询:

Future<int> countOpenTodos(AppDatabase db) async {
  final row = await db
      .customSelect(
        'SELECT COUNT(*) AS count '
        'FROM todos WHERE completed = ?',
        variables: [const Variable.withBool(false)],
      )
      .getSingle();

  return row.read<int>('count');
}

参数仍然应该通过 Variable 传递,不应直接拼接用户输入。


4. 事务:把多条操作变成一个原子状态变化

4.1 事务解决什么问题

考虑“创建分类并创建待办”:

  1. 插入分类;
  2. 获取分类 ID;
  3. 插入引用该 ID 的待办。

如果第 3 步失败,而第 1 步已经提交,就会留下只有分类没有待办的中间状态。若这两步在业务上必须一起成功,就需要事务。

事务的原子性可以形式化为:

初始状态 S0
执行操作 O1、O2、...、On
成功:提交后的状态 S1 = On(... O2(O1(S0)) ...)
失败:恢复为 S0

不存在“只提交前 n 个操作”的可见中间结果。

4.2 Drift 中的事务

Future<int> createCategoryAndTodo(
  AppDatabase db, {
  required String categoryName,
  required String title,
}) {
  return db.transaction(() async {
    final categoryId = await db.into(db.categories).insert(
          CategoriesCompanion.insert(name: categoryName),
        );

    return db.into(db.todos).insert(
          TodosCompanion.insert(
            title: title,
            categoryId: Value(categoryId),
            createdAt: DateTime.now(),
          ),
        );
  });
}

在事务回调中:

  • 所有数据库操作都必须 await
  • 不要把 Future 创建出来后不等待;
  • 不要在事务内部启动无法等待完成的后台任务;
  • 不要在事务中执行网络请求或长时间阻塞操作。

错误示例:

db.transaction(() async {
  unawaited(db.into(db.todos).insert(...));
});

事务回调可能已经结束并提交,但插入操作仍在排队或尚未完成。这样会破坏代码对事务边界的理解。

4.3 失败、回滚和异常处理

Future<void> importTodos(
  AppDatabase db,
  List<String> titles,
) async {
  try {
    await db.transaction(() async {
      for (final title in titles) {
        await db.into(db.todos).insert(
              TodosCompanion.insert(
                title: title,
                createdAt: DateTime.now(),
              ),
            );
      }
    });
  } on SqliteException catch (error) {
    // 记录 error.extendedResultCode 等诊断信息,
    // 再向上层转换为可理解的业务错误。
    rethrow;
  }
}

如果循环中的某次插入违反约束,事务会失败并回滚此前已经完成的插入。调用者不能只根据“异常发生在最后一步”推断前面的行已经保留。

需要区分:

  • 约束错误:例如唯一键冲突、非空约束失败;
  • 锁或并发错误:另一个连接正在写入;
  • 磁盘错误:空间不足、文件不可写;
  • 程序错误:事务内部的 Dart 异常。

这些错误的恢复策略不同,不能统一吞掉。

4.4 事务与并发

SQLite 通常允许多个读取,但同一时刻的写入能力受锁机制限制。常见表现包括:

  • 两个写事务竞争时出现 busy 或 locked;
  • 大事务占用写锁时间过长,导致其他写入等待或失败;
  • 在主 isolate 直接执行大批量操作,造成 UI 卡顿;
  • 多个数据库实例分别打开同一个文件,增加并发和生命周期复杂度。

事务不是“自动解决并发”的工具。它只保证事务边界内的原子性和一致性。实际并发行为还取决于:

  • SQLite 的 journal 模式;
  • 是否启用 WAL;
  • busy timeout;
  • 连接数量;
  • 事务持续时间;
  • 平台文件系统。

不要在业务代码中假设所有平台都具有完全相同的锁等待行为。


5. 迁移:让旧数据库安全升级

5.1 Schema 版本与应用版本不同

schemaVersion 描述数据库结构版本:

数据库 v1:todos(id, title, completed, created_at)
数据库 v2:增加 priority
数据库 v3:增加 categories,并让 todos.category_id 引用 categories

它不等同于应用版本。应用可能从 1.0 直接升级到 3.0,但数据库仍然必须依次执行:

v1 → v2 → v3

升级逻辑必须兼容用户实际安装过的旧版本,而不是只兼容“上一个发布版本”。

5.2 增加列的完整示例

先把数据库版本提高到 2:

@DriftDatabase(tables: [Todos, Categories])
class AppDatabase extends _$AppDatabase {
  AppDatabase(super.e);

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
        onCreate: (Migrator m) async {
          await m.createAll();
        },
        onUpgrade: (Migrator m, int from, int to) async {
          if (from < 2) {
            await m.addColumn(todos, todos.priority);
          }
        },
      );
}

同时修改表:

class Todos extends Table {
  IntColumn get id => integer().autoIncrement()();

  TextColumn get title => text()();

  IntColumn get priority =>
      integer().withDefault(const Constant(0))();

  BoolColumn get completed =>
      boolean().withDefault(const Constant(false))();

  DateTimeColumn get createdAt => dateTime()();
}

为什么新增列通常需要默认值?

旧表中的每一行都必须得到新列的值。如果新增列是 NOT NULL 且没有默认值,SQLite 无法为已有行构造合法记录。可以选择:

  • 允许 NULL
  • 提供默认值;
  • 先添加可空列,回填数据,再通过更复杂的重建表流程收紧约束。

5.3 多个版本必须按顺序迁移

@override
int get schemaVersion => 3;

@override
MigrationStrategy get migration => MigrationStrategy(
      onCreate: (Migrator m) async {
        await m.createAll();
      },
      onUpgrade: (Migrator m, int from, int to) async {
        if (from < 2) {
          await m.addColumn(todos, todos.priority);
        }

        if (from < 3) {
          await m.createTable(categories);
        }
      },
    );

当用户数据库版本是 1 时,升级路径是:

from = 1
执行 < 2 的迁移:增加 priority
执行 < 3 的迁移:创建 categories
最终版本 = 3

当用户数据库版本是 2 时,只执行创建 categories 的迁移。

迁移代码的条件应根据旧版本 from 判断,而不是只写:

if (from == 1) {
  ...
}

因为从 v1 升级到 v3 时,需要执行 v2 和 v3 的全部步骤。

5.4 修改列通常比增加列复杂

SQLite 对部分 ALTER TABLE 能力有限。例如直接修改已有列的约束,常见做法是:

  1. 创建新表;
  2. 把旧表数据转换后复制进去;
  3. 删除旧表;
  4. 将新表重命名;
  5. 重新创建索引、外键和触发器。

抽象 SQL 如下:

CREATE TABLE todos_new (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  priority INTEGER NOT NULL DEFAULT 0,
  created_at INTEGER NOT NULL
);

INSERT INTO todos_new(id, title, priority, created_at)
SELECT id, title, 0, created_at
FROM todos;

DROP TABLE todos;
ALTER TABLE todos_new RENAME TO todos;

风险在于:

  • 忘记复制某个列;
  • 忘记重建索引;
  • 忘记重建外键;
  • 迁移中途失败后留下不完整状态;
  • 新旧列的数据转换规则不正确。

因此每次 Schema 变更都应测试至少这些路径:

空数据库首次创建
v1 → v2
v1 → v3
v2 → v3
已存在真实数据的升级
迁移中途异常后的再次启动

5.5 迁移不是删除数据库重建

开发阶段可以删除数据库文件快速验证新表结构,但生产环境不能把删除数据库当迁移策略。删除会直接丢失用户数据,也可能丢失离线期间尚未同步的数据。

如果迁移失败,应用应:

  1. 保留原始数据库文件;
  2. 记录数据库版本和迁移步骤;
  3. 让迁移在事务语义下失败并停止继续使用;
  4. 根据错误决定修复、回滚版本或恢复备份。

迁移完成后要验证结果,不要只验证“应用没有崩溃”。例如应读取新列、检查行数、检查关键索引或执行代表性查询。


6. 响应式数据:数据库变化如何到达 Flutter UI

6.1 get()watch() 的区别

get() 执行一次查询:

final todos = await (db.select(db.todos)
      ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
    .get();

它返回一个 Future。之后数据库再发生变化,todos 不会自动更新。

watch() 返回 Stream:

final stream = (db.select(db.todos)
      ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
    .watch();

当 Drift 判断相关表发生变化时,Stream 会重新执行查询并发出新的结果。

Flutter 中可以直接使用 StreamBuilder

class TodoList extends StatelessWidget {
  const TodoList({
    super.key,
    required this.database,
  });

  final AppDatabase database;

  @override
  Widget build(BuildContext context) {
    final query = database.select(database.todos)
      ..orderBy([
        (t) => OrderingTerm(
              expression: t.createdAt,
              mode: OrderingMode.desc,
            ),
      ]);

    return StreamBuilder<List<Todo>>(
      stream: query.watch(),
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Text('读取失败:${snapshot.error}');
        }

        if (!snapshot.hasData) {
          return const CircularProgressIndicator();
        }

        final todos = snapshot.data!;

        if (todos.isEmpty) {
          return const Text('暂无待办');
        }

        return ListView.builder(
          itemCount: todos.length,
          itemBuilder: (context, index) {
            final todo = todos[index];

            return CheckboxListTile(
              value: todo.completed,
              title: Text(todo.title),
              onChanged: (value) async {
                await (database.update(database.todos)
                      ..where((t) => t.id.equals(todo.id)))
                    .write(
                  TodosCompanion(
                    completed: Value(value ?? false),
                  ),
                );
              },
            );
          },
        );
      },
    );
  }
}

数据流可以表示为:

sequenceDiagram
    participant UI as Flutter UI
    participant S as Drift Stream
    participant DB as SQLite

    UI->>S: 订阅 query.watch()
    S->>DB: 执行 SELECT
    DB-->>S: 返回初始结果
    S-->>UI: 发出 List<Todo>

    UI->>DB: UPDATE todos
    DB-->>S: 相关表发生变化
    S->>DB: 重新执行 SELECT
    DB-->>S: 返回新结果
    S-->>UI: 发出新的 List<Todo>

响应式查询不是 SQLite 自己推送数据。SQLite 只执行读写;Drift 负责监听表变化,并在需要时重新执行查询。

6.2 事务与响应式通知

如果一次业务操作包含多条写入:

await db.transaction(() async {
  await updateTodo();
  await insertHistory();
});

UI 不应依赖事务中间状态。事务提交前,其他查询不应该观察到一个“只更新了第一步”的业务状态。提交后,相关响应式查询会根据数据库变化重新计算。

这也是事务和响应式数据组合的重要原因:

多条写入
    ↓
事务内保持中间状态不可见
    ↓
提交
    ↓
Stream 得到一致的新快照
    ↓
Flutter 重建 UI

如果把同一业务操作拆成多个独立事务,UI 可能在它们之间看到临时状态,甚至触发多次重建。

6.3 响应式查询的边界

watch() 并不是通用的内存状态管理器。它只负责数据库查询结果:

  • 数据库发生相关变化时会重新查询;
  • 外部网络响应不会自动触发它;
  • 业务层内存变量变化不会自动触发它;
  • 如果查询涉及的表或表达式没有被正确识别,不能假设所有外部因素都会触发更新;
  • 大结果集会在每次重新查询后产生新的列表,仍需控制查询范围。

对于列表页面,应该在 SQL 层使用 WHERELIMIT 和合适的索引,而不是监听整张大表后在 Dart 中过滤。


7. 多表 Schema、外键和一致性

7.1 外键不等于普通整数列

下面的 categoryId 只是整数列:

IntColumn get categoryId => integer().nullable()();

它并没有自动保证对应分类存在。真正的外键需要在表定义中声明,具体 Drift 写法应根据所使用版本的迁移 API 配置外键和删除行为。

逻辑上,外键约束保证:

todos.category_id 为非 NULL
⇒ categories.id 中存在相同值

如果还需要级联删除,应明确选择 CASCADERESTRICTSET NULL。这些行为会改变删除一个分类时待办记录的结果,不能依赖默认猜测。

另外,SQLite 的外键约束通常需要连接级别启用。移动端 Drift 的具体默认行为取决于执行器和版本;如果业务依赖外键,应该在测试中验证“插入不存在的外键是否确实失败”,而不是只检查 Dart 表定义。

7.2 索引要匹配查询

如果经常查询:

WHERE completed = 0
ORDER BY created_at DESC

单独为 completed 建索引不一定足够。索引设计应考虑过滤和排序的组合,例如复合索引:

CREATE INDEX todos_open_created_idx
ON todos(completed, created_at DESC);

但索引是否被使用取决于数据分布、查询条件和 SQLite 查询计划。可以使用 SQLite 的 EXPLAIN QUERY PLAN 诊断:

EXPLAIN QUERY PLAN
SELECT *
FROM todos
WHERE completed = 0
ORDER BY created_at DESC;

索引不是越多越好。每次插入、更新和删除都可能维护相关索引,因此应围绕真实查询建立,并用实际数据验证。


8. 平台差异:Android、iOS、桌面和 Web

8.1 Android 和 iOS

Android、iOS 通常通过原生 SQLite 执行器访问应用私有目录中的数据库文件:

  • 文件不会因为 Flutter Widget 重建而改变位置;
  • 升级应用通常保留数据库文件;
  • 卸载应用通常会删除应用私有数据;
  • iOS 和 Android 的 SQLite 版本、编译选项以及文件系统行为可能存在差异;
  • 不应依赖某个特定平台才有的 SQLite 扩展。

数据库文件路径应通过平台适配的路径 API 获取,而不是写死 /data/.../var/...

8.2 Windows、macOS 和 Linux

桌面平台也可以使用原生 SQLite 执行器,但需要考虑:

  • 用户可以复制、替换或删除数据库文件;
  • 应用可能同时启动多个进程;
  • 文件权限和锁行为与移动端不同;
  • 数据库备份路径和应用数据目录策略不同;
  • 发布包必须包含 Drift 所需的原生数据库组件或正确使用对应执行器。

桌面应用如果允许用户导入数据库文件,需要先验证文件是否来自兼容 Schema 版本,不能直接覆盖当前数据库。

8.3 Web

Web 平台不能直接使用移动端的文件路径和原生 SQLite 动态库。Drift 在 Web 上通常通过 SQLite WASM 执行,并需要配置:

  • SQLite WASM 文件;
  • Drift Web 端相关资源;
  • 浏览器 Worker 或执行环境;
  • 数据持久化后端,例如浏览器支持的存储机制;
  • 正确的资源路径和构建配置。

因此,下面这种代码不能直接用于 Web:

final file = File('/some/path/app.sqlite');
final executor = NativeDatabase(file);

dart:io、本地文件路径和原生 SQLite 执行器属于非 Web 路径。Web 端应使用与当前 Drift 版本匹配的 Web 数据库初始化方式,并按照该版本的资源部署要求配置 sqlite3.wasm 等文件。

Web 还存在额外边界:

  • 浏览器存储可能被用户清理;
  • 隐私模式和不同浏览器的持久化行为可能不同;
  • WASM、Worker 和跨源资源策略会影响初始化;
  • 多标签页同时写入需要额外验证;
  • Web 数据库不应被当成服务器端共享数据库。

如果应用需要 Android、iOS、桌面和 Web 共用同一套数据访问层,可以共享表定义、DAO 和大部分查询代码,但应通过条件导入或依赖注入替换数据库执行器。


9. 常见失败表现与诊断方法

9.1 生成文件缺失

现象:

Target of URI doesn't exist: 'app_database.g.dart'

原因通常是:

  • 没有运行 build_runner
  • part 文件名与生成配置不一致;
  • 表定义有 Dart 编译错误;
  • 生成文件被错误地加入忽略规则;
  • 生成过程遇到冲突。

诊断顺序:

dart analyze
dart run build_runner build --delete-conflicting-outputs

先修复 Dart 分析错误,再看生成器错误。不要手写生成文件来绕过问题。

9.2 查询没有更新 UI

检查以下因果链:

UI 是否真的订阅了 watch()
    ↓
写操作是否提交成功
    ↓
写操作是否写入了 UI 查询使用的表
    ↓
查询条件是否仍然过滤掉了新数据
    ↓
StreamBuilder 是否处理了 error 和 loading

例如,使用 get() 后把结果放入 FutureBuilder,数据库变化不会自动重新触发查询。这不是 Drift 失效,而是调用方选择了一次性查询 API。

9.3 迁移后出现 “no such column”

典型原因是:

  • 修改了 Dart 表定义,但没有增加 schemaVersion
  • 增加了 schemaVersion,但没有实现对应 onUpgrade
  • 只测试了新安装,没有测试已有数据库;
  • 迁移条件错误,导致某个旧版本跳过了步骤。

验证时应用一个真实的旧数据库文件进行启动,而不是每次都删除数据库重新创建。

9.4 唯一约束冲突

如果列定义了:

TextColumn get name => text().unique()();

重复插入会失败。业务上如果“存在则更新”,可以选择明确的冲突策略,而不是捕获所有异常后忽略:

await db.into(db.categories).insertOnConflictUpdate(
  CategoriesCompanion.insert(name: '工作'),
);

是否适合使用冲突更新,取决于唯一键和更新语义。若冲突时不应覆盖原记录,就应捕获约束异常并向用户报告。

9.5 数据库被锁定

遇到 locked 或 busy 类错误时,应检查:

  • 是否有未结束的事务;
  • 是否在事务中执行了网络请求;
  • 是否打开了多个数据库实例;
  • 是否在不同 isolate 中不受控地写入;
  • 是否一次性导入了过大的批次;
  • 是否在桌面平台有另一个进程打开数据库。

不要简单地无限重试。重试应有上限,并区分“短暂竞争”与“永久文件权限错误”。


10. 测试 Schema、查询和迁移

数据库测试至少应覆盖三层。

10.1 查询行为测试

使用内存数据库执行查询:

import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  late AppDatabase db;

  setUp(() {
    db = AppDatabase(NativeDatabase.memory());
  });

  tearDown(() async {
    await db.close();
  });

  test('未完成事项按创建时间倒序返回', () async {
    await db.into(db.todos).insert(
          TodosCompanion.insert(
            title: '旧任务',
            createdAt: DateTime(2024, 1, 1),
          ),
        );

    await db.into(db.todos).insert(
          TodosCompanion.insert(
            title: '新任务',
            createdAt: DateTime(2024, 1, 2),
          ),
        );

    final rows = await (db.select(db.todos)
          ..where((t) => t.completed.equals(false))
          ..orderBy([
            (t) => OrderingTerm.desc(t.createdAt),
          ]))
        .get();

    expect(rows.map((e) => e.title).toList(), ['新任务', '旧任务']);
  });
}

这个测试验证的是实际排序和默认值,而不是只验证 Dart 表定义能编译。

10.2 事务回滚测试

test('事务失败时此前插入的记录也会回滚', () async {
  try {
    await db.transaction(() async {
      await db.into(db.categories).insert(
            CategoriesCompanion.insert(name: '工作'),
          );

      // name 唯一,第二次插入应失败。
      await db.into(db.categories).insert(
            CategoriesCompanion.insert(name: '工作'),
          );
    });
  } catch (_) {
    // 预期失败。
  }

  final rows = await db.select(db.categories).get();
  expect(rows, isEmpty);
});

该测试验证了“异常导致整个事务回滚”,而不是仅验证第二次插入失败。

10.3 迁移测试

迁移测试需要保留旧版本数据库,执行升级后检查:

  • 新表是否存在;
  • 新列是否存在;
  • 旧数据是否保留;
  • 默认值是否正确;
  • 索引和约束是否有效;
  • 从不同旧版本升级是否得到相同最终结构。

可以把迁移测试视为函数测试:

migrate(v1 database) = valid v3 database
migrate(v2 database) = valid v3 database

两个输入版本不同,但最终应满足同一组 v3 Schema 不变量。


11. 实际取舍

Drift 适合以下场景:

  • 本地结构化数据;
  • 离线优先应用;
  • 需要复杂筛选、连接和事务;
  • 希望查询结果直接以 Stream 驱动 UI;
  • 希望在编译期发现列名和类型错误。

它不能自动解决:

  • 多设备数据同步;
  • 冲突合并;
  • 云端权限;
  • 数据加密;
  • 数据备份;
  • 服务器端并发;
  • Web 多标签页协作。

如果数据库包含敏感信息,SQLite 文件本身通常不是加密数据库。需要加密时,应选择经过验证的加密 SQLite 方案,并确认它与目标平台、Drift 执行器、迁移和备份策略兼容,不能仅因为文件扩展名改成 .db.enc 就认为数据已加密。

一个可靠的数据层应满足以下可验证条件:

Schema 能从空数据库创建
旧版本能逐级迁移到当前版本
查询在数据库层完成筛选和排序
相关写入通过事务保持原子性
UI 需要实时数据时使用 watch()
每个数据库实例都有明确生命周期
原生平台与 Web 使用适配各自平台的执行器
失败路径保留错误信息并可诊断

当这些条件成立时,SQLite 负责持久化和关系运算,Drift 负责类型安全、代码生成、事务抽象和响应式通知,Flutter 则负责将查询结果转换为界面状态。三者之间的边界清晰,Schema、数据访问和 UI 生命周期也就不容易互相污染。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。