通知をsqfliteに保存したがUI Stateも必要なので困った
🤔Think
sqflite
を使用して、FCMからローカルDBに通知の記録を保存する機能を作った。しかし、ボトムナビゲーションバーと通知のリストを表示するページ両方で、Notifierを使うと、通知のリストに2個通知のリストが作られた。えっなんで💦
通知のページでは、ローカルDBのデータを表示してるというよりもFreezedに保持した値を保存してました。これが良くなかった💦
riverpodは、グローバル State ってやつなのか、他の場所にもstateの変更が影響してしまう。Badgeのカウントだけ数えてくれれば良いとわかったので、通知のページは、ローカルDBのデータを表示することにした。
UI Stateの管理してるNotifier
はこんな感じですね。List操作するだけ。SwiftUIでも同じでしたけど、Cloud Firestoreのデータ消してもUIに表示されてるのは、リストの値なので、リストも操作して、削除する必要があったりします。
モデルを作ります。これは、sqflite用のもの
/// ローカルに、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'],
);
}
}
これが今回問題になってたNotifier
です。通知のカウントと削除でしか使わなくなった。UIの状態を変更する役割を担ってくれます。
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);
}
}
GoRouter使ってるので、こんな感じで、ボトムナビゲーションバーは書きます。
// Flutter imports:
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
// Package imports:
import 'package:go_router/go_router.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/domain/notification/notification_state.dart';
class ScaffoldWithNavBar extends HookConsumerWidget {
const ScaffoldWithNavBar({
required this.navigationShell,
Key? key,
}) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));
final StatefulNavigationShell navigationShell;
Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(notificationNotifierProvider);
final isLoading = useState(true);
useEffect(() {
// 初回のデータ取得
ref.read(databaseHelperProvider).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,
);
ref.read(databaseHelperProvider).addNotification(entity).then((_) {
ref
.read(notificationNotifierProvider.notifier)
.addNotification(entity);
});
}
});
return () {
subscription.cancel(); // Clean up
};
}, const []);
return Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'ホーム'),
BottomNavigationBarItem(icon: Icon(Icons.task_alt), label: 'タスクと管理'),
/// BadgeでWrapして、通知のカウントを表示
BottomNavigationBarItem(
icon: Badge.count(
count: notifications.length,
backgroundColor: Colors.red,
child: Icon(Icons.notifications)),
label: '通知'),
],
currentIndex: navigationShell.currentIndex,
onTap: (index) => _onTap(context, index),
),
);
}
void _onTap(BuildContext context, int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
}
しかし、うまくいかない???
StreamBuilderだと限界あるのか???
すいません。書いてるコードは、自由な現場なので、決まりはありません笑
私は、flutter_hooks
+ hooks_riverpod
で開発するのを推奨してます。
なんでもRiverpod使えばいいだろうって考えなので、StreamNotifierを使用して表示することにしたが、削除ボタン押したら、ref.invalidateを実行してもstateの更新がされないのか画面が切り替わらなかった???
対応策としては
私は、レイヤーを分けてますけど、これは真似しなくてよくて、DatabaseHelperなるクラスをStreamNotifierで呼び出せば問題ないです。
分けたほうがいいのは、もしObjectBoxに変えたいとか、要望が出てきたら、変更が必要なので、インターフェースを作ってあげました。抽象クラスと言ったほうが良い。
他のクラスで、newしたクラス。外で、インスタンス化したクラスをコンストラクタ引数に渡すDI(依存性の注入)なるものをやってます。
Riverpodを使うと、Refを使うから、勝手にDIしてくれてると思います。
このクラスの中にあるStreamを使います。
import 'package:path/path.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
part 'data_base_helper.g.dart';
(keepAlive: true)
DatabaseHelper databaseHelper(DatabaseHelperRef rer) {
return DatabaseHelper();
}
// データベースの操作を行うクラス
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],
);
}
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());
}
}
ロジックは、サービスクラスに書いたりしますね。DB操作するなら、DTOなるフォルダに配置したりしますが。これを抽象クラスで参照します。このクラスを作っておくと、別のローカルDBを使った時に、Isar, ObjectBox, sembast, Driftなどに取り替えることができます。保守しやすいコードにするならこれがいいのかな。
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:workstream_prod/application/local/data_base_helper.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
part 'notification_repository.g.dart';
(keepAlive: true)
NotificationRepository notificationRepository(NotificationRepositoryRef ref) {
return NotificationRepositoryImpl(ref);
}
abstract interface class NotificationRepository {
Stream<List<NotificationState>> watchNotification();
}
class NotificationRepositoryImpl implements NotificationRepository {
Ref ref;
NotificationRepositoryImpl(this.ref);
Stream<List<NotificationState>> watchNotification() async* {
yield* ref.read(databaseHelperProvider).watchNotification();
}
}
application層に作成したStreamNotifierで、sqfliteを呼び出してUIに表示します。このクラスは、複雑な状態管理をするのに向いているそうです。単純に表示するだけなら、StreamProviderで十分ですね。
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:workstream_prod/domain/notification/notification_state.dart';
import 'package:workstream_prod/domain/repository/notification/notification_repository.dart';
part 'notification_stream_notifier.g.dart';
class NotificationStreamNotifier extends _$NotificationStreamNotifier {
Stream<List<NotificationState>> build() async* {
yield* ref.read(notificationRepositoryProvider).watchNotification();
}
Stream<List<NotificationState>> watchNotification() async* {
yield* ref.read(notificationRepositoryProvider).watchNotification();
}
}
こちらが問題の通知のアイコンをタップしたら表示されるページなんですけど、UI Stateの管理をNotifierだけでやろうとすると、他のページにも影響が出ました。このページでしか管理しなくても良い状態もあるので、flutter_hooks
の useEffect
を使うことで、FCMの通知を探知して、UIに状態の変更を反映させるロジックを実装できました。
最初は、 ref.listenも試してみたが、FirebaseMessaging.onMessage.listen
はここで、購読するものではないようです。Widgetのコードしか書かないのかな。
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/application/usecase/notification/notification_stream_notifier.dart';
import 'package:workstream_prod/core/theme/app_color.dart';
import 'package:workstream_prod/presentation/component/indicator_component.dart';
import 'dart:async';
// 遅延処理のロジック
class Debounce {
final Duration delay;
Timer? _timer;
Debounce({required this.delay});
void run(void Function() action) {
_timer?.cancel();
_timer = Timer(delay, action);
}
void cancel() {
_timer?.cancel();
}
}
class NotificationPage extends HookConsumerWidget {
const NotificationPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final notificationStream = ref.watch(notificationStreamNotifierProvider);
final bouncer = Debounce(delay: const Duration(milliseconds: 300));
useEffect(() {
final subscription = FirebaseMessaging.onMessage.listen((event) {
if (event.notification != null) {
bouncer.run(() {
// invalidateは頻繁に使うとパフォーマンスに影響があるので、遅延処理を入れる
ref.invalidate(notificationStreamNotifierProvider);
});
}
});
return () {
subscription.cancel();
bouncer.cancel();
};
}, []);
return Scaffold(
appBar: AppBar(
title: Text('通知'),
),
body: switch (notificationStream) {
AsyncData(:final value) => ListView.builder(
itemCount: value.length,
itemBuilder: (context, index) {
final notification = value[index];
return Padding(
padding: const EdgeInsets.only(
left: 30, right: 30, top: 10, bottom: 10),
child: Container(
width: double.infinity,
height: 100 * 1.5,
// 外枠に薄いグレー
decoration: BoxDecoration(
border: Border.all(
color: AppColor.lightGrey,
),
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
await ref
.read(databaseHelperProvider)
.deleteNotification(notification.id ?? 0);
/// [通知を削除したら、stateの通知記録を削除]
ref
.read(notificationNotifierProvider.notifier)
.removeNotification(index);
/// [通知を削除したら、通知一覧を再取得する]
ref.invalidate(notificationStreamNotifierProvider);
},
),
title: Text(notification.title ?? ''),
subtitle: Text(notification.body ?? ''),
),
),
);
},
),
AsyncError(:final error) => Text('Error: $error'),
_ => IndicatorComponent(),
},
);
}
}
まとめ
Riverpodのプロバイダーだけで解決できるかなと思ったが、ページ内だけで完結したい処理もあったので、flutter_hooks
なら、useState()
, useEffect()
を使い、やStatefulWidget
なら、initState()
, setState()
, も使う必要があるなと考えさせれらましたね。
まだまだ課題は残っているのですが、今回は、FlutterのライフサイクルとFCMの仕組みを理解していないと、機能実装で躓くっことが多かったですね。
Riverpodを使えばいいだろうって、空気を最近は感じます。私は、未経験でエンジニアになった頃、StatefulWidgetすらわかってない状態で、Riverpodを使うのを強制されました😨
StatefulWidgetを使うのも嫌がられる。ダメってわけではないんだぞ。「お前らよりレベル高い自社開発は実は使ってた笑」
確かに使えばいいけど、Dartのロジックやページが呼ばれたどのタイミングで、処理を実行するか考えないと、バグったりするので、初心者の人は、Dartの文法やFlutterのライフサイクルについて勉強して欲しいですね。
私は、どうやってそんな知識を得たかというと、フィットネスアプリを作るときに、すごくFlutterに詳しい人から、UIの状態の管理についての考え方を教わってからですね。
もしhooks_riverpod
を使えないプロジェクトの場合は、ConsumerStatefulWidgetを使っていると思うので、このページでしか使わないであろうラジオボタンやチェックボックスのstate管理は、setState()
, ローカルDBへの接続やFCMの通知の購読をする処理は、iniState()
を使うとよさそうです。
class HomeView extends ConsumerStatefulWidget {
const HomeView({super.key});
HomeViewState createState() => HomeViewState();
}
class HomeViewState extends ConsumerState<HomeView> {
void initState() {
super.initState();
// `ref` は StatefulWidget のすべてのライフサイクルメソッド内で使用可能です。
ref.read(counterProvider);
}
Widget build(BuildContext context) {
// `ref` は build メソッド内で使用することもできます。
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}
Discussion