🦾

【Flutter】MasamuneのDB連携機能強化

2023/07/29に公開

こんにちは。広瀬マサルです。

Flutter上で動作する統合フレームワークMasamuneを随時更新しています。

https://zenn.dev/mathru/articles/dcd9a52bcf3e94

今回はアプリ開発体験を大きく変えうるアップデートがあったのでその紹介をします。

masamune

https://pub.dev/packages/masamune

https://pub.dev/packages/masamune_builder

はじめに

Masamuneフレームワークは「CLIによるコード生成」と「build_runnnerによるコードの自動生成」を用いて可能な限りコードの記述量を減らし、アプリ開発の安定性と高速性を高めるFlutter上のフレームワークです。

データベースに関してもFirestoreをベースにしたNoSQLデータベースを利用し、わずか1行の更新でランタイムDB端末ローカルDBFirestoreを切り替えることが可能な仕組みを提供しています。

これまでは、ModelAdapterを切り替えることでアプリ内のすべてのDBを入れ替えるような仕組みを提供していましたが

Masamune Ver.2.3.4から各データモデルごとにModelAdapterを設定可能になりました。

つまり、ユーザーデータやコンテンツデータ、それに付随するデータごとにデータベースを変えることができ、さらにそれらを連携して意識することなく利用することができます

どういったことができるかを順を追って説明していきましょう。

データの種類と置き場所

アプリで取り扱うデータはデータの置き場所によって下記の3つに分けられるはずです。

  1. アプリ内で定義されたデータ
    • 固定されたデータリスト
      • カテゴリー
      • 性別の種類
      • 年齢層
      • 地域
    • アプリ内の設定
      • 最大投稿文字数
      • お気に入りの登録上限
    • enumconstでコード内に記載されることが多い
  2. 端末ローカルに保存されるデータ
    • 自分のみで閲覧するためのデータ
      • メモデータ
      • スケジュール
    • アプリの状態を保存するデータ
      • チュートリアルをクリアしたか
      • トロフィーデータ
      • 音量ボリューム
      • アプリテーマ
    • 端末内にファイルとして保存したりSharedPreferencesで保存されることが多い
  3. リモートDBに保存されるデータ
    • 他ユーザーと共有するためのデータ
      • ユーザープロフィールデータ
      • ブログなどの記事データ
      • ダイレクトメッセージなどのコミュニケーションデータ
    • データの永続化
      • 「端末ローカルに保存されるデータ」の「自分のみで閲覧するためのデータ」を別端末で閲覧したり端末を変えても見れるようにする
    • FirestoreRDBなどに保存される

これらはアプリの要件によって変わります。

(例えばカテゴリーを固定でなくユーザーや管理者が自由に追加・削除できるようにするためにはリモートDBに保存する必要がある)

Masamuneフレームワークでは1→RuntimeDatabase、2→LocalDatabase、3→FirestoreDatabaseとして扱うことが可能です。

異なるDB間でのリレーション

下記のデータを考えてみます。

  • 記事データ(全ユーザーに共有するためFirestoreDatabaseを利用)
    • 記事名
    • 記事の内容
  • カテゴリーデータ(アプリ内での固定データのためRuntimeDatabaseを利用)
    • カテゴリー名
  • ユーザーデータ(全ユーザーに共有するためFirestoreDatabaseを利用)
    • ユーザー名
    • アイコン
  • アプリ設定データ(アプリ内の設定のためLocalDatabaseを利用)
    • アプリテーマ
    • チュートリアルをクリアしたか

このとき下記のようなリレーションを設定可能です。

  • 記事データ(全ユーザーに共有するためFirestoreDatabaseを利用)
    • 記事名
    • 記事の内容
    • カテゴリーデータへの参照
  • カテゴリーデータ(アプリ内での固定データのためRuntimeDatabaseを利用)
    • カテゴリー名
  • ユーザーデータ(全ユーザーに共有するためFirestoreDatabaseを利用)
    • ユーザー名
    • アイコン
    • アプリ設定データへの参照
  • アプリ設定データ(アプリ内の設定のためLocalDatabaseを利用)
    • アプリテーマ
    • チュートリアルをクリアしたか

Masamuneフレームワークでは特定のデータを読み込むときload()メソッドを実行しますが、そのときに参照に含まれているデータもまとめて読み込みます

つまり、記事データを読み込むだけでFirestoreに保存されているデータを読み込むだけでなく、付随するRuntime上に定義されているカテゴリーデータもまとめて読み込むことができ、同じデータとして扱えるというわけです

同じ様にFirestoreに保存されているユーザーデータとローカルに保存されているアプリ設定データをユーザーが持つデータとしてまとめて読み込むことができます。

データベースの移行

例えばカテゴリーを固定で設定していたものをユーザーによって追加・削除できるようにしたい場合、通常であればカテゴリーのテーブルを別途作りそこにデータの読み書きを行う処理を別途実装する必要があります。そこそこの改修内容となります。

Masamuneフレームワークではカテゴリーを予めRuntimeDatabaseで定義しておけば、1行の変更で完了します。

