「FlutterとFirebase Cloud Messagingを使ったiOSとAndroidのプッシュ通知のハンドリング」
湯 鵬飛
湯 鵬飛
2023-07-28
本記事では、FlutterとFirebase Cloud Messagingを使って開発したiOSとAndroidのアプリでのプッシュ通知の取り扱いについて解説します。具体的には、アプリがバックグラウンドや完全に閉じた状態でのプッシュ通知のハンドリング、そしてプッシュ通知をタップしてアプリを開いたときのメッセージの取得方法について説明します。また、バックグラウンド状態でのUI更新の問題点とそれを解決する方法、さらには型の不一致エラーの対処法についても触れています。

iOS FCM Widget Issue

You:

FlutterでiOSとAndroidアプリを開発しています。 今Firebase CloudMessageを使って、プッシュ通知機能を開発している真っ最中です。 実行状況: 1.僕が開発したアプリが完全に閉じます。バックグランドでも実行していない状態です。 2.Firebaseのコンソールで1つのプッシュ通知を発信しました。 3.Androidも、iOSも、スマホOSのプッシュ通知の受信メッセージが来ました。 4.Androidのプッシュ通知をタッチしたら、僕が開発したアプリが起動して、設定したWidgetでプッシュ通知の表示ができましたが、 問題:iOSの場合は、アプリが起動したけど、Widgetが起動されていないです。 原因と解決方法を教えてください。

ChatGPT:

iOSでのFirebase Cloud Messaging (FCM)を用いたプッシュ通知を受けてアプリが起動した際に特定のWidgetが表示されない問題について説明します。

原因として考えられるのは、iOSのプッシュ通知のハンドリングについての設定です。具体的には、FirebaseMessaging.instance.getInitialMessage() がアプリが起動された直後に通知を取得するためのもので、通知によりアプリが起動されたときにはこれを使用して通知を処理する必要があります。

以下に、FlutterでiOSの通知を処理するためのサンプルコードを示します。

FirebaseMessaging.instance
    .getInitialMessage()
    .then((RemoteMessage message) {
  if (message != null) {
    Navigator.pushNamed(context, '/message',
        arguments: MessageArguments(message, true));
  }
});

FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  RemoteNotification notification = message.notification;
  AndroidNotification android = message.notification?.android;

  if (notification != null && android != null) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text(notification.title),
          content: SingleChildScrollView(
            child: ListBody(
              children: <Widget>[
                Text(notification.body),
              ],
            ),
          ),
        );
      },
    );
  }
});

FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
  print('A new onMessageOpenedApp event was published!');
  Navigator.pushNamed(context, '/message',
      arguments: MessageArguments(message, true));
});

上記のコードでは、getInitialMessageを使ってアプリが通知で開始された場合のメッセージを取得しています。また、onMessageはアプリがフォアグラウンドにあるときに新しいメッセージが来たとき、onMessageOpenedAppはアプリがバックグラウンドまたは終了状態で通知をタップして開始されたときにそれぞれ呼び出されます。

もし、上記のような処理が既に実装されていても問題が解決しない場合は、iOSのCapability設定で"Push Notifications"と"Background Modes"(ここでは "Remote notifications"を選択)が有効化されているか確認してください。

それでも問題が解決しない場合は、具体的なコードとエラーメッセージをお教えいただければ、より詳しいサポートが可能となります。

You:

つまり、アプリが起動するときのプッシュ通知の情報を獲得は

FirebaseMessaging.instance.getInitialMessage()

を使って獲得する、

 FirebaseMessaging.onBackgroundMessage

ではない

ということですか?

ChatGPT:

はい、その通りです。

アプリが起動する(Cold startまたは完全に閉じた状態から)ときにプッシュ通知をタップした場合、FirebaseMessaging.instance.getInitialMessage() を使用します。この関数はアプリが完全に終了した状態から開始されたとき、そしてその開始がプッシュ通知のタップによるものであった場合にその通知のデータを取得します。

一方、FirebaseMessaging.onBackgroundMessage はアプリがバックグラウンド状態(完全には終了していないが、ユーザーには見えない状態)であるときにプッシュ通知を受信した場合のハンドラーです。

したがって、あなたが直面している問題に対する解決策としては FirebaseMessaging.instance.getInitialMessage() の使用が適していると言えます。

You:

FirebaseMessaging.onBackgroundMessage の書き方はなんでしょうか?

ChatGPT:

FirebaseMessaging.onBackgroundMessageは、アプリがバックグラウンド状態でプッシュ通知を受信した際に行う処理を指定するためのメソッドです。以下に、使用例を示します。

まず、バックグランドメッセージハンドラーのための関数をトップレベル(クラスの外部)に定義します。この関数は、非同期であり、唯一の引数としてRemoteMessageオブジェクトを取ります。

Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // ここに、バックグラウンドで通知を受け取ったときの処理を書く
  print("Handling a background message: ${message.messageId}");
}

