見出し画像

【Flutter Web】DriftでローカルDBを実装する


概要

Drift

Drift」は、SQLiteを簡易的に使えるようにしたパッケージです。
このDriftを『Flutte Web』で使ってみたので、その備忘録を記す

「SQLite Wasm」 と 「Web Worker」

●SQLite Wasm
WebAssembly」はウェブブラウザーでの処理をバイナリー形式で実行出来る仕組みです。
SQLite Wasm』はSQLite公式のWebAssemblyです。
これを導入する事によりブラウザ上でRDBMSを実行出来るようになる訳です。
→ 本記事では「sqlite3.wasm」をダウンロードして使用します。

本記事でのDriftでは「IndexedDB」にアクセスさせます。
これにより、以下の事が可能となります。
・SQL操作
・大容量(数GB単位)のデーター保存
・永続化出来る(有効期限などの制限は無い)

●Web Worker
Web Worker 」を使うと、Javascriptをマルチスレッドで実行することが出来ます。
DB処理を別スレッドで行うため、(時間が掛かっても)UI操作が固まる事を回避出来るようになります。
→ 本記事では「drift_worker.js」をダウンロードして使用します。

GitHub

●GitHub
本記事の全体的なソースコードはGitHubにて公開しております。

参考サイト

●参考になったサイト

実装

検証環境

●検証環境
macOS Sonoma 14.3
VSCode 1.92.1
Flutter 3.22.3
Dart 3.4.3

※ 基本的なFlutter開発環境は整っている事を前提とします

プロジェクトを作成

では、早速やってみましょう。

●新規プロジェクトの作成
1.コマンドパレット(Command + Shift + P)を開き「flutter」と入力し、「Flutter: New Project」を選択。
2.一番上の「Application」を選択。
3.ワークスペースとなるディレクトリを選択(この中にプロジェクトが作成されます)

プロジェクト名は「note_flutter_drift」とします(GitHubのリポジトリ名もこれに合わせてます)。

下準備

●WebAssembly
先述の「SQLite Wasm」 と 「Web Worker」を使うための下準備をします。

まずは以下をダウンロードする
1.sqlite3の公式GitHubリリースページから「sqlite3.wasm
2.driftの公式GitHubのリリースページから「drift_worker.js
をダウンロードしてwebディレクトリに配置する。

※ 配置後は以下のような感じになります

●パッケージ

dependencies:
  drift: ^2.19.0
  intl: ^0.19.0
dev_dependencies:
  drift_dev: ^2.19.0
  build_runner:

pubspec.yaml」に追記

drift:Drift(DBパッケージ)
intl:ローカライズ関連。本記事では日時のフォーマット形式などで使用。
build_runner:build_runnerコマンド(ファイル名.g.dart)のコード生成

データーベース

database.dart を作成し、以下のようにします。

【database.dart】(とりあえず定義関連)

import 'package:drift/drift.dart';
import 'package:drift/wasm.dart'; // Web専用(アプリで使うとエラーになるので注意)
part 'database.g.dart'; // ファイル名.g.dart

class Todos extends Table{
  IntColumn get id => integer().autoIncrement()(); // ID
  TextColumn get content => text()(); // 内容
  // TextColumn get content => text().withLength(min: 12, max: 48)();
  DateTimeColumn get createDatetime => dateTime()(); // 作成日時
  // DateTimeColumn get createDatetime => dateTime().nullable()();
}

@DriftDatabase(tables: [Todos])
class Database extends _$Database {
  Database._(QueryExecutor e) : super(e);
  factory Database() => Database._(connectOnWeb());
  @override
  int get schemaVersion => 1;
}

DatabaseConnection connectOnWeb() { // DBコネクト(Web用)
  return DatabaseConnection.delayed(Future(() async {
    final result = await WasmDatabase.open(
      databaseName: 'todos_db',
      sqlite3Uri: Uri.parse('sqlite3.wasm'),
      driftWorkerUri: Uri.parse('drift_worker.js'),
    );

    if (result.missingFeatures.isNotEmpty) {
      print('Using ${result.chosenImplementation} due to missing browser '
          'features: ${result.missingFeatures}');
    }

    return result.resolvedExecutor;
  }));
}

「テーブル定義」「DB定義」「DBコネクト関数」を書きます。

connectOnWeb関数内のこの2行に注目してください。
先ほど下準備したファイルが使われてます。ここでデーターの保存先を指定している訳です。
※ 詳細は本記事の『データーの保存場所』にて後述

●「ファイル名.g.dart」のコード生成

$ flutter pub run build_runner build --delete-conflicting-outputs

build_runnerコマンドで「ファイル名.g.dart」のコード生成します。

今回の場合「database.g.dart」が生成されます

●CRUD用のメソッド
Databaseクラス内にCRUD用のメソッドを追記します。
DBへのアクセスは非同期で行うので、Future(ワンショット形式)やStream(ストリーム形式)となります。

Driftを使うとこんなに簡潔に書けちゃう
特に一番下のwatchEntries内で使用している「.watch()」は付けるだけでリアル監視出来ちゃうという優れもの。

【database.dart】(完成形)

import 'package:drift/drift.dart';
import 'package:drift/wasm.dart'; // Web専用(アプリで使うとエラーになるので注意)
part 'database.g.dart'; // ファイル名.g.dart

