【Flutter】MasamuneのDB連携機能強化 - mathru.net | Flutter/Unityによるアプリ開発/楽曲・映像制作/素材配布
2023-07-28

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

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

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

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

masamune

An application development framework centered on automatic code generation using build_runner.
https://pub.dev https://pub.dev
title
Building system for masamune framework. Automatic creation of models, themes, pages, and translation data.
https://pub.dev https://pub.dev
title

はじめに

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

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

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

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

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

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

データの種類と置き場所

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

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

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

(例えばカテゴリーを固定でなくユーザーや管理者が自由に追加・削除できるようにするためにはリモート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を変更する場合、それまでのデータを新しいデータベースに移行する必要がありますが、 RuntimeDatabase → LocalDatabase 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.
@freezed
@formValue
@immutable
@CollectionModelPath("post")
abstract class PostModel with _$PostModel {
  const factory PostModel({
    required String title,
    required String text,
    @refParam CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

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

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("category")
abstract class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

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

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("user")
abstract class UserModel with _$UserModel {
  const factory UserModel({
    required String name,
    ModelImageUri? icon,
    @refParam PrefsModelRef? prefs,
  }) = _UserModel;
  const UserModel._();

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

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the document.
@DocumentModelPath("app/prefs")
abstract class PrefsModel with _$PrefsModel {
  const factory PrefsModel({
    String? theme,
    @Default(false) bool isFinishTutorial,
  }) = _PrefsModel;
  const PrefsModel._();

  ~~~~
}

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

katana code generate

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

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

// main.dart

~~~~~

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

さらに RuntimeModelAdapter と LocalModelAdapter を定義します。

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

// main.dart

~~~~~

const localModelAdapter = LocalModelAdapter();
const runtimeModelAdapter = RuntimeModelAdapter(
  initialValue: [
    CategoryModelDataCollection(
      {
        "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.
@freezed
@formValue
@immutable
@CollectionModelPath(
  "post",
  adapter: firestoreModelAdapter,
)
abstract class PostModel with _$PostModel {
  const factory PostModel({
    required String title,
    required String text,
    @refParam CategoryModelRef category,
  }) = _PostModel;
  const PostModel._();

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

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
  "category",
  adapter: runtimeModelAdapter,
)
abstract class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

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

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

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

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the document.
@DocumentModelPath(
  "app/prefs",
  adapter: localModelAdapter,
)
abstract class PrefsModel with _$PrefsModel {
  const factory PrefsModel({
    String? theme,
    @Default(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';

@immutable
@PagePath("/")
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.
  /// ```
  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  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);
              },
            );
          }),
        ],
      ),
    );
  }
}

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

image block

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

image block

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

image block

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

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

image block

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

image block

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

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

image block

最後にカテゴリーを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.dart の adapter を firestoreModelAdapter に変更します。

// models/category.dart

~~~~~~

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
  "category",
  adapter: firebaseModelAdapter,
)
abstract class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    required String name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~~~
}

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

image block

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

image block

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

image block

おわりに

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

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

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

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

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

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

FlutterやUnityを使ったアプリ開発やアプリの紹介。制作した楽曲・映像の紹介。画像や映像素材の配布。仕事の受注なども行っています。
https://mathru.net https://mathru.net
title

GitHub Sponsors

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

Developed the katana/masamune framework, which has dramatically improved the overall efficiency of Flutter-based application development.
https://github.com https://github.com
title
【Flutter】Googleスプレッドシートをデータソースとして使えるようにした

Development Flutter Masamune Dart pub

◀︎ Next
【Flutter】Masamuneで超速アプリ開発⑤「メモ帳アプリ作成 - テキスト検索と画像アップロード」

Development Flutter Dart pub Plugins Masamune

▶︎ Previous