次に、main関数の中で、このハンドラーをonBackgroundMessageに設定します。

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  runApp(MyApp());
}

このように設定することで、アプリがバックグラウンド状態でプッシュ通知を受信した際には、_firebaseMessagingBackgroundHandler関数が実行されます。

ただし、この関数はアプリのUIにアクセスできないことに注意してください。なぜなら、この関数が実行されるとき、アプリはバックグラウンドにあるか、あるいは終了しているからです。したがって、この関数の中で何かUIを更新しようとした場合、エラーが発生します。UIに影響を与える操作を行いたい場合には、別の方法を考える必要があります。たとえば、受信した通知のデータを何らかの形で保存しておき、アプリが再度フォアグラウンドに戻ったときにそれを読み出して処理する、などの方法が考えられます。

You:

FirebaseMessaging.onBackgroundMessageの中に、

下記のようなDialogを表示させたいですが、どうすればいいですか

showDialog(
     context: context,
     barrierDismissible: false,
     builder: (BuildContext context) {
       return Dialog(
         child:
          ConstrainedBox(
             constraints: BoxConstraints(maxWidth: 500), // ここで最大幅を設定します
             child: Container(
               child: 
                SingleChildScrollView(
                 child: 
                   Column(
                     mainAxisSize: MainAxisSize.min,
                     children: [
                       Container(
                         color: Colors.orange,
                         padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
                         child: Row(
                           children: [
                             Icon(Icons.notifications, color: Colors.white),
                             SizedBox(width: 8),
                             Text(
                               'Lポケットお知らせ',
                               style: TextStyle(color: Colors.white, fontSize: 14),
                             ),
                           ],
                         ),
                       ),
                       Padding(
                         padding: EdgeInsets.symmetric(vertical: 10),
                         child: Text(
                           '${message["title"]}',
                           textAlign: TextAlign.center,
                           style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                         ),
                       ),
                       Padding(
                         padding: EdgeInsets.symmetric(vertical: 5),
                         child: 
                         (message.containsKey('imgurl')==true)?Image.network('${message["imgurl"]}'):null,
                       ),
                       Padding(
                         padding: EdgeInsets.fromLTRB(20, 10, 20, 0),
                         child: Align(alignment: Alignment.centerLeft,
                           child: Text('${message["body"]}', style: TextStyle(fontSize: 14),),),
                       ),
                       Padding(
                         padding: EdgeInsets.fromLTRB(20, 20, 20, 20),
                         child: Row(
                           mainAxisAlignment: MainAxisAlignment.spaceBetween,
                           children: [
                             ElevatedButton(
                               onPressed: () {
                                 Navigator.of(context).pop();
                               },
                               style: ElevatedButton.styleFrom(
                                 foregroundColor: Colors.white, backgroundColor: Colors.orange.shade300, // フォントカラーを白にする
                               ),
                               child: Text('閉じる'),
                             ),
                             ElevatedButton(
                               onPressed: () {
                                 funcReadNotifactionInfo(message);
                                 Navigator.of(context).pop();
                               },
                               style: ElevatedButton.styleFrom(
                                 foregroundColor: Colors.white, backgroundColor: Colors.orange.shade900, // フォントカラーを白にする
                               ),
                               child: Text('詳細を見る'),
                             ),
                           ],
                         ),
                       ),
                     ],
                   ),
                 
               )
             ),
           )
          );
     },
   );

ChatGPT:

Flutterでは、FirebaseMessaging.onBackgroundMessageの中でUIに直接アクセスすることはできません。これは、バックグラウンドで実行されるコードは別のDart Isolateで実行され、UIの更新などの主要なIsolateでの操作を行うことができないためです。

そのため、ダイアログを表示するなどのUI操作を行いたい場合、その操作をアプリがフォアグラウンドに戻ったときに行うようにする必要があります。そのためには、バックグラウンドメッセージハンドラーの中で通知の内容を何らかの形で保存しておき(例えば、Shared Preferencesを使用するなど)、アプリが再度フォアグラウンドに戻ったときにそれを読み出して処理する、といった方法が考えられます。

以下にその一例を示します。

まず、バックグランドメッセージハンドラーの中で通知の内容を保存します:

Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // Shared Preferencesを使って通知の内容を保存
  SharedPreferences prefs = await SharedPreferences.getInstance();
  await prefs.setString('notification_title', message.notification.title);
  await prefs.setString('notification_body', message.notification.body);
  // その他必要な情報も同様に保存
}

そして、アプリが再度フォアグラウンドになったときに、その情報を読み出してダイアログを表示します:

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  super.didChangeAppLifecycleState(state);
  if (state == AppLifecycleState.resumed) {
    // アプリがフォアグラウンドに戻ったとき
    _showNotificationDialog();
  }
}

void _showNotificationDialog() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  String title = prefs.getString('notification_title');
  String body = prefs.getString('notification_body');

  if (title != null && body != null) {
    // 保存されていた通知の内容をもとにダイアログを表示
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text(title),
          content: Text(body),
          actions: <Widget>[
            TextButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );

    // 通知の内容をクリア
    prefs.remove('notification_title');
    prefs.remove('notification_body');
  }
}