class Todos extends Table{
  IntColumn get id => integer().autoIncrement()(); // ID
  TextColumn get content => text()(); // 内容
  // TextColumn get content => text().withLength(min: 12, max: 48)();
  DateTimeColumn get createDatetime => dateTime()(); // 作成日時
  // DateTimeColumn get createDatetime => dateTime().nullable()();
}

@DriftDatabase(tables: [Todos])
class Database extends _$Database {
  Database._(QueryExecutor e) : super(e);
  factory Database() => Database._(connectOnWeb());
  @override
  int get schemaVersion => 1;

  Future insertTodo(String content,DateTime createDatetime) {
    return into(todos).insert(TodosCompanion.insert(content: content, createDatetime: createDatetime));
  }

  Future deleteTodo(int id) {
    return (delete(todos)..where((todo) => todo.id.equals(id))).go();
  }

  Future updateTodo(int id, String content) {
    return (update(todos)..where((todo) => todo.id.equals(id)))
        .write(TodosCompanion(content: Value(content)));
  }  

  Stream<List<Todo>> watchEntries() {
    return (select(todos)).watch();
  }
}

DatabaseConnection connectOnWeb() { // DBコネクト(Web用)
  return DatabaseConnection.delayed(Future(() async {
    final result = await WasmDatabase.open(
      databaseName: 'todos_db',
      sqlite3Uri: Uri.parse('sqlite3.wasm'),
      driftWorkerUri: Uri.parse('drift_worker.js'),
    );

    if (result.missingFeatures.isNotEmpty) {
      print('Using ${result.chosenImplementation} due to missing browser '
          'features: ${result.missingFeatures}');
    }

    return result.resolvedExecutor;
  }));
}

画面側

画面側を実装しましょう。

【main.dart】

import 'package:flutter/material.dart';
import './database.dart';
import 'package:intl/intl.dart'; // ロケール。日時表示で使う。

final db = Database();

void main() {
  final list = Expanded(
    child: StreamBuilder(
      stream: db.watchEntries(),
      builder:
          (BuildContext context, AsyncSnapshot<List<Todo>> snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }

        // snapshot.data![i].カラム名 では長いので
        List<Todo>  tl = snapshot.data ?? [];
        
        return ListView.builder(        
          itemCount: snapshot.data!.length,
          itemBuilder: (context, i) => ListTile(
            leading: const Icon(Icons.person),
            title: Text(tl[i].content),
            //DateFormat df = DateFormat('yyyy-MM-dd HH:mm:ss');
            subtitle: Text( 
              "[ID:${tl[i].id}] ${DateFormat('yyyy-MM-dd HH:mm:ss').format(tl[i].createDatetime)}" 
            ),
            trailing:  Wrap(
              spacing: 5, // アイコンの間の幅を調整
              children: [
                IconButton(
                  icon: const Icon(Icons.edit),
                  onPressed: () async {
                    await db.updateTodo(tl[i].id,
                      '更新'
                    );
                  },
                ), 
                IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () async { // 削除なので、本来は「確認ダイアログ」を挟む
                    await db.deleteTodo(tl[i].id);
                  },
                ),
              ],
            ),
            tileColor: Colors.purple[50], // アイテムの背景色
          ),
        );
      },
    ),
  );

  final addButton = ElevatedButton(
    child: const Text('追加'),
    onPressed: () async {
      await db.insertTodo(
        '追加', DateTime.now()
      );
    },
  );

  final body = SafeArea( // ボディー
    child: Column(
      children: [
        list,
        addButton,
      ],
    )
  );

  final sc = Scaffold(
    body: body, // ボディー
  );

  final app = MaterialApp(home: sc);
  runApp(app);
}

main()直下にウィジェットを書きまくるという暴挙に出てますが、今回は検証用なのでご容赦を😉

・StreamBuilderのstreamに先ほどのwatchEntriesメソッドを指定。
・スナップショットとして、TodoクラスのListを指定。
の2点に注目

ちなみに「Expanded」でラッピングしてますが、これはListViewをColumnに入れる場合はExpanded等で制限をする必要があるからです。

動作確認

●ビルド

1.コマンドパレット(Command + Shift + P)を開き「flutter: Select Device」と入力
2.今回はWebなので「Chrome」を選択

「実行」ボタン押下でビルドします。

●動作確認

アプリ起動

「追加」ボタンを5回ほど押下

「ID:3」の編集アイコン押下

「ID:4」の削除アイコン押下

「localhost」の場合、ビルドは再インストールという形になります。
アプリでいう「終了 → 起動」のプロセスは、「更新」ボタンでブラウザを更新となります。

※ もちろん、外部へデプロイやリリースした場合は、ブラウザを閉じてもデーターは保存されます。

データーの保存場所

データーの保存場所(パス)についてですが
Web(ブラウザ)の場合、任意のファイルにアクセスは出来ません。
よってブラウザ固有のパスに保存されます。
例えばDrift(SQLite)であれば Storage - IndexedDB となります。

「表示」→「開発/管理」→「デベロッパーツール」

デベロッパーツールで「Application」→「Storage」→「IndexedDB」を表示

著書

【辛島信芳の著書】
IT技術などに興味のある方は、是非ご覧になってください。


この記事が気に入ったらサポートをしてみませんか?