🔔

FCMから通知を受け取るとstate管理でつまずいた

2024/07/31に公開

Tips💡

FCMを使用して、Firebaseのコンソールから、全てのアプリに通知を送る機能を作る要件があった。実験で試しに作ってみた。サーバー側にロジックは作らず、Firebase Messagingから送るだけなのだが、躓いたことが多かった💦

こちらを参考に実装を変えました

  1. 📲全ての端末にデータを送ることはできた
  2. 🔔通知は受け取ったがflutter_hooksだけだと限界があった
  3. ValueNotifierのerrorが発生
  4. スコープの広い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は、ウィジェットのライフサイクルに紐付いています。
この問題を解決するには、以下のアプローチを検討してください:

  1. 状態管理の方法を変更する:
    useStateの代わりに、より広いスコープを持つ状態管理ソリューション(例:Riverpod)を使用することを検討します。
  2. 非同期操作の取り扱いを改善する:
    非同期操作(特に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