こんにちは。広瀬マサルです。
Flutter上で動作する統合フレームワークMasamuneを随時更新しています。
今回はアプリ開発体験を大きく変えうるアップデートがあったのでその紹介をします。
masamune
はじめに
Masamuneフレームワークは「CLIによるコード生成」と「build_runnnerによるコードの自動生成」を用いて可能な限りコードの記述量を減らし、アプリ開発の安定性と高速性を高めるFlutter上のフレームワークです。
データベースに関してもFirestoreをベースにしたNoSQLデータベースを利用し、わずか1行の更新でランタイムDB
と端末ローカルDB
、Firestore
を切り替えることが可能な仕組みを提供しています。
これまでは、ModelAdapter
を切り替えることでアプリ内のすべてのDBを入れ替えるような仕組みを提供していましたが
Masamune Ver.2.3.4
から各データモデルごとにModelAdapter
を設定可能になりました。
つまり、ユーザーデータやコンテンツデータ、それに付随するデータごとにデータベースを変えることができ、さらにそれらを連携して意識することなく利用することができます。
どういったことができるかを順を追って説明していきましょう。
データの種類と置き場所
アプリで取り扱うデータはデータの置き場所によって下記の3つに分けられるはずです。
-
アプリ内で定義されたデータ
-
固定されたデータリスト
- カテゴリー
- 性別の種類
- 年齢層
- 地域
-
アプリ内の設定
- 最大投稿文字数
- お気に入りの登録上限
-
enum
やconst
でコード内に記載されることが多い
-
固定されたデータリスト
-
端末ローカルに保存されるデータ
-
自分のみで閲覧するためのデータ
- メモデータ
- スケジュール
-
アプリの状態を保存するデータ
- チュートリアルをクリアしたか
- トロフィーデータ
- 音量ボリューム
- アプリテーマ
-
端末内にファイルとして保存したり
SharedPreferences
で保存されることが多い
-
自分のみで閲覧するためのデータ
-
リモート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")
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")
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")
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")
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,
)
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,
)
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,
)
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,
)
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);
},
);
}),
],
),
);
}
}
アプリをビルドすると下記のような画面が起動します。
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.dart
のadapter
をfirestoreModelAdapter
に変更します。
// models/category.dart
~~~~~~
/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
"category",
adapter: firebaseModelAdapter,
)
class CategoryModel with _$CategoryModel {
const factory CategoryModel({
required String name,
}) = _CategoryModel;
const CategoryModel._();
~~~~~~
}
ビルドして起動してみるとこれまでと同じ様にカテゴリー名を取得できていることがわかります。
試しにFirestoreのほうにデータを追加してみましょう。
アプリを再起動するとデータが反映されていることがわかります。
おわりに
この新しい機能を使うことで、細かくデータソースを指定したコードが書けるようになります。
下記のような様々なメリットが生まれてくるでしょう。
- モックアップの作成→サーバーの実装の短縮化
- 全データを一元的に管理
- データスキームを定義することで安全にデータを指定
自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください!
また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!
また仕事の依頼等ございましたら、私のTwitterやWebサイトで直接ご連絡をお願いいたします!
GitHub Sponsors
スポンサーを随時募集してます。ご支援お待ちしております!