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

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

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

https://qiita.com/mathru/items/79041081fd56fbbba724

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

# masamune

https://pub.dev/packages/masamune

https://pub.dev/packages/masamune_builder

# はじめに

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

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

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

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

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

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

## データの種類と置き場所

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

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

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

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

[Masamuneフレームワーク](https://pub.dev/packages/masamune)では1→`RuntimeDatabase`、2→`LocalDatabase`、3→`FirestoreDatabase`として扱うことが可能です。

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

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

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

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

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

[Masamuneフレームワーク](https://pub.dev/packages/masamune)では特定のデータを読み込むとき`load()`メソッドを実行しますが、そのときに**参照に含まれているデータもまとめて読み込みます**。

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

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

## データベースの移行

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

[Masamuneフレームワーク](https://pub.dev/packages/masamune)ではカテゴリーを予め`RuntimeDatabase`で定義しておけば、1行の変更で完了します。

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

# 実際にやってみよう

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

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

```bash
katana create net.mathru.modeltest
```

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

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

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

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

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

```dart
// 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._();

  ~~~~
}
```

```dart
// 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._();

  ~~~~
}
```

```dart
// 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._();

  ~~~~
}
```

```dart
// 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`による自動生成を行います。

```bash
katana code generate
```

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

https://qiita.com/mathru/items/4ba93558baae7023f998

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

```dart
// main.dart

~~~~~

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

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

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

```dart
// 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`の指定を行っていきます。

```dart
// 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._();

  ~~~~
}
```

```dart
// 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._();

  ~~~~
}
```

```dart
// 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._();

  ~~~~
}
```

```dart
// 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`による自動生成を再度行います。

```bash
katana code generate
```

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

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

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

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

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

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

![](https://storage.googleapis.com/zenn-user-upload/476af7373ff0-20230729.png)

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

![](https://storage.googleapis.com/zenn-user-upload/d91f97c4919f-20230729.png)

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

![](https://storage.googleapis.com/zenn-user-upload/cf1d561694bf-20230729.png)

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

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

![](https://storage.googleapis.com/zenn-user-upload/fd07f7d05f8a-20230729.png)

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

![](https://storage.googleapis.com/zenn-user-upload/ee0cb8aab2b0-20230729.png)

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

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

![](https://storage.googleapis.com/zenn-user-upload/16149e577106-20230729.png)

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

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

```dart
// 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`に変更します。

```dart
// 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._();

  ~~~~~~
}
```

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

![](https://storage.googleapis.com/zenn-user-upload/e492db28dd15-20230729.png)

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

![](https://storage.googleapis.com/zenn-user-upload/a0b032232dc7-20230729.png)

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

![](https://storage.googleapis.com/zenn-user-upload/a8f586db40a9-20230729.png)

# おわりに

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

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

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

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

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

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

https://github.com/sponsors/mathrunet