またDBを変更する場合、それまでのデータを新しいデータベースに移行する必要がありますが、RuntimeDatabaseLocalDatabase or FirestoreDatabaseの場合は、そこも気にする必要がありません。

実際にやってみよう

それでは実際の実装を行っていきたいと思います。

まずkatana createでプロジェクトを作成します。

katana create net.mathru.modeltest

少し待てばプロジェクトファイルが作成されます。

続いて各種データモデルを作成していきましょう。

# 記事データ
katana code collection post
# カテゴリーデータ
katana code collection category
# ユーザーデータ
katana code collection user
# アプリ設定データ
katana code document prefs

アプリ設定データはコレクションではなくドキュメントで作成します。

各モデルのフィールドを設定していきます。

// models/post.dart

/// Value for model.



("post")
class PostModel with _$PostModel {
  const factory PostModel({
    required String title,
    required String text,
     CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

  ~~~~
}
// models/category.dart

/// Value for model.



// TODO: Set the path for the collection.
("category")
class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~
}
// models/user.dart

/// Value for model.



// TODO: Set the path for the collection.
("user")
class UserModel with _$UserModel {
  const factory UserModel({
    required String name,
    ModelImageUri? icon,
     PrefsModelRef? prefs,
  }) = _UserModel;
  const UserModel._();

  ~~~~
}
// models/prefs.dart

/// Value for model.



// TODO: Set the path for the document.
("app/prefs")
class PrefsModel with _$PrefsModel {
  const factory PrefsModel({
    String? theme,
    (false) bool isFinishTutorial,
  }) = _PrefsModel;
  const PrefsModel._();

  ~~~~
}

下記コマンドでbuild_runnerによる自動生成を行います。

katana code generate

Firestoreを利用するので下記の記事を参考にFirestoreを利用可能にします。

https://zenn.dev/mathru/books/d219c9b7cdfd53/viewer/5d6da8

constで記述するためプラットフォームごとのFirebaseOptionsを指定してFirestoreModelAdapterを作成してください。

// main.dart

~~~~~

const firestoreModelAdapter = FirestoreModelAdapter(
  iosOptions: DefaultFirebaseOptions.ios,
  androidOptions: DefaultFirebaseOptions.android,
);

さらにRuntimeModelAdapterLocalModelAdapterを定義します。

RuntimeModelAdapterにはカテゴリーのデータを予め追加しておきます。

// main.dart

~~~~~

const localModelAdapter = LocalModelAdapter();
const runtimeModelAdapter = RuntimeModelAdapter(
  initialValue: [
    CategoryModelInitialCollection(
      {
        "beauty_and_health": CategoryModel(
          name: "Beauty & Health",
        ),
        "it_and_programming": CategoryModel(
          name: "IT & Programming",
        ),
        "business": CategoryModel(
          name: "Business",
        ),
      },
    ),
  ],
);

各モデルにModelAdapterの指定を行っていきます。

// models/post.dart

/// Value for model.



(
  "post",
  adapter: firestoreModelAdapter,
)
class PostModel with _$PostModel {
  const factory PostModel({
    required String title,
    required String text,
     CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

  ~~~~
}
// models/category.dart

/// Value for model.



// TODO: Set the path for the collection.
(
  "category",
  adapter: runtimeModelAdapter,
)
class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~
}
// models/user.dart

/// Value for model.



// TODO: Set the path for the collection.
(
  "user",
  adapter: firestoreModelAdapter,
)
class UserModel with _$UserModel {
  const factory UserModel({
    required String name,
    ModelImageUri? icon,
     PrefsModelRef? prefs,
  }) = _UserModel;
  const UserModel._();

  ~~~~
}
// models/prefs.dart

/// Value for model.



// TODO: Set the path for the document.
(
  "app/prefs",
  adapter: localModelAdapter,
)
class PrefsModel with _$PrefsModel {
  const factory PrefsModel({
    String? theme,
    (false) bool isFinishTutorial,
  }) = _PrefsModel;
  const PrefsModel._();

  ~~~~
}

アダプターを変更したのでbuild_runnerによる自動生成を再度行います。

katana code generate

これでデータモデル側の実装は完了です。

データを確認するためにページを作成します。

今回はデータを確認するという目的で1ページにまとめますが、実際はプロフィールページや記事のページを作るようなイメージになります。

pages/home.dartを編集します。

// pages/home.dart

// ignore: unused_import, unnecessary_import
import 'package:flutter/material.dart';
// ignore: unused_import, unnecessary_import
import 'package:masamune/masamune.dart';
import 'package:masamune_universal_ui/masamune_universal_ui.dart';
import 'package:modeltest/models/category.dart';
import 'package:modeltest/models/post.dart';
import 'package:modeltest/models/prefs.dart';
import 'package:modeltest/models/user.dart';

// ignore: unused_import, unnecessary_import
import '/main.dart';

part 'home.page.dart';


("/")
class HomePage extends PageScopedWidget {
  const HomePage({
    super.key,
  });

