はじめに
この記事はWebフロントエンジニアが、FlutterでもSignalを使ってアプリ開発してみたら最高だった話の続きで、コード実装編になります。
実装に入る前の開発背景なども知りたい方は是非リンク元も見てみてください😇
ディレクトリ構成
構成は以下のようにしました。
- constants (定数)
- models (モデル定義)
- routes (各ページ)
- services (モデルと1:1で結びついた処理、主に通信周り)
- states (状態管理) // ⭐️
- utils (状態伴わない汎用関数)
- widgets // ⭐️
- common (共通Widget)
- features (各画面ごとに必要なWidget)
- home
- setting
...
アプリ開発における一般的な構成だと思います。
widgetsはwebで言うcomponentsのことですが、個人開発でそんなにキッチリ分ける必要がなかったのでcommon/featureパターンでざっくり分けています。
そしてSignalはこの「widgets」と「states」にのみ存在します。
具体的な実装の前にやりたいことの説明
説明していなかったのですが、
残念ながらFlutterにおける現状のSignalの認知度は限りなく低いです。
どれくらい低いかと言うと、「Flutter signal」で検索しても日本語記事が3つくらいしかありません。
そのどれもがAPIの使い方の説明であり、「じゃあどうやって実際に設計して使っていくの?」まで言及した記事はありません。
そのため私自身開発するにあたり、まずはSignalを使ったデータの受け渡し方を設計していくところから入りました。
幸いなことにSignalは特定の環境に依存しない、リアクティブプログラミング共通の概念でもあるので、
提供されているAPIやデータ更新/検知の流れはWebでもアプリでも変わりありません。(パッケージ自体Preactを参考に作られています)
そのためSignal系のフロント開発で一番実績があり、自身も慣れている
Vue3のCompositionAPIをベースに考えるのは一つの最適解だと考えました。
ちなみにVue.jsだと、公式ドキュメントにも記載のある以下が基本的な状態管理の書き方です。
import { ref } from 'vue'
// モジュールスコープで生成されたグローバルな状態
const globalCount = ref(1) // ①
export function useCount() {
// コンポーネント毎に生成されたローカルな状態
const localCount = ref(1) // ②
return {
globalCount,
localCount
}
}
Vueではuse〇〇で始まる、これら状態を含んだ処理をcomposableと言い、他のcomposable内やcomponent内で使用できます。また状態はcomponent内でも定義できます。
<script setup>
const { globalCount, localCount } = useCount();
const count = ref(1) // ③
</script>
ここでのゴールはこれをDart言語で再現することです。
実装する
上で見て分かるように、Vue3のcompositionAPIでは状態のスコープは主に3つあります。
①グローバルステート
②ローカルステート (in composable)
③ローカルステート (in component)
①は分かりやすいですね。
アプリケーション全体を通して1つの状態をグローバルで共有するために使います。
②と③の違いは分かりづらいかもしれませんが、
③はそのコンポーネント内でのみ定義されて使われるのに対して、
②は他のコンポーネントやコンポーザブル内にも流用したい(ただしスコープは呼び出し先で閉じたい)場合に使われます。
まず分かりやすい①から実装しましょう!
// 定義元のコード
import 'package:signals/signals_flutter.dart';
final Signal<int> globalCount = signal(0); // グローバルな状態
・・・はい、以上です。
これだけでグローバルに存在する状態が作成できました。
以下のように使用します。
// 呼び出し先のコード
import '/src/states/count.dart';
print(globalCount.value);
え・・これだけで動くの?って感じですが、はい、これでグローバルに状態を共有した状態になります。
(語弊はありますが)riverpodの役目をたった一行で果たしてくれる訳ですね。
では次に②を考えてみましょう。
Vueだと関数の中に状態を入れるのですが、Dartではそれはしません。
何故ならDartは分割代入が出来ないからです😭
つまり以下のような書き方はサポートされていません。
final { globalCount, localCount } = useCount();
出来るとしてもstate['localCount']
みたいな感じです。
これではコード補完も効きませんし、ただただキツいの一言。
色々と探した結果、パターンマッチングという手法があることを知りました。
クラスのパラメータとして返すことで、呼び出し先で擬似的に分割代入ぽい書き方ができるようになります。
具体的には以下のような形です。
// 定義元のコード
import 'package:signals/signals_flutter.dart';
// カウントクラス
class Count {
final Signal<int> localCount = signal(0); // ローカルな状態
}
// 呼び出し先のコード
import '/src/states/count.dart';
final Count(:localCount) = Count();
これでlocalCount
は補完も型も効くようになりますし、
ここで呼び出されたlocalCount
のスコープはその呼び出された場所で閉じられるようになります。
さて①と②の動きまでは確認できたのですが、
どこからでも呼ばれることを考えるとグローバル汚染には気をつけたいですし、
①と②の呼び出し方も統一したいですね。
以下のように修正しましょう。
import 'package:signals/signals_flutter.dart';
final Signal<int> _globalCount = signal(0);
// カウント管理クラス
class Count {
Signal<int> get globalCount => _globalCount; // グローバルな状態
final Signal<int> localCount = signal(0); // ローカルな状態
Count._internal();
}
Count useCount() {
return Count._internal();
}
はい、上記が私が思う最善の基本形です。
まずglobalCount
の方は変数定義に_(アンダースコア)を付けます。
Dartの言語仕様で_を付けると、そのファイル外からのアクセスができない、つまりprivateになります。
その上でCountクラスに_globalCount
を返すゲッターglobalCount
を定義します。
これで_globalCount
は、実質グローバルではあるけどCountクラスのフィールドになりパターンマッチングで拾えるようになります。
もし_globalCount
を完全にそのファイル内だけでしか操作させたくない場合は、以下のようにReadOnlySignal
にしてaddGlobalCount()
のような関数をクラス内部で書けば良いですし、
ReadOnlySignal<int> get globalCount => _globalCount;
addGlobalCount() {
_globalCount.value++;
}
加工して返したければ以下のようにComputedSignal
を使うなど、かなり自由度が高く書けます。
ComputedSignal<int> get doubleGlobalCount => computed(() => _globalCount.value * 2);
ちなみにクラス内に書かれた最後のCount._internal();
は、クラス外部からのインスタンス化を制限するために使われます。
このようにすることで、Countクラスのインスタンスを直接作成できなくなり、インスタンスの作成方法をクラス内部に制限することができます。
つまり唯一useCount()
関数を通してのみ、この状態クラスを扱えるようになります。
use〇〇という関数名を必ずしも命名しないといけないわけではないのですが、
慣習的に「あ、状態を持っているんだな」と差別化できて分かりやすいのでそうしています。
また設計時Singletonパターンもどうかと考えましたが、
それだとグローバルな状態とローカルな状態の両立は難しく、またVueのcompositionAPIの動きからもかけ離れているのでペケです。
ではこの状態クラスを実際のwidgetで使っていきましょう。
vsCodeで「stls」と書けば、以下のようなStatelessWidgetのテンプレがまず作れると思います。
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
今回用にちょっと整えます。
class CountWidget extends StatelessWidget {
CountWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Text('GlobalCount is '),
Text('LocalCount is '),
],
),
ElevatedButton(
onPressed: () {},
child: Text('カウントアップ'),
)
],
);
}
}
そして肝心の状態を読み込みます。
class CountWidget extends StatelessWidget {
final count = useCount(); // ①
CountWidget({super.key});
@override
Widget build(BuildContext context) {
final Count(:globalCount, :localCount) = count; // ②
return Column(
children: [
Watch( // ③
(context) => Row(
children: [
Text('GlobalCount is ${globalCount.value}'),
Text('LocalCount is ${globalCount.value}'),
],
),
),
ElevatedButton(
onPressed: () {
localCount.value++;
globalCount.value++;
},
child: Text('カウントアップ'),
)
],
);
}
}
どうですか? Signal系フレームワークを触ったことのあるフロントエンジニアならかなり直感的に分かる内容ではないでしょうか?
①まずWidget内で状態クラスをインスタンス化します。
②buildメソッド内で必要な変数、関数をパターンマッチングで拾ってきます。
③テンプレート内でSignalを使いたい箇所をWatchで囲います。
これでボタンを押した時、カウントアップされている様子が確認できます。
また状態を保持しているのはWidget自身ではなく状態管理クラスになるので、この場合はStatelessWidget
を使うべきと私は判断しています。
後ほどStatefulWidget
での使用例を出しますが、
setState()
との大きな違いは、再レンダリングの対象がbuildメソッド全体ではなくWatch()
内に限定されるということです。
例えばボタンの文章をText('カウントアップ${globalCount.value}')
などとしても、Watch外なので変化が起きません。
今回のアプリ開発でsetState()によるbuild単位での更新は一度も使用する機会がありませんでした。
ちなみにVueだとWatch
すら書かずに、テンプレート内は必要な要素だけ自動で再レンダリングしてくれます。
Vueがパフォーマンス面で優れているのもそういったことが理由です。
では③状態をWidget内で定義する場合はどうでしょうか?
この場合は状態はWidgetが持っていると見做されるのでStatefulWidget
を使います。
先ほどと同じようにvsCodeで「stfl」と打つとStatefulWidgetのテンプレが出てきます。
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
今回用に整えます。
class CountWidget extends StatefulWidget {
const CountWidget({super.key});
@override
State<CountWidget> createState() => _CountWidgetState();
}
class _CountWidgetState extends State<CountWidget> {
@override
Widget build(BuildContext context) {
return Column(
children: [
Watch(
(context) => Row(
children: [
Text('GlobalCount is '),
Text('LocalCount is '),
Text('WidgetCount is '),
],
),
),
ElevatedButton(
onPressed: () {},
child: Text('カウントアップ'),
)
],
);
}
}
状態を作成します。
class CountWidget extends StatefulWidget {
const CountWidget({super.key});
@override
State<CountWidget> createState() => _CountWidgetState();
}
class _CountWidgetState extends State<CountWidget> with SignalsAutoDisposeMixin { // ①
final count = useCount();
late final widgetCount = createSignal(context, 0); // ②
@override
Widget build(BuildContext context) {
final Count(:globalCount, :localCount) = count;
return Column(
children: [
Watch(
(context) => Row(
children: [
Text('GlobalCount is ${globalCount.value}'),
Text('LocalCount is ${localCount.value}'),
Text('WidgetCount is ${widgetCount.value}'),
],
),
),
ElevatedButton(
onPressed: () {
localCount.value++;
globalCount.value++;
widgetCount.value++;
},
child: Text('カウントアップ'),
)
],
);
}
}
はい、先ほどとの違いはSignalsAutoDisposeMixin
を使っていることと、状態をcreateSignal
で作成していることです。
SignalsAutoDisposeMixin
は無くても機能するのですが、
指定しておくとWidgetの破棄のタイミングでWidget内で定義されたSignalが自動でdisposed()してくれます。
ちなみにクラス内で一度final count = useCount();
でインスタンス化せずに、
いきなりbuildメソッド内でfinal Count(:globalCount, :localCount) = useCount();
とした方がWebの書き方に近いのですが、
buildメソッドは色んなタイミングで発生するので無駄にインスタンス化される可能性があるのと、
作成した状態クラスはinitState()
などbuildメソッド以外でも使われるので、初期化は一度だけ、あとはパターンマッチングで適宜拾うという形がパフォーマンス的にも良いと思われます。
とりあえずこれでSignalを使った状態クラスの作り方と、Widget内での使い方の説明は以上です。
次にModel層やService層を考慮した実践例を出します。
チャットに必要な設計を考えてみる
アプリ開発時のコードをかなり端折って解説します。(実際の処理内容とは異なります)
まずmodels/chat.dart
を用意して以下のようにモデル定義を行います。
import 'package:cloud_firestore/cloud_firestore.dart';
class ChatData {
final String uid;
final String name;
final String message;
final DateTime createdAt;
final DateTime updatedAt;
ChatData({
required this.uid,
required this.name,
required this.message,
required this.createdAt,
required this.updatedAt,
});
factory ChatData.fromFirestore(DocumentSnapshot doc) {
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
return ChatData(
uid: data['uid'],
name: data['name'],
message: data['message'],
createdAt: (data['createdAt'] as Timestamp).toDate(),
updatedAt: (data['updatedAt'] as Timestamp).toDate(),
);
}
Map<String, dynamic> toFirestore() {
return {
'uid': uid,
'name': name,
'message': message,
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
};
}
@override
String toString() {
return 'ChatData(uid: $uid, name: $name, message: $message, createdAt: $createdAt), updatedAt: $updatedAt)';
}
}
特に解説することはありませんが、firebaseに入れるデータ構造の定義と、それを入れたり出したりする際の変換用関数(fromFirestore、toFirestore
)をここでは定義しました。
次にservices/chat.dart
を用意して、このモデルを扱うためのコードを書いていきます。
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
class ChatService {
// 参照先
final chatCollection = FirebaseFirestore.instance.collection('chats');
// チャット情報を取得
Future<List<ChatData>> fetchChatList({
int? num,
}) async {
try {
// 初期化
num ??= 10;
final querySnapshot = await chatCollection.orderBy('createdAt', descending: true).limit(num).get();
// チャット情報が存在しなければ空配列で返す
if (querySnapshot.docs.isEmpty) return [];
// チャット型に整形して返す
return querySnapshot.docs.map((log) => ChatData.fromFirestore(log)).toList();
} catch (onError) {
Log.e('Error: $onError');
return [];
}
}
// チャット情報を登録
Future<bool> postChat({
required String uid,
required String name,
required String message,
}) async {
try {
// チャット情報
final chatData = ChatData(
uid: uid,
name: name,
message: message,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
).toFirestore();
final chatRef = chatCollection.doc();
await chatRef.set(chatData);
return true;
} catch (onError) {
Log.e('Error: $onError');
return false;
}
}
ここではチャットコレクションへアクセスして情報を取ってきたり、データ挿入のためのコードを書いています。
重要なのは各Serviceクラスは対象となるモデルにのみ関心を持ち、service間で依存し合うような実装や、それより上位層となるstatesの読み込みは避けるべきということです
理由はそうすることで、依存関係を考えずにシンプルに実装できるからです。
そしてこれらは、これから解説するstatesなどの上位層で使用します。
それではstates/chatManager.dart
を用意して、いよいよ状態管理を行うためのコードを見ていきましょう。
import 'dart:async';
import 'package:signals/signals_flutter.dart';
import 'src/models/chat.dart';
import 'src/services/chat.dart';
import 'src/states/application_setting.dart';
import 'src/states/error_manager.dart';
import 'src/states/system_manager.dart';
import 'src/states/user_manager.dart';
import 'src/utils/log.dart';
final _applicationSetting = useApplicationSetting();
final _userManager = useUserManager();
final _errorManager = useErrorManager();
final _systemManager = useSystemManager();
// グローバルState
final Signal<List<ChatData>> _chatList = signal([]);
// チャットの状態管理クラス
class ChatManager {
// チャット一覧
Computed<List<ChatData>> get chatList => computed(() {
return [..._chatList.value]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
});
// チャットを取得
Future<void> getChatList({
required int num, // 取得数
required bool isOverride, // 上書きかどうか
}) async {
try {
final ApplicationSetting(:blockList) = _applicationSetting;
final UserManager(:user, :getUser) = _userManager;
final SystemManager(:isLocalMode, :isMaintenance) = _systemManager;
// 通信できない状態ならreturn
if (isLocalMode.value || isMaintenance.value) return;
// ユーザ情報取得前であれば先に取得する
if (user.value == null) await getUser();
var result = await ChatService().fetchChatList(num: num);
// ブロック中のユーザの書き込みであれば除外
result = result.where((chat) => !blockList.value.contains(chat.uid)).toList();
_chatList.value = isOverride ? result : [..._chatList.value, ...result];
} catch (onError) {
Log.e('Error: $onError');
}
}
// チャットを送信
Future<bool> sendChat({required String message}) async {
try {
final ApplicationSetting(:nickname) = _applicationSetting;
final UserManager(:user, :getUser) = _userManager;
final SystemManager(:isLocalMode, :isMaintenance) = _systemManager;
// 通信できない状態ならreturn
if (isLocalMode.value || isMaintenance.value) return false;
// ユーザ情報取得前であれば先に取得する
if (user.value == null) await getUser();
// チャット送信
final isSuccess = await ChatService().postChat(
uid: user.value.uid,
name: '${nickname.value}',
message: message,
);
if (!isSuccess) throw Exception('チャットの送信に失敗しました。');
await getChatList(num: 10, isOverride: true); // チャット再取得
return true;
} catch (onError) {
final ErrorManager(:showErrorDialog) = _errorManager;
showErrorDialog(onError.toString());
Log.e('Error: $onError');
return false;
}
}
ChatManager._internal();
}
ChatManager useChatManager() {
return ChatManager._internal();
}
はい、ちょっとごちゃごちゃしていますが、状態クラスの基本形は先ほど説明したのと同じ構成です。
違いがあるとするならば、実際は1つの状態管理クラスの中で、別の様々な状態管理クラスを呼んでいるということです。
例えばuseApplicationSetting()
では、
sharedPreferences(Webで言うlocalStorage)に保管されている内容をSignalとして取り出せるようにしているため、そこからブロックしているユーザ情報を引っ張ってきてチャットをフィルタしたり、useSystemManager()
ではメンテナンス状態や端末の通信状態を保管しているので、それによって通信しないようにしたり、といった制御を加えています。
これはVue3開発でも同様で、色んなところで定義した状態を必要に応じて摘んで組み立てていくことから、compositionAPIと命名されているのだと思います。
ここでSignalの挙動に関しての補足ですが、
Signalは更新があると、そのSignalに依存している他の対象が自動的に再評価されます。
ただし再評価が行われるのは、実際に値に更新があった時ではなく正確にはそれが読み込まれた時です。
例えば先ほどの例のようにbuildメソッドのWatch内で、Signalの値をUIで使用していれば、これは常に読み込んでいると言えるので値が更新されればそのままUIが切り替わります。
一方でbuildメソッドのWatch外でSignalの値を使用していても、
実際に値の更新が検知されるのは次にbuildメソッドが走ったタイミングになります。
このように様々なstateが複雑に絡み合った状態にあっても、
それが現在のコンテキストにおいて監視されていない限りは無駄に呼び出され、再計算コストが掛かるわけではないので安心して使えます。
ただし1つの状態管理クラスに何でもかんでも入れると確実に管理が大変になるので、
状態管理クラスは極力その責務を明確に分けるなど、設計力はある意味問われるところだと思います😎
さてここまでで一通りの解説を終えました。
最後に少しばかりTipsを紹介していきます。
Tips
Signalの値が変わるたびに勝手にログが出るのが鬱陶しい
デフォルトでprintされるようになっています、main.dartで以下のように書いておきましょう。
// Signalの自動追跡をオフに
SignalsObserver.instance = null;
ちなみにパフォーマンスチューニング時にまたコメントアウトを解除して、無駄な計算が発生していないか見るのがおすすめです。
今回のアプリ開発でタイマー機能もSignalで管理していたのですが、1秒おきに無駄にComputedが走っていたりなど、このログのおかげでいくつか改善できました。
sharedPreferencesとSignalを連携したい
まず以下の2つのファイルを作成します。
[states/shared_preferences.dart]
import 'package:shared_preferences/shared_preferences.dart';
// sharedPreference用キー情報
class SharedPreferencesKey {
static const blockList = 'blockList';
}
// SharedPreference用のシングルトンクラス
class SharedPreference {
static final SharedPreference _instance = SharedPreference._internal();
SharedPreferences? _prefs;
factory SharedPreference() {
return _instance;
}
SharedPreference._internal();
Future<void> initPrefs() async {
_prefs ??= await SharedPreferences.getInstance();
}
SharedPreferences? get prefs => _prefs;
}
SharedPreference useSharedPreference() {
return SharedPreference();
}
[states/application_setting.dart]
import 'package:signals/signals_flutter.dart';
import 'package:until_moss_grows/src/states/shared_preference.dart';
// SharedPreferencesのインスタンス
final _sharedPreference = useSharedPreference();
// ブロック設定
final Signal<List<String>> _blockList = signal([])
..subscribe((value) {
_sharedPreference.prefs?.setStringList(SharedPreferencesKey.blockList, value);
});
// 設定用クラス
class ApplicationSetting {
// ブロックリスト
Signal<List<String>> get blockList => _blockList;
// 初期化
void initApplicationData() async {
if (_sharedPreference.prefs == null) return;
_blockList.value = _sharedPreference.prefs!.getStringList(SharedPreferencesKey.blockList) ?? [];
}
ApplicationSetting._internal();
}
ApplicationSetting useApplicationSetting() {
return ApplicationSetting._internal();
}
SharedPreferencesへアクセスするためのインスタンス自体はSingletonで扱われている記事が多かったのでそうしています。
shared_preferences.dartは一度作成したら、SharedPreferencesKeyに追加していくこと以外更新することはありません。
初期化方法はmain.dart内でrunApp(MyApp())
となっていたら以下のような感じです。
class MyApp extends StatefulWidget {
const UntilMossGrows({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final sharedPreference = useSharedPreference();
final applicationSetting = useApplicationSetting();
@override
void initState() {
super.initState();
final SharedPreference(:initPrefs) = sharedPreference;
final ApplicationSetting(:initApplicationData) = applicationSetting;
// 初期化
initPrefs().then((value) {
initApplicationData();
});
}
これで起動時、sharedPreferecesに保管された情報を状態として読み込んでいるので、以降はどのファイルにいても、useApplicationSetting()から取得できるようになります。
また..subscribe()
により、状態を更新したらそのまま自動でsharedPreferencesへ保管されるようになります。
これはVueUseで言うuseLocalStorage()
みたいな感じです。
デバイスサイズをSignalで管理したい
個人的にはかなり必須だと思うので以下をそのままご使用ください。
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
// グローバルState
final Signal<double> _width = signal(0);
final Signal<double> _height = signal(0);
final Signal<double> _keybordHeight = signal(0);
final Signal<EdgeInsets> _safePadding = signal(EdgeInsets.zero);
// デバイスサイズ監視クラス
class DeviceSize {
// 横幅
ReadonlySignal<double> get width => _width;
// 縦幅
ReadonlySignal<double> get height => _height;
// キーボード高さ
ReadonlySignal<double> get keybordHeight => _keybordHeight;
// 余白込みの幅取得
double withSafeSize({double? top, double? left, double? right, double? bottom}) {
if (top != null) return top + _safePadding.value.top;
if (left != null) return left + _safePadding.value.left;
if (right != null) return right + _safePadding.value.right;
if (bottom != null) return bottom + _safePadding.value.bottom;
return 0;
}
// 初期化
void setDeviceSize(BuildContext context) {
_width.value = MediaQuery.of(context).size.width;
_height.value = MediaQuery.of(context).size.height;
_safePadding.value = MediaQuery.of(context).padding;
_keybordHeight.value = MediaQuery.of(context).viewInsets.bottom;
}
DeviceSize._internal();
}
DeviceSize useDeviceSize() {
return DeviceSize._internal();
}
初期化方法は先ほどのmain.dartのbuildメソッド内に以下を追加するだけです。
@override
Widget build(BuildContext context) {
final DeviceSize(:setDeviceSize) = deviceSize;
setDeviceSize(context);
return MaterialApp(...
あとはどこからでも、例えそれがbuildCountext外であっても、
final DeviceSize(:width, :withSafeSize) = useDeviceSize();
のように呼び出せます。以上!
最後に
いかがでしたか?
私自身アプリ開発の経験はこれが初めてだったのでアプリのリリースまでは時間が掛かったのですが、
Signalのお陰でCompositionAPIをDartで扱っているような感覚で終始開発が出来ていました😇