持久化存储架构:SQL
大多数 Flutter 应用程序,无论规模大小,往往需要在用户设备上存储数据。例如:API 密钥、用户偏好内容,以及需要支持离线访问的数据。
在本教程中,你将学习如何遵循 Flutter 架构设计模式,并在 Flutter 应用中实现基于 SQL 的复杂数据持久化存储。
如果你需要了解如何存储更简单的键值 (Key-Value) 数据,请参阅实用教程中的示例: 持久化存储架构:键值 (Key-Value) 数据
在阅读本教程之前,你应该先掌握 SQL 和 SQLite 的基础知识。如果你需要帮助,可以先阅读 用 SQLite 实现数据持久化 教程。
本示例采用 sqflite 并配合 sqflite_common_ffi
插件,能够同时支持移动端和桌面端。如果需要支持 Web 端,则需要使用实验性插件
sqflite_common_ffi_web,但本示例并未使用它。
示例应用:待办事项应用
#该示例应用为单页面结构,主要包含:顶部的 AppBar、中间的代办事项列表以及底部的文本输入框。
应用主体由 TodoListScreen 构成。该界面包含一个由 ListTile widget 组成的 ListView 列表,其中每一项代表一个待办事项。在底部,TextField 允许用户通过输入任务描述,然后点击带有 “Add” 字样的 FilledButton 来创建新的待办事项。
用户可以点击带有垃圾桶图标的“删除” IconButton 来删除待办事项。
待办事项列表使用数据库服务来实现本地存储,并在用户启动应用程序时重新加载到应用中。
采用 SQL 存储复杂数据
#此功能遵循推荐的 Flutter 架构设计,包含 UI 层 (UI Layer) 和数据层 (Data Layer)。此外,数据模型则定义在领域层 (Domain Layer) 中。
-
UI 层 (UI Layer) 由
TodoListScreen和TodoListViewModel构成。 -
领域层 (Domain Layer) 由定义业务数据模型的
Todo数据类构成。 -
数据层 (Data Layer) 由
TodoRepository和DatabaseService构成。
待办事项 UI 层
#
TodoListScreen 是一个用于显示和创建待办事项的 UI Widget。它遵循 MVVM 模式,由 TodoListViewModel 负责维护待办事项列表,并封装了加载、添加和删除待办事项列表这三项操作。
此界面由两部分组成:其一采用 ListView 实现的待办事项列表,其二用于创建新待办事项的 TextField 和 Button。
ListView 外层包裹着 ListenableBuilder,它通过监听 TodoListViewModel 的数据变化,从而为每个待办事项显示对应的 ListTile。
ListenableBuilder(
listenable: widget.viewModel,
builder: (context, child) {
return ListView.builder(
itemCount: widget.viewModel.todos.length,
itemBuilder: (context, index) {
final todo = widget.viewModel.todos[index];
return ListTile(
title: Text(todo.task),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => widget.viewModel.delete.execute(todo.id),
),
);
},
);
},
)
待办事项列表定义于 TodoListViewModel 中,并由 load 命令负责加载。该方法会调用 TodoRepository 来获取待办事项列表。
List<Todo> _todos = [];
List<Todo> get todos => _todos;
Future<Result<void>> _load() async {
try {
final result = await _todoRepository.fetchTodos();
switch (result) {
case Ok<List<Todo>>():
_todos = result.value;
return Result.ok(null);
case Error():
return Result.error(result.error);
}
} on Exception catch (e) {
return Result.error(e);
} finally {
notifyListeners();
}
}
当点击 FilledButton 时,会执行 add 命令,此时文本控制器的当前值将作为参数被传入。
FilledButton.icon(
onPressed: () =>
widget.viewModel.add.execute(_controller.text),
label: const Text('Add'),
icon: const Icon(Icons.add),
)
add 命令随即调用 TodoRepository.createTodo() 方法,并传入用户输入的任务描述文本,从而创建一个新的待办事项。
createTodo() 方法返回新创建的待办事项,然后将其添加到视图模型的 _todo 列表中。
待办事项包含由数据库生成的唯一 ID。正因如此,创建待办事项的职责由 TodoRepository 承担,而非视图模型。
Future<Result<void>> _add(String task) async {
try {
final result = await _todoRepository.createTodo(task);
switch (result) {
case Ok<Todo>():
_todos.add(result.value);
return Result.ok(null);
case Error():
return Result.error(result.error);
}
} on Exception catch (e) {
return Result.error(e);
} finally {
notifyListeners();
}
}
最后,TodoListScreen 还会监听 add 命令的执行结果,并在操作完成后清空 TextEditingController 中的内容。
void _onAdd() {
// Clear the text field when the add command completes.
if (widget.viewModel.add.completed) {
widget.viewModel.add.clearResult();
_controller.clear();
}
}
当用户点击 ListTile 中的 IconButton 时,执行删除命令。
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => widget.viewModel.delete.execute(todo.id),
)
然后,视图模型调用 TodoRepository.deleteTodo() 方法,传入唯一的待办事项标识符。与之相符的结果会被程序从视图模型和屏幕中移除。
Future<Result<void>> _delete(int id) async {
try {
final result = await _todoRepository.deleteTodo(id);
switch (result) {
case Ok<void>():
_todos.removeWhere((todo) => todo.id == id);
return Result.ok(null);
case Error():
return Result.error(result.error);
}
} on Exception catch (e) {
return Result.error(e);
} finally {
notifyListeners();
}
}
待办事项领域层
#
此示例应用程序的领域层 (Domain Layer) 包含
Todo 待办事项的数据模型。
数据项由不可变数据类 (Immutable Data) 进行定义。在本示例具体实现中,使用 freezed package 来自动生成相关代码。
该类定义了两个属性:
ID(int 类型)和待办任务描述(String 类型)。
@freezed
abstract class Todo with _$Todo {
const factory Todo({
/// The unique identifier of the Todo item.
required int id,
/// The task description of the Todo item.
required String task,
}) = _Todo;
}
待办事项数据层
#该功能的数据层由 TodoRepository 和 DatabaseService 这两个类组成。
在内部,TodoRepository 使用 DatabaseService,它通过 sqflite package 实现 SQL 数据库的访问。你还可以使用其他存储 package,如 sqlite3、drift,甚至云存储解决方案,如 firebase_database,来实现相同的 DatabaseService。
在内部, TodoRepository 使用 DatabaseService ,它通过 sqflite 包实现 SQL 数据库的访问。你可以使用其他存储包,如 sqlite3、 drift ,甚至云存储解决方案,如 firebase_database
,来实现相同的 DatabaseService 。
TodoRepository 在每次执行请求前,均会检查数据库的连接状态,并在未开启时建立数据库连接。
它实现了 fetchTodos()、 createTodo() 和 deleteTodo() 方法。
class TodoRepository {
TodoRepository({required DatabaseService database}) : _database = database;
final DatabaseService _database;
Future<Result<List<Todo>>> fetchTodos() async {
if (!_database.isOpen()) {
await _database.open();
}
return _database.getAll();
}
Future<Result<Todo>> createTodo(String task) async {
if (!_database.isOpen()) {
await _database.open();
}
return _database.insert(task);
}
Future<Result<void>> deleteTodo(int id) async {
if (!_database.isOpen()) {
await _database.open();
}
return _database.delete(id);
}
}
DatabaseService 通过 sqflite package 实现了对 SQLite 数据库的访问。
在编写 SQL 代码时,建议将表名和列名定义为常量,这样可以避免拼写错误。
static const String _todoTableName = 'todo';
static const String _idColumnName = '_id';
static const String _taskColumnName = 'task';
The open() method opens the existing database,
or creates a new one if it doesn’t exist.
Future<void> open() async {
_database = await databaseFactory.openDatabase(
join(await databaseFactory.getDatabasesPath(), 'app_database.db'),
options: OpenDatabaseOptions(
onCreate: (db, version) {
return db.execute(
'CREATE TABLE $_todoTableName($_idColumnName INTEGER PRIMARY KEY AUTOINCREMENT, $_taskColumnName TEXT)',
);
},
version: 1,
),
);
}
请注意,id 列被设置为 primary key 和 autoincrement,这意味着每条新插入的数据都会为 id 列分配一个唯一的递增值。
insert() 方法在数据库中创建一个新的待办事项,并返回一个新创建的 Todo 实例。其中 id 的值会按照上述机制来自动生成。
Future<Result<Todo>> insert(String task) async {
try {
final id = await _database!.insert(_todoTableName, {
_taskColumnName: task,
});
return Result.ok(Todo(id: id, task: task));
} on Exception catch (e) {
return Result.error(e);
}
}
所有 DatabaseService 操作都使用 Result 类来返回值,正如 Flutter 架构建议 的那样。这有助于在应用程序代码的后续步骤中处理错误。
getAll() 方法执行数据库查询,获取 id 和 task 列中的所有值。对于每一条记录,它创建一个 Todo 类的实例。
Future<Result<List<Todo>>> getAll() async {
try {
final entries = await _database!.query(
_todoTableName,
columns: [_idColumnName, _taskColumnName],
);
final list = entries
.map(
(element) => Todo(
id: element[_idColumnName] as int,
task: element[_taskColumnName] as String,
),
)
.toList();
return Result.ok(list);
} on Exception catch (e) {
return Result.error(e);
}
}
delete() 方法根据待办事项的 id 来执行数据库删除操作。
此时,如果没有删除任何数据项,将返回一个错误,以表明该操作未能按预期完成。
Future<Result<void>> delete(int id) async {
try {
final rowsDeleted = await _database!.delete(
_todoTableName,
where: '$_idColumnName = ?',
whereArgs: [id],
);
if (rowsDeleted == 0) {
return Result.error(Exception('No todo found with id $id'));
}
return Result.ok(null);
} on Exception catch (e) {
return Result.error(e);
}
}
整合业务
#
在应用程序的 main() 方法中,首先初始化 DatabaseService(针对不同平台编写特定的初始化代码),然后将新创建的 DatabaseService 实例注入 TodoRepository,最后将 TodoRepository 作为构造依赖项注入 MainApp。
void main() {
late DatabaseService databaseService;
if (kIsWeb) {
throw UnsupportedError('Platform not supported');
} else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
// Initialize FFI SQLite
sqfliteFfiInit();
databaseService = DatabaseService(databaseFactory: databaseFactoryFfi);
} else {
// Use default native SQLite
databaseService = DatabaseService(databaseFactory: databaseFactory);
}
runApp(
MainApp(
// ···
todoRepository: TodoRepository(database: databaseService),
),
);
}
随后,在创建 TodoListScreen 时,需要同步创建 TodoListViewModel,并将 TodoRepository 作为其依赖项注入。
TodoListScreen(
viewModel: TodoListViewModel(todoRepository: widget.todoRepository),
)
除非另有说明,本文档之所提及适用于 Flutter 3.38.1 版本。本页面最后更新时间:2025-10-30。查看文档源码 或者 为本页面内容提出建议。