大多数 Flutter 应用程序,无论大小,都可能在某个时候需要将数据存储在用户的设备上。例如,API 密钥、用户偏好设置或需要离线可用的数据。

在本示例中,您将学习如何在 Flutter 应用程序中,遵循 Flutter 架构设计模式,使用 SQL 集成复杂数据的持久化存储。

要学习如何存储更简单的键值数据,请参阅 Cookbook 示例:持久化存储架构:键值数据

阅读本示例前,您应该熟悉 SQL 和 SQLite。如果需要帮助,您可以先阅读使用 SQLite 持久化数据 示例。

本示例使用 sqflite 以及 sqflite_common_ffi 插件,它们结合支持移动端和桌面端。Web 端支持由实验性插件 sqflite_common_ffi_web 提供,但本示例未包含。

示例应用:待办事项列表应用

#

示例应用程序由一个屏幕组成,顶部是应用栏,中间是项目列表,底部是文本输入框。

ToDo application in light mode

应用程序主体包含 `TodoListScreen`。此屏幕包含一个 `ListView`,其中包含 `ListTile` 项目,每个项目代表一个待办事项。在底部,`TextField` 允许用户通过写入任务描述然后点击“Add” `FilledButton` 来创建新的待办事项。

用户可以点击删除 `IconButton` 来删除待办事项。

待办事项列表使用数据库服务在本地存储,并在用户启动应用程序时恢复。

使用 SQL 存储复杂数据

#

此功能遵循推荐的Flutter 架构设计,包含 UI 层和数据层。此外,在领域层中您会找到使用的数据模型。

  • 包含 `TodoListScreen` 和 `TodoListViewModel` 的 UI 层
  • 包含 `Todo` 数据类的领域层
  • 包含 `TodoRepository` 和 `DatabaseService` 的数据层

待办事项列表表示层

#

`TodoListScreen` 是一个 Widget,它包含负责显示和创建待办事项的 UI。它遵循MVVM 模式,并附带 `TodoListViewModel`,后者包含待办事项列表以及加载、添加和删除待办事项的三个命令。

该屏幕分为两部分,一部分包含待办事项列表(使用 `ListView` 实现),另一部分是 `TextField` 和 `Button`,用于创建新的待办事项。

`ListView` 被 `ListenableBuilder` 包裹,`ListenableBuilder` 监听 `TodoListViewModel` 中的更改,并为每个待办事项显示一个 `ListTile`。

dart
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` 并获取待办事项列表。

dart
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` 命令并传入文本控制器值。

dart
FilledButton.icon(
  onPressed: () =>
      widget.viewModel.add.execute(_controller.text),
  label: const Text('Add'),
  icon: const Icon(Icons.add),
)

然后,`add` 命令调用 `TodoRepository.createTodo()` 方法,并传入任务描述文本,从而创建一个新的待办事项。

`createTodo()` 方法返回新创建的待办事项,然后将其添加到视图模型中的 `_todo` 列表中。

待办事项包含由数据库生成的唯一标识符。这就是为什么视图模型不创建待办事项,而是 `TodoRepository` 来创建。

dart
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` 会被清除。

dart
void _onAdd() {
  // Clear the text field when the add command completes.
  if (widget.viewModel.add.completed) {
    widget.viewModel.add.clearResult();
    _controller.clear();
  }
}

当用户点击 `ListTile` 中的 `IconButton` 时,删除命令被执行。

dart
IconButton(
  icon: const Icon(Icons.delete),
  onPressed: () => widget.viewModel.delete.execute(todo.id),
)

然后,视图模型调用 `TodoRepository.deleteTodo()` 方法,传入唯一的待办事项标识符。正确的结果会将待办事项从视图模型 *和* 屏幕中删除。

dart
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();
  }
}

待办事项列表领域层

#

本示例应用程序的领域层包含 `Todo` 项目数据模型。

项目由一个不可变数据类表示。在本例中,应用程序使用 `freezed` 包来生成代码。

该类有两个属性,一个由 `int` 表示的 ID,以及一个由 `String` 表示的任务描述。

dart
@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` 作为所有待办事项的真相来源。视图模型必须使用此存储库来访问待办事项列表,并且不应暴露任何关于它们如何存储的实现细节。

在内部,`TodoRepository` 使用 `DatabaseService`,后者使用 `sqflite` 包实现对 SQL 数据库的访问。您可以使用其他存储包,如 `sqlite3`、`drift` 甚至云存储解决方案(如 `firebase_database`)来实现相同的 `DatabaseService`。

`TodoRepository` 在每次请求前检查数据库是否已打开,并在必要时打开它。

它实现了 `fetchTodos()`、`createTodo()` 和 `deleteTodo()` 方法。

dart
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` 包实现对 SQLite 数据库的访问。

最好将表名和列名定义为常量,以避免在编写 SQL 代码时出现拼写错误。

dart
static const _kTableTodo = 'todo';
static const _kColumnId = '_id';
static const _kColumnTask = 'task';

`open()` 方法打开现有数据库,如果不存在则创建一个新数据库。

dart
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 $_kTableTodo($_kColumnId INTEGER PRIMARY KEY AUTOINCREMENT, $_kColumnTask TEXT)',
        );
      },
      version: 1,
    ),
  );
}

请注意,`id` 列被设置为 `primary key` 和 `autoincrement`;这意味着每个新插入的项目都会为 `id` 列分配一个新值。

`insert()` 方法在数据库中创建一个新的待办事项,并返回一个新创建的 `Todo` 实例。`id` 如前所述生成。

dart
Future<Result<Todo>> insert(String task) async {
  try {
    final id = await _database!.insert(_kTableTodo, {_kColumnTask: task});
    return Result.ok(Todo(id: id, task: task));
  } on Exception catch (e) {
    return Result.error(e);
  }
}

`DatabaseService` 的所有操作都使用 `Result` 类返回值,这符合 Flutter 架构建议。这有助于在应用程序代码的后续步骤中处理错误。

`getAll()` 方法执行数据库查询,获取 `id` 和 `task` 列中的所有值。对于每个条目,它都会创建一个 `Todo` 类实例。

dart
Future<Result<List<Todo>>> getAll() async {
  try {
    final entries = await _database!.query(
      _kTableTodo,
      columns: [_kColumnId, _kColumnTask],
    );
    final list = entries
        .map(
          (element) => Todo(
            id: element[_kColumnId] as int,
            task: element[_kColumnTask] as String,
          ),
        )
        .toList();
    return Result.ok(list);
  } on Exception catch (e) {
    return Result.error(e);
  }
}

`delete()` 方法根据待办事项 `id` 执行数据库删除操作。

在这种情况下,如果未删除任何项目,则会返回错误,表明出现问题。

dart
Future<Result<void>> delete(int id) async {
  try {
    final rowsDeleted = await _database!.delete(
      _kTableTodo,
      where: '$_kColumnId = ?',
      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`。

dart
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` 作为依赖项传递给它。

dart
TodoListScreen(
  viewModel: TodoListViewModel(todoRepository: widget.todoRepository),
)