🗂

永続ストレージアーキテクチャ:SQL - Flutterアーキテクチャ・デザインパターン

に公開

公式Docはこちら↓
https://docs.flutter.dev/app-architecture/design-patterns/sql

SQLを使用して複雑なアプリケーションデータをユーザーのデバイスに保存する方法を解説します。

ほとんどのFlutterアプリケーションは、大小にかかわらず、いずれかの時点でユーザーのデバイスにデータを保存する必要があるかもしれません。例えば、APIキー、ユーザー設定、またはオフラインで利用できるべきデータなどです。

このレシピでは、Flutterアーキテクチャ設計パターンに従って、SQLを使用して複雑なデータの永続ストレージを統合する方法を学びます。よりシンプルなキー値データを保存する方法を学ぶには、
永続ストレージアーキテクチャ: キーバリューデータを参照してください。

このレシピを読むには、SQLとSQLiteに精通している必要があります。
もし助けが必要な場合は、このレシピを読む前にSQLiteでデータを永続化するレシピを読むことができます。

この例では、sqflitesqflite_common_ffiプラグインを使用しており、これらはモバイルとデスクトップの両方をサポートしています。Webのサポートは実験的なプラグインsqflite_common_ffi_webで提供されていますが、この例には含まれていません。

サンプルアプリケーション: ToDoリストアプリケーション

サンプルアプリケーションは、上部にアプリバー、アイテムのリスト、下部にテキストフィールド入力がある単一の画面で構成されています。

アプリケーションの本体にはTodoListScreenが含まれています。
このListViewにはListTileアイテムが含まれており、それぞれがToDoアイテムを表しています。
下部には、TextFieldがあり、ユーザーはタスクの説明を書き込み、「+ Add」FilledButtonをタップすることで新しいToDoアイテムを作成できます。

ユーザーは削除IconButtonをタップしてToDoアイテムを削除できます。

ToDoアイテムのリストはデータベースサービスを使用してローカルに保存され、ユーザーがアプリケーションを起動すると復元されます。

SQLで複雑なデータを保存する

この機能は、推奨される[Flutterアーキテクチャ設計][]に従っており、UI層とデータ層を含んでいます。
さらに、ドメイン層には使用されるデータモデルがあります。

  • TodoListScreenTodoListViewModelを持つUI層
  • Todoデータクラスを持つドメイン層
  • TodoRepositoryDatabaseServiceを持つデータ層

ToDoリストプレゼンテーション層

TodoListScreenは、ToDoアイテムの表示と作成を担当するUIを含むウィジェットです。
これはMVVMパターンに従っており、TodoListViewModelが付属しており、ToDoアイテムのリストと、ToDoアイテムの読み込み、追加、削除の3つのコマンドが含まれています。

この画面は2つの部分に分かれており、1つはListViewを使用して実装されたToDoアイテムのリスト、もう1つはTextFieldButtonで、新しいToDoアイテムの作成に使用されます。

ListViewListenableBuilderでラップされており、TodoListViewModelの変更をリッスンし、各ToDoアイテムの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),
          ),
        );
      },
    );
  },
)

ToDoアイテムのリストはTodoListViewModelで定義されており、loadコマンドによって読み込まれます。このメソッドはTodoRepositoryを呼び出し、ToDoアイテムのリストを取得します。

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()メソッドを呼び出し、新しいToDoアイテムを作成します。

createTodo()メソッドは新しく作成されたToDoを返し、それはViewModelの_todoリストに追加されます。

ToDoアイテムには、データベースによって生成された一意の識別子が含まれています。
これが、ViewModelがToDoアイテムを作成せず、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();
  }
}

最後に、TodoListScreenaddコマンドの結果もリッスンします。アクションが完了すると、TextEditingControllerはクリアされます。

void _onAdd() {
  // addコマンドが完了したらテキストフィールドをクリアします。
  if (widget.viewModel.add.completed) {
    widget.viewModel.add.clearResult();
    _controller.clear();
  }
}

ユーザーがListTileIconButtonをタップすると、削除コマンドが実行されます。

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

その後、ViewModelは一意のToDoアイテム識別子を渡してTodoRepository.deleteTodo()メソッドを呼び出します。正しい結果は、ToDoアイテムをViewModelと画面から削除します。

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リスト ドメイン層

このサンプルアプリケーションのドメイン層には、Todoアイテムデータモデルが含まれています。

アイテムは不変のデータクラスによって表現されます。
この場合、アプリケーションはfreezedパッケージを使用してコードを生成します。

このクラスには2つのプロパティがあります。intで表されるIDと、Stringで表されるタスク説明です。

@freezed
abstract class Todo with _$Todo {
  const factory Todo({
    /// ToDoアイテムの一意の識別子。
    required int id,

    /// ToDoアイテムのタスク説明。
    required String task,
  }) = _Todo;
}

ToDoリスト データ層

この機能のデータ層は、2つのクラスで構成されています。TodoRepositoryDatabaseServiceです。

TodoRepositoryは、すべてのToDoアイテムの信頼できる情報源として機能します。
ViewModelは、ToDoリストにアクセスするためにこのRepositoryを使用する必要があり、どのように保存されているかの実装詳細を公開すべきではありません。

内部的には、TodoRepositoryDatabaseServiceを使用しており、これはsqfliteパッケージを使用してSQLデータベースへのアクセスを実装しています。
sqlite3drift、あるいは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パッケージを使用してSQLiteデータベースへのアクセスを実装しています。

SQLコードを書く際のタイプミスを避けるために、テーブル名と列名を定数として定義することをお勧めします。

static const String _todoTableName = 'todo';
static const String _idColumnName = '_id';
static const String _taskColumnName = 'task';

open()メソッドは既存のデータベースを開くか、存在しない場合は新しいデータベースを作成します。

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アイテムを作成し、新しく作成された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のすべての操作は、[Flutterアーキテクチャの推奨事項][]で推奨されているResultクラスを使用して値を返します。
これにより、アプリケーションコードのさらなるステップでのエラー処理が容易になります。

getAll()メソッドはデータベースクエリを実行し、idtask列のすべての値を取得します。
各エントリについて、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()メソッドは、ToDoアイテムの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を初期化します。これはプラットフォームによって異なる初期化コードが必要です。
次に、新しく作成されたDatabaseServiceTodoRepositoryに渡し、そのTodoRepository自体がコンストラクタ引数としてMainAppに渡されます。

void main() {
  late DatabaseService databaseService;
  if (kIsWeb) {
    throw UnsupportedError('Platform not supported');
  } else if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
    // FFI SQLiteを初期化する
    sqfliteFfiInit();
    databaseService = DatabaseService(databaseFactory: databaseFactoryFfi);
  } else {
    // デフォルトのネイティブSQLiteを使用する
    databaseService = DatabaseService(databaseFactory: databaseFactory);
  }

  runApp(
    MainApp(
      // ···
      todoRepository: TodoRepository(database: databaseService),
    ),
  );
}

次に、TodoListScreenが作成されるときに、TodoListViewModelも作成し、TodoRepositoryを依存関係として渡します。

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

Discussion