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 发布版本为准;drift 和 drift_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);
}
关键点如下:
@DriftDatabase(tables: [Todos])告诉 Drift 参与生成的表;schemaVersion是数据库结构版本,不是应用版本;NativeDatabase.createInBackground会在原生平台使用 SQLite 执行器,并将数据库工作放到后台执行环境,避免初始化和查询直接阻塞 Flutter UI isolate;- 数据库文件必须放在应用可写目录中;
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 事务解决什么问题
考虑“创建分类并创建待办”:
- 插入分类;
- 获取分类 ID;
- 插入引用该 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 能力有限。例如直接修改已有列的约束,常见做法是:
- 创建新表;
- 把旧表数据转换后复制进去;
- 删除旧表;
- 将新表重命名;
- 重新创建索引、外键和触发器。
抽象 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 迁移不是删除数据库重建
开发阶段可以删除数据库文件快速验证新表结构,但生产环境不能把删除数据库当迁移策略。删除会直接丢失用户数据,也可能丢失离线期间尚未同步的数据。
如果迁移失败,应用应:
- 保留原始数据库文件;
- 记录数据库版本和迁移步骤;
- 让迁移在事务语义下失败并停止继续使用;
- 根据错误决定修复、回滚版本或恢复备份。
迁移完成后要验证结果,不要只验证“应用没有崩溃”。例如应读取新列、检查行数、检查关键索引或执行代表性查询。
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 层使用 WHERE、LIMIT 和合适的索引,而不是监听整张大表后在 Dart 中过滤。
7. 多表 Schema、外键和一致性
7.1 外键不等于普通整数列
下面的 categoryId 只是整数列:
IntColumn get categoryId => integer().nullable()();
它并没有自动保证对应分类存在。真正的外键需要在表定义中声明,具体 Drift 写法应根据所使用版本的迁移 API 配置外键和删除行为。
逻辑上,外键约束保证:
todos.category_id 为非 NULL
⇒ categories.id 中存在相同值
如果还需要级联删除,应明确选择 CASCADE、RESTRICT 或 SET 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 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 离线缓存:Cache-Aside、同步、冲突、过期和用户隔离
- 下一篇:Flutter 安全存储:Keychain、Keystore、密钥、备份和设备迁移
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论