  /// Used to transition to the HomePage screen.
  ///
  /// ```dart
  /// router.push(HomePage.query(parameters));    // Push page to HomePage.
  /// router.replace(HomePage.query(parameters)); // Replace page to HomePage.
  /// ```
  
  static const query = _$HomePageQuery();

  
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    const userId = "user1";
    final user = ref.model(UserModel.document(userId))..load();
    final posts = ref.model(PostModel.collection())..load();
    final categories = ref.model(CategoryModel.collection())..load();

    // Describes the structure of the page.
    return UniversalScaffold(
      appBar: UniversalAppBar(title: Text(l().appTitle)),
      body: UniversalListView(
        children: [
          const ListTile(
            title: Text(
              "User Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          if (user.value != null)
            ListTile(
              title: Text(user.value?.name ?? ""),
              subtitle: Text(user.value?.prefs?.value?.theme ?? ""),
              trailing: FormSwitch(
                initialValue:
                    user.value?.prefs?.value?.isFinishTutorial ?? false,
                onChanged: (value) async {
                  await user.value?.prefs?.save(
                    user.value?.prefs?.value?.copyWith(isFinishTutorial: value),
                  );
                },
              ),
            )
          else
            ListTile(
              leading: const Icon(Icons.add),
              title: const Text("Create user data"),
              onTap: () async {
                final prefs = ref.model(PrefsModel.document());
                await prefs.load().showIndicator(context);
                if (prefs.value == null) {
                  await prefs.save(const PrefsModel()).showIndicator(context);
                }
                await user
                    .save(
                      UserModel(
                        name: "User at ${DateTime.now().yyyyMMddHHmmss()}",
                        prefs: prefs,
                      ),
                    )
                    .showIndicator(context);
              },
            ),
          const Divider(),
          const ListTile(
            title: Text(
              "Post Data",
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...posts.mapListenable((post) {
            return ListTile(
              title: Text(post.value?.title ?? ""),
              subtitle: Text(post.value?.category?.value?.name ?? ""),
            );
          }),
          Divider(color: theme.color.outline.withOpacity(0.25)),
          ...categories.map((category) {
            return ListTile(
              leading: const Icon(Icons.add),
              title: Text(category.value?.name ?? ""),
              onTap: () async {
                final newPost = posts.create();
                await newPost
                    .save(
                      PostModel(
                        title: "Post at ${DateTime.now().yyyyMMddHHmmss()}",
                        text: "",
                        category: category,
                      ),
                    )
                    .showIndicator(context);
              },
            );
          }),
        ],
      ),
    );
  }
}

アプリをビルドすると下記のような画面が起動します。

Create user dataをタップするとユーザーデータが作成されます。

Firestoreを開くとこのようにデータが保存されています。

チェックを入れるとそれがローカルに書き込まれデータが永続化されます。

アプリを再度起動してもチェックが入ったままになります。

各カテゴリーをタップすると投稿が作成されます。

Firestoreを確認すると下記のようにデータが保存されています。

Firestore上はcategoryフィールドに参照用のパスしか保存されていませんがアプリ上はそのパスからnameのデータを拾ってカテゴリ名まで表示できていることがわかります。

最後にカテゴリーをFirestore管理に移してみましょう。

main.dartを開きRuntimeModelAdapterのカテゴリーの設定をFirestoreModelAdapterに移植します。

// main.dart

~~~~~~

const firestoreModelAdapter = FirestoreModelAdapter(
  iosOptions: DefaultFirebaseOptions.ios,
  androidOptions: DefaultFirebaseOptions.android,
  initialValue: [
    CategoryModelInitialCollection(
      {
        "beauty_and_health": CategoryModel(
          name: "Beauty & Health",
        ),
        "it_and_programming": CategoryModel(
          name: "IT & Programming",
        ),
        "business": CategoryModel(
          name: "Business",
        ),
      },
    ),
  ],
);
const localModelAdapter = LocalModelAdapter();
const runtimeModelAdapter = RuntimeModelAdapter();

models/category.dartadapterfirestoreModelAdapterに変更します。

// models/category.dart

~~~~~~

/// Value for model.



// TODO: Set the path for the collection.
(
  "category",
  adapter: firebaseModelAdapter,
)
class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~~~
}

ビルドして起動してみるとこれまでと同じ様にカテゴリー名を取得できていることがわかります。

試しにFirestoreのほうにデータを追加してみましょう。

アプリを再起動するとデータが反映されていることがわかります。

おわりに

この新しい機能を使うことで、細かくデータソースを指定したコードが書けるようになります。

下記のような様々なメリットが生まれてくるでしょう。

  • モックアップの作成→サーバーの実装の短縮化
  • 全データを一元的に管理
  • データスキームを定義することで安全にデータを指定

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください!

また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!

また仕事の依頼等ございましたら、私のTwitterWebサイトで直接ご連絡をお願いいたします!

https://mathru.net/ja/contact

GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております!

https://github.com/sponsors/mathrunet

Discussion