ただし、上記のコードはあくまで一例であり、実際には通知の内容に応じて適切なデータ構造を用いて情報を保存し、またその情報をもとに適切なUI操作を行うようにする必要があります。また、_showNotificationDialogWidgetsBindingObserverをMixinしたクラスのメソッドとして定義しています。そのため、このメソッドを使用するためにはそのクラスをアプリのstate管理に適用させる必要があります。

You:

  FirebaseMessaging.instance.getInitialMessage().then((RemoteMessage message) {
    if (message != null) {
      print('AZLog:onMessageOpenedApp: $message');
      this._showMessageDialog1(message.data);
    }
  } as FutureOr Function(RemoteMessage? value));

上記のコードは下記のエラーメッセージが発生

例外が発生しました
_TypeError (type '(RemoteMessage) => Null' is not a subtype of type '(RemoteMessage?) => dynamic' in type cast)

原因と対策方法を教えてくだだい。

ChatGPT:

このエラーメッセージは、RemoteMessageオブジェクトがnullである可能性があるということを示しています。それに対して、thenメソッドの中のコールバック関数がRemoteMessageを非nullとして扱っています。これが型の不一致を引き起こしています。

getInitialMessage()は、初期のメッセージ(つまり、アプリが起動した原因となるプッシュ通知)が存在しない場合にnullを返す可能性があるため、その結果を処理する際にはnullを考慮に入れる必要があります。

以下のように修正することで、この問題を解消できます:

FirebaseMessaging.instance.getInitialMessage().then((RemoteMessage? message) {
  if (message != null) {
    print('AZLog:onMessageOpenedApp: $message');
    this._showMessageDialog1(message.data);
  }
});

これで、RemoteMessagenullである場合でも正しく動作するようになります。

You:

今までの会話を整理したいです。日本語でマインドマップの形で下記のステップでまとめてください。 1.まず「マインドマップ:」という文字列を先頭に入れてください。 2.そして改行を入れてください。 3.マインドマップの内容を作ってください。必ずmarkdownの記述方式で出力してください。

ChatGPT:

まず「マインドマップ:」

- Firebase Cloud Messagingを使ったプッシュ通知
  - プッシュ通知の状況
    - Android: プッシュ通知をタッチしてアプリが起動し、通知内容を表示
    - iOS: プッシュ通知をタッチしてアプリが起動するが、通知内容を表示できない
  - 解決策
    - アプリが完全に終了した状態で通知をタップして開いた場合
      - `FirebaseMessaging.instance.getInitialMessage()`を用いて、起動時のメッセージを取得
    - アプリがバックグラウンド状態で通知を受け取った場合
      - `FirebaseMessaging.onBackgroundMessage()`を用いて、バックグラウンドメッセージハンドラーを設定
    - UIの更新について
      - バックグラウンドメッセージハンドラー内ではUIを直接更新できない
      - UIを更新する処理は、アプリがフォアグラウンドに戻ったときに行う
        - バックグラウンドメッセージハンドラー内で通知内容を保存(例: Shared Preferencesを使用)
        - アプリがフォアグラウンドになったとき、保存した通知内容を読み出しダイアログ等で表示
    - 型の不一致エラー
      - `FirebaseMessaging.instance.getInitialMessage().then()`のコールバック関数は`null`が返る可能性を考慮に入れる必要がある
        - 修正方法: コールバック関数の引数を`RemoteMessage?`とする

You:

今までの会話をブログに作りたい。マインドマップ作成の内容を除いて、 タイトルと集約文、そしてSEO対応のため、キーワードを「,」区切りでまとめてください。出力文のフォーマットは「タイトル:」「集約文:」「キーワード:」の形でお願いいたします。「タイトル:」「集約文:」「キーワード:」は改行2回で区切りしてください。そして「タイトル」「集約文」「キーワード」以外の説明がいりません。そして日本語で出力してください。

ChatGPT:

タイトル: 「FlutterとFirebase Cloud Messagingを使ったiOSとAndroidのプッシュ通知のハンドリング」

集約文: 本記事では、FlutterとFirebase Cloud Messagingを使って開発したiOSとAndroidのアプリでのプッシュ通知の取り扱いについて解説します。具体的には、アプリがバックグラウンドや完全に閉じた状態でのプッシュ通知のハンドリング、そしてプッシュ通知をタップしてアプリを開いたときのメッセージの取得方法について説明します。また、バックグラウンド状態でのUI更新の問題点とそれを解決する方法、さらには型の不一致エラーの対処法についても触れています。

キーワード: Flutter, Firebase Cloud Messaging, プッシュ通知, iOS, Android, バックグラウンドメッセージハンドラー, UI更新, 型の不一致エラー, getInitialMessage, onBackgroundMessage, Shared Preferences