FCMから通知を受け取るとstate管理でつまずいた
Tips💡
FCMを使用して、Firebaseのコンソールから、全てのアプリに通知を送る機能を作る要件があった。実験で試しに作ってみた。サーバー側にロジックは作らず、Firebase Messagingから送るだけなのだが、躓いたことが多かった💦
- 📲全ての端末にデータを送ることはできた
- 🔔通知は受け取ったがflutter_hooksだけだと限界があった
- ValueNotifierのerrorが発生
- スコープの広いriverpodを使った方が良いことがわかった
error log
A ValueNotifier<int> was used after being disposed.
Once you have called dispose() on a ValueNotifier<int>, it can no longer be used.
Edit
🤖AIに聞いてみた
このエラーは、ValueNotifier(Flutterのステート管理オブジェクト)が破棄された後に使用されようとしたときに発生します。Hooksを使用する際、特にuseStateを通じて作成されたValueNotifierは、ウィジェットのライフサイクルに紐付いています。
この問題を解決するには、以下のアプローチを検討してください:
- 状態管理の方法を変更する:
useStateの代わりに、より広いスコープを持つ状態管理ソリューション(例:Riverpod)を使用することを検討します。 - 非同期操作の取り扱いを改善する:
非同期操作(特にFirebase Messagingのリスナー)が、ウィジェットが破棄された後も実行されている可能性があります。
改善策
後ほど、riverpod v-2のNotifier
+ freezed
で状態の管理をおこなうロジックを作成します。
ローカルDBで、sqfliteを使っているので、こちらのロジックとモデルを先に作っておきます。
nullの値が入ってこともあるので、?つけて、null許容したメンバ変数を定義します。
/// ローカルに、push通知を保存する state class
class NotificationState {
int? id;
String? title;
String? body;
NotificationState({this.id, this.title, this.body});
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'body': body,
};
}
factory NotificationState.fromJson(Map<String, dynamic> map) {
return NotificationState(
id: map['id'],
title: map['title'],
body: map['body'],
);
}
}
実験用なので、使ってないメソッドもあります。
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
// データベースの操作を行うクラス
class DatabaseHelper {
// データベースのバージョン
static const int _databaseVersion = 1;
// データベースの名前
static const String _databaseName = 'notification.db';
// データベースのインスタンスを取得
Future<Database> _getDB() async {
return openDatabase(
join(await getDatabasesPath(), _databaseName), // データベースのパスを指定
version: _databaseVersion, // データベースのバージョンを指定
onCreate: (db, version) async {
// データベースにテーブルを作成。複数形の名前をつけるルールがあるようだ
await db.execute(
'CREATE TABLE notifications(id INTEGER PRIMARY KEY, title TEXT, body TEXT)',
);
},
);
}
// insert notification
Future<int> addNotification(NotificationState notificationState) async {
final db = await _getDB(); // データベースのインスタンスを取得
return db.insert(
'notifications', // テーブル名
notificationState.toJson(), // JSON形式に変換
conflictAlgorithm: ConflictAlgorithm.replace, // データが重複した場合は置き換える
);
}
// remove notification
Future<int> deleteNotification(int id) async {
final db = await _getDB();
return db.delete(
'notifications',
where: 'id = ?',
whereArgs: [id],
);
}
// すべてのTodoを取得
Future<List<NotificationState>> getNotification() async {
final db = await _getDB();
final List<Map<String, dynamic>> maps = await db.query('notifications');
return List.generate(maps.length, (i) {
return NotificationState(
id: maps[i]['id'],
title: maps[i]['title'],
);
});
}
// Streamでリアルタイムにデータを取得
Stream<List<NotificationState>> watchNotification() async* {
final db = await _getDB();
yield* db.query('notifications').asStream().map(
(event) => event.map((e) => NotificationState.fromJson(e)).toList());
}
}
FCMから取得したtitle
, body
は、Future, Streamを使わずに、riverpodのNotifierに格納して、状態を管理します。値自体は、モデルクラスが保持してます。
値が、nullなら空のリストを返し、値があればモデルで保持している title, bodyを返して、UIに表示します。もしデータが送信されてきたり、削除ボタンを押すと、UIのStateが更新されて、画面から消えます。
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
part 'notification_notifier.g.dart';
class NotificationNotifier extends _$NotificationNotifier {
List<NotificationState> build() {
return [];
}
void setNotifications(List<NotificationState> notifications) {
state = notifications;
}
void addNotification(NotificationState notification) {
state = [notification, ...state];
}
void removeNotification(int index) {
state = List.from(state)..removeAt(index);
}
}
UI側は、flutter_hooks
だけで以前やっていたのですが、いい感じでできなかったので、 hooks_riverpod
を組み合わせて、UI Stateの管理と、FirebaseMessaging.onMessage.listen
を使って、リアルタイムに通知を取得できるようにしています。
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:workstream_prod/application/local/data_base_helper.dart';
import 'package:workstream_prod/application/usecase/notification/notification_notifier.dart';
import 'package:workstream_prod/core/logger/logger.dart';
import 'package:workstream_prod/core/theme/app_color.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
class NotificationPage extends HookConsumerWidget {
const NotificationPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(notificationNotifierProvider);
final isLoading = useState(true);
useEffect(() {
// 初回のデータ取得
DatabaseHelper().getNotification().then((data) {
ref.read(notificationNotifierProvider.notifier).setNotifications(data);
isLoading.value = false;
});
// FCMメッセージのリスナー設定
final subscription =
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
logger.d('Got a message whilst in the foreground!');
logger.d('Message data: ${message.data}');
if (message.notification != null) {
logger.d('FCM message🔔: ${message.notification}');
final entity = NotificationState(
title: message.notification?.title,
body: message.notification?.body,
);
DatabaseHelper().addNotification(entity).then((_) {
ref
.read(notificationNotifierProvider.notifier)
.addNotification(entity);
});
}
});
return () {
subscription.cancel(); // Clean up
};
}, const []);
return Scaffold(
appBar: AppBar(
title: Text('通知'),
),
body: isLoading.value
? const Center(child: CircularProgressIndicator())
: notifications.isEmpty
? const Center(child: Text('通知は現在ありません'))
: ListView.builder(
itemCount: notifications.length,
itemBuilder: (context, index) {
final notification = notifications[index];
return Padding(
padding: const EdgeInsets.only(
left: 30, right: 30, top: 10, bottom: 10),
child: Container(
width: double.infinity,
height: 100,
// 外枠に薄いグレー
decoration: BoxDecoration(
border: Border.all(
color: AppColor.lightGrey,
),
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
DatabaseHelper()
.deleteNotification(notification.id ?? 0)
.then((_) {
ref
.read(notificationNotifierProvider.notifier)
.removeNotification(index);
});
},
),
title: Text(notification.title ?? ''),
subtitle: Text(notification.body ?? ''),
),
),
);
},
),
);
}
}
まとめ
通知機能をローカルに保存して、新しい通知が来たとき表示、削除したらUIから消えるロジックを作るには、Stream使うより、NotifierでUI Stateを管理した方が楽にはできました。StatefulWidgetを使っていたときは、割と簡単な方でしたが、何度も画面が更新されてアプリのパフォーマンスに影響しそうなので、flutter_hooks
+ hooks_riverpod
を使って実装しました。
Discussion