跳至主要內容

持久化儲存架構:SQL

大多數的 Flutter 應用程式,無論大小,都可能需要在某個時間點將資料儲存在使用者的裝置上。例如,API 金鑰、使用者偏好設定或應該離線可用的資料。

在此食譜中,您將學習如何使用 SQL 在 Flutter 應用程式中整合複雜資料的持久化儲存,並遵循 Flutter 架構設計模式。

若要了解如何儲存更簡單的鍵值資料,請參考食譜:持久儲存架構:鍵值資料

要閱讀本食譜,您應該熟悉 SQL 和 SQLite。如果您需要協助,可以在閱讀本食譜之前先閱讀使用 SQLite 持久化資料食譜。

此範例使用 sqflitesqflite_common_ffi 外掛程式,兩者結合支援行動裝置和桌面裝置。網頁支援由實驗性外掛程式 sqflite_common_ffi_web 提供,但未包含在此範例中。

範例應用程式:ToDo 清單應用程式

#

此範例應用程式包含一個單一畫面,頂部有一個應用程式列,一個項目清單,以及底部的文字欄位輸入。

ToDo application in light mode

應用程式的主體包含 TodoListScreen。此畫面包含 ListViewListTile 項目,每個項目代表一個待辦事項。在底部,TextField 允許使用者透過輸入任務描述,然後點擊「新增」FilledButton 來建立新的待辦事項。

使用者可以點擊刪除 IconButton 來刪除待辦事項。

待辦事項清單使用資料庫服務在本機儲存,並在使用者啟動應用程式時還原。

使用 SQL 儲存複雜資料

#

此功能遵循建議的 Flutter 架構設計,包含 UI 層和資料層。此外,在領域層中,您會找到使用的資料模型。

  • UI 層包含 TodoListScreenTodoListViewModel
  • 領域層包含 Todo 資料類別
  • 資料層包含 TodoRepositoryDatabaseService

ToDo 清單呈現層

#

TodoListScreen 是一個 Widget,其中包含負責顯示和建立待辦事項的 UI。它遵循 MVVM 模式,並搭配 TodoListViewModel,其中包含待辦事項清單和三個用於載入、新增和刪除待辦事項的命令。

此畫面分為兩個部分,一個部分包含待辦事項清單,使用 ListView 實作,另一個部分是 TextFieldButton,用於建立新的待辦事項。

ListViewListenableBuilder 包裝,該 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
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;
}

ToDo 清單資料層

#

此功能的資料層由兩個類別組成,即 TodoRepositoryDatabaseService

TodoRepository 作為所有待辦事項的真實來源。檢視模型必須使用此存放庫來存取待辦事項清單,並且不應公開有關它們如何儲存的任何實作詳細資訊。

在內部,TodoRepository 使用 DatabaseService,該服務使用 sqflite 套件實作對 SQL 資料庫的存取。您可以使用其他儲存套件(例如 sqlite3drift 甚至雲端儲存解決方案(例如 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 keyautoincrement;這表示每個新插入的項目都會為 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() 方法會執行資料庫查詢,取得 idtask 欄中的所有值。對於每個項目,它會建立一個 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,然後再將其作為建構函式引數相依性傳遞到 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,
  ),
)