Flutter アプリでメッセージを受信する

デバイスの状態に応じて受信メッセージの処理方法が変わります。このようなシナリオと独自のアプリに FCM を統合する方法を理解するには、まず、デバイスが取りうる状態を定義しておく必要があります。

状態 説明
フォアグラウンド アプリが開いている状態で、表示または使用されている。
バックグラウンド アプリは開いているが、バックグラウンドで実行されている(最小化されている)。通常、この状態はユーザーがデバイスのホームボタンを押した場合、アプリ スイッチャーを使用して別のアプリに切り替えた場合、アプリを別のタブ(ウェブ)で開いた場合に発生します。
終了 デバイスがロックされているか、アプリケーションが実行されていない状態。

アプリが FCM 経由でメッセージ ペイロードを受信できるようにするには、いくつかの前提条件を満たす必要があります。

  • 少なくとも 1 回アプリケーションを開く必要があります(FCM への登録を可能にするために)。
  • iOS で、ユーザーがアプリ スイッチャーからアプリをスワイプした場合、バックグラウンド メッセージを再開するために、アプリを手動で再起動する必要があります。
  • Android で、ユーザーがデバイスの設定からアプリを強制終了した場合、メッセージを受信するには、アプリを手動で再開する必要があります。
  • ウェブの場合、ウェブのプッシュ証明書を使用して(getToken() を使用)、トークンをリクエストする必要があります。

メッセージの受信権限をリクエストする

iOS、macOS、ウェブ、Android 13 以降では、デバイスで FCM ペイロードを受信する前に、まずユーザーに権限を付与する必要があります。

firebase_messaging パッケージには、requestPermission メソッドで権限をリクエストするシンプルな API が用意されています。この API は、リクエストする権限のタイプを定義する多くの名前付き引数を受け入れます。たとえば、通知ペイロードを含むメッセージによって Siri 経由で音を再生したり、メッセージを読み上げることをリクエストします。デフォルトでは、このメソッドは適切なデフォルト権限をリクエストします。リファレンス API には、各権限の用途に関する詳細なドキュメントが用意されています。

まず、アプリケーションからメソッドを呼び出します(iOS ではネイティブ モーダルが表示され、ウェブではブラウザのネイティブ API フローがトリガーされます)。

FirebaseMessaging messaging = FirebaseMessaging.instance;

NotificationSettings settings = await messaging.requestPermission(
  alert: true,
  announcement: false,
  badge: true,
  carPlay: false,
  criticalAlert: false,
  provisional: false,
  sound: true,
);

print('User granted permission: ${settings.authorizationStatus}');

リクエストから返された NotificationSettings オブジェクトの authorizationStatus プロパティにより、ユーザーの決定を判断できます。

  • authorized: ユーザーが権限の付与を許可した。
  • denied: ユーザーが権限の付与を拒否した。
  • notDetermined: ユーザーが権限を付与するかどうか選択しない。
  • provisional: ユーザーが一時的に権限の付与を許可した。

NotificationSettings の他のプロパティは、現在のデバイスで特定の権限が有効、無効、または未対応かを示します。

権限が付与され、さまざまな種類のデバイス状態を理解したら、アプリで受信 FCM ペイロードの処理を開始できます。

メッセージ処理

アプリの現在の状態に基づいて、受信ペイロードのメッセージ タイプに応じて異なる処理方法を実装する必要があります。

フォアグラウンド メッセージ

アプリがフォアグラウンドで動作しているときにメッセージを処理するには、onMessage ストリームをリッスンします。

FirebaseMessaging.onMessage.listen((RemoteMessage message) {
  print('Got a message whilst in the foreground!');
  print('Message data: ${message.data}');

  if (message.notification != null) {
    print('Message also contained a notification: ${message.notification}');
  }
});

ストリームには、ペイロードの送信元、一意の ID、送信時間、通知が含まれているかどうかなど、ペイロードに関する詳細を含む RemoteMessage が含まれています。アプリがフォアグラウンドで動作中にメッセージが取得されたため、Flutter アプリケーションの状態とコンテキストに直接アクセスできます。

フォアグラウンド メッセージと通知メッセージ

Android と iOS のデフォルトでは、アプリがフォアグラウンドで動作中に通知メッセージを受信すると、通知は表示されません。ただし、この動作をオーバーライドすることは可能です。

  • Android の場合は、「優先度の高い」通知チャンネルを作成します。
  • iOS の場合は、アプリのプレゼンテーション オプションを更新します。

バックグラウンド メッセージ

バックグラウンド メッセージの処理プロセスは、ネイティブ(Android と Apple)プラットフォームとウェブベースのプラットフォームとで異なります。

Apple プラットフォームと Android

onBackgroundMessage ハンドラを登録してバックグラウンド メッセージを処理します。メッセージを受信すると isolate が作成されます(Android のみ。iOS / macOS では新たな isolate は必要ありません)。これにより、アプリが実行されていなくてもメッセージを処理できます。

バックグラウンド メッセージ ハンドラについては、次の点に注意が必要です。

  1. 匿名関数は使用できません。
  2. トップレベル関数にする必要があります(初期化を必要とするクラスメソッドなどは使用できません)。
  3. Flutter バージョン 3.3.0 以降を使用している場合、関数宣言のすぐ上でメッセージ ハンドラに @pragma('vm:entry-point') アノテーションを付ける必要があります(そうしないと、リリースモードのツリー シェイキングで削除される可能性があります)。
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // If you're going to use other Firebase services in the background, such as Firestore,
  // make sure you call `initializeApp` before using other Firebase services.
  await Firebase.initializeApp();

  print("Handling a background message: ${message.messageId}");
}

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

ハンドラは、アプリのコンテキスト外の独自の isolate で実行されるため、アプリの状態を更新したり、UI に影響するロジックを実行することはできません。ただし、HTTP リクエストなどのロジックの実行、I/O オペレーションの実行(ローカル ストレージの更新など)、他のプラグインとの通信は可能です。

できるだけ早くロジックを完了することをおすすめします。長時間実行されるタスクはデバイスのパフォーマンスに影響を与え、OS がプロセスを強制的に終了する場合があります。タスクが 30 秒を超えて実行されると、デバイスは自動的にそのプロセスを強制終了します。

ウェブ

ウェブの場合、バックグラウンドで実行される Service Worker という JavaScript を作成します。Service Worker を使用してバックグラウンド メッセージを処理します。

まず、web ディレクトリに firebase-messaging-sw.js という名前で新しいファイルを作成します。

// Please see this file for the latest firebase-js-sdk version:
// https://github.com/firebase/flutterfire/blob/master/packages/firebase_core/firebase_core_web/lib/src/firebase_sdk_version.dart
importScripts("https://www.gstatic.com/firebasejs/10.7.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.7.0/firebase-messaging-compat.js");

firebase.initializeApp({
  apiKey: "...",
  authDomain: "...",
  databaseURL: "...",
  projectId: "...",
  storageBucket: "...",
  messagingSenderId: "...",
  appId: "...",
});

const messaging = firebase.messaging();

// Optional:
messaging.onBackgroundMessage((message) => {
  console.log("onBackgroundMessage", message);
});

このファイルでは、アプリ SDK とメッセージング SDK の両方をインポートし、Firebase を初期化して、messaging 変数を公開する必要があります。

次に、ワーカーを登録します。index.html ファイル内で、Flutter をブートストラップする <script> タグを変更してワーカーを登録します。

<script src="flutter_bootstrap.js" async>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', function () {
      navigator.serviceWorker.register('firebase-messaging-sw.js', {
        scope: '/firebase-cloud-messaging-push-scope',
      });
    });
  }
</script>

まだ古いテンプレート システムを使用している場合は、次のように Flutter をブートストラップする <script> タグを変更してワーカーを登録できます。

<html>
<body>
  <script>
      var serviceWorkerVersion = null;
      var scriptLoaded = false;
      function loadMainDartJs() {
        if (scriptLoaded) {
          return;
        }
        scriptLoaded = true;
        var scriptTag = document.createElement('script');
        scriptTag.src = 'main.dart.js';
        scriptTag.type = 'application/javascript';
        document.body.append(scriptTag);
      }

      if ('serviceWorker' in navigator) {
        // Service workers are supported. Use them.
        window.addEventListener('load', function () {
          // Register Firebase Messaging service worker.
          navigator.serviceWorker.register('firebase-messaging-sw.js', {
            scope: '/firebase-cloud-messaging-push-scope',
          });

          // Wait for registration to finish before dropping the <script> tag.
          // Otherwise, the browser will load the script multiple times,
          // potentially different versions.
          var serviceWorkerUrl =
            'flutter_service_worker.js?v=' + serviceWorkerVersion;

          navigator.serviceWorker.register(serviceWorkerUrl).then((reg) => {
            function waitForActivation(serviceWorker) {
              serviceWorker.addEventListener('statechange', () => {
                if (serviceWorker.state == 'activated') {
                  console.log('Installed new service worker.');
                  loadMainDartJs();
                }
              });
            }
            if (!reg.active && (reg.installing || reg.waiting)) {
              // No active web worker and we have installed or are installing
              // one for the first time. Simply wait for it to activate.
              waitForActivation(reg.installing ?? reg.waiting);
            } else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
              // When the app updates the serviceWorkerVersion changes, so we
              // need to ask the service worker to update.
              console.log('New service worker available.');
              reg.update();
              waitForActivation(reg.installing);
            } else {
              // Existing service worker is still good.
              console.log('Loading app from service worker.');
              loadMainDartJs();
            }
          });

          // If service worker doesn't succeed in a reasonable amount of time,
          // fallback to plaint <script> tag.
          setTimeout(() => {
            if (!scriptLoaded) {
              console.warn(
                'Failed to load app from service worker. Falling back to plain <script> tag.'
              );
              loadMainDartJs();
            }
          }, 4000);
        });
      } else {
        // Service workers not supported. Just drop the <script> tag.
        loadMainDartJs();
      }
  </script>
</body>

次に、Flutter アプリを再起動します。ワーカーが登録され、このファイルを介してバックグラウンド メッセージが処理されます。

インタラクションの処理

通知は目に見える手がかりであるため、ユーザーが(タップして)操作することはよくあります。Android と iOS のいずれも、デフォルトの動作ではアプリが開きます。アプリが終了している場合は、アプリが起動します。アプリがバックグラウンドで動作している場合は、フォアグラウンドに移動します。

通知の内容によっては、アプリを開くときにユーザーの操作を処理する必要が生じることがあります。たとえば、通知を介して新しいチャット メッセージを送信した後にユーザーが通知をタップした場合、アプリの起動時に特定のスレッドを開きたいことがあります。

firebase-messaging パッケージには、このインタラクションを処理する 2 つの方法が用意されています。

  • getInitialMessage(): アプリが終了状態から開いた状態になると、RemoteMessage を含む Future が返されます。使用済みの RemoteMessage は削除されます。
  • onMessageOpenedApp: アプリがバックグラウンド状態から開いた状態になった場合に、RemoteMessage を送信する Stream です。

ユーザーがスムーズな UX を実現できるように、両方のシナリオを処理することをおすすめします。以下のサンプルコードは、この方法を示しています。

class Application extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _Application();
}

class _Application extends State<Application> {
  // It is assumed that all messages contain a data field with the key 'type'
  Future<void> setupInteractedMessage() async {
    // Get any messages which caused the application to open from
    // a terminated state.
    RemoteMessage? initialMessage =
        await FirebaseMessaging.instance.getInitialMessage();

    // If the message also contains a data property with a "type" of "chat",
    // navigate to a chat screen
    if (initialMessage != null) {
      _handleMessage(initialMessage);
    }

    // Also handle any interaction when the app is in the background via a
    // Stream listener
    FirebaseMessaging.onMessageOpenedApp.listen(_handleMessage);
  }

  void _handleMessage(RemoteMessage message) {
    if (message.data['type'] == 'chat') {
      Navigator.pushNamed(context, '/chat',
        arguments: ChatArguments(message),
      );
    }
  }

  @override
  void initState() {
    super.initState();

    // Run code required to handle interacted messages in an async function
    // as initState() must not be async
    setupInteractedMessage();
  }

  @override
  Widget build(BuildContext context) {
    return Text("...");
  }
}

インタラクションの処理方法は、アプリの設定によって異なります。上記の例では、StatefulWidget を使用する基本的な例を示しています。

メッセージをローカライズする

ローカライズされた文字列は、次の 2 つの方法で送信できます。

  • 各ユーザーの希望言語をサーバーに保存し、言語ごとにカスタマイズした通知を送信する
  • ローカライズした文字列をアプリに埋め込んで、オペレーティング システムのネイティブのロケール設定を使用する

2 番目の使用方法を以下に紹介します。

Android

  1. resources/values/strings.xml でデフォルト言語メッセージを指定します。

    <string name="notification_title">Hello world</string>
    <string name="notification_message">This is a message</string>
    
  2. values-language ディレクトリに、翻訳されたメッセージを指定します。たとえば、フランス語のメッセージは resources/values-fr/strings.xml に指定します。

    <string name="notification_title">Bonjour le monde</string>
    <string name="notification_message">C'est un message</string>
    
  3. サーバー ペイロードでは、titlemessagebody のキーを使用するのではなく、ローカライズしたメッセージ用の title_loc_keybody_loc_key を使用し、表示したいメッセージの name 属性を設定します。

    メッセージ ペイロードは次のようになります。

    {
      "data": {
        "title_loc_key": "notification_title",
        "body_loc_key": "notification_message"
      }
    }
    

iOS

  1. Base.lproj/Localizable.strings でデフォルト言語メッセージを指定します。

    "NOTIFICATION_TITLE" = "Hello World";
    "NOTIFICATION_MESSAGE" = "This is a message";
    
  2. language.lproj ディレクトリに、翻訳されたメッセージを指定します。たとえば、フランス語のメッセージは fr.lproj/Localizable.strings に指定します。

    "NOTIFICATION_TITLE" = "Bonjour le monde";
    "NOTIFICATION_MESSAGE" = "C'est un message";
    

    メッセージ ペイロードは次のようになります。

    {
      "data": {
        "title_loc_key": "NOTIFICATION_TITLE",
        "body_loc_key": "NOTIFICATION_MESSAGE"
      }
    }
    

メッセージ配信データのエクスポートを有効にする

メッセージ データを BigQuery にエクスポートしてさらに分析できます。BigQuery では、BigQuery SQL を使用してデータを分析したり、別のクラウド プロバイダにデータをエクスポートしたり、データをカスタム ML モデルで使用したりできます。BigQuery へのエクスポートには、メッセージの種類、メッセージの送信手段(API、Notifications Composer)に関係なく、メッセージで利用可能なすべてのデータが含まれます。

エクスポートを有効にするには、まずこちらで説明されている手順を行い、その後次の指示に従います。

Android

次のコードを使用します。

await FirebaseMessaging.instance.setDeliveryMetricsExportToBigQuery(true);

iOS

iOS の場合は、AppDelegate.m を次のように変更する必要があります。

#import "AppDelegate.h"
#import "GeneratedPluginRegistrant.h"
#import <Firebase/Firebase.h>

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [GeneratedPluginRegistrant registerWithRegistry:self];
  // Override point for customization after application launch.
  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

- (void)application:(UIApplication *)application
    didReceiveRemoteNotification:(NSDictionary *)userInfo
          fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
  [[FIRMessaging extensionHelper] exportDeliveryMetricsToBigQueryWithMessageInfo:userInfo];
}

@end

ウェブ

ウェブの場合、v9 バージョンの SDK を使用するには、Service Worker を変更する必要があります。v9 バージョンはバンドルする必要があるため、Service Worker を機能させるためには、esbuild などのバンドラを使用する必要があります。この方法については、サンプルアプリをご覧ください。

v9 SDK に移行したら、次のコードを使用できます。

import {
  experimentalSetDeliveryMetricsExportedToBigQueryEnabled,
  getMessaging,
} from 'firebase/messaging/sw';
...

const messaging = getMessaging(app);
experimentalSetDeliveryMetricsExportedToBigQueryEnabled(messaging, true);

新しいバージョンの Service Worker を web フォルダにエクスポートする場合は、yarn build を実行するのを忘れないでください。

iOS で通知に画像を表示する

Apple デバイスで FCM 通知を受信後に FCM ペイロードの画像を表示するには、通知サービス拡張機能を追加して、それを使用するようにアプリを設定する必要があります。

Firebase 電話認証を使用している場合は、Firebase Auth Pod を Podfile に追加する必要があります。

ステップ 1 - 通知サービス拡張機能を追加する

  1. Xcode で [ファイル] > [新規] > [ターゲット...] をクリックします。
  2. ターゲット候補のリストがモーダルに表示されます。下にスクロールするか、フィルタを使用して [通知サービス拡張機能] を選択します。[次へ] をクリックします。
  3. プロダクト名を追加して(このチュートリアルでは「ImageNotification」を使用)、言語を Objective-C に設定し、[完了] をクリックします。
  4. [有効にする] をクリックして、スキームを有効にします。

ステップ 2 - ターゲットを Podfile に追加する

新しい拡張機能を Podfile に追加し、その拡張機能が Firebase/Messaging Pod にアクセスできることを確認します。

  1. ナビゲータから Podfile を開きます([Pod] > [Podfile])。

  2. ファイルの一番下までスクロールし、以下を追加します。

    target 'ImageNotification' do
      use_frameworks!
      pod 'Firebase/Auth' # Add this line if you are using FirebaseAuth phone authentication
      pod 'Firebase/Messaging'
    end
    
  3. ios または macos ディレクトリから pod install を使用して Pod をインストールまたは更新します。

ステップ 3 - 拡張機能ヘルパーを使用する

この時点で、すべてが正常に動作している必要があります。最後に、拡張機能ヘルパーを呼び出します。

  1. ナビゲータから ImageNotification 拡張機能を選択します

  2. NotificationService.m ファイルを開きます。

  3. ファイルの上部で、以下に記載のとおり、NotificationService.h とその直後に FirebaseMessaging.h をインポートします。

    NotificationService.m の内容を次のように置き換えます。

    #import "NotificationService.h"
    #import "FirebaseMessaging.h"
    #import "FirebaseAuth.h" // Add this line if you are using FirebaseAuth phone authentication
    #import <UIKit/UIKit.h> // Add this line if you are using FirebaseAuth phone authentication
    
    @interface NotificationService ()
    
    @property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
    @property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
    
    @end
    
    @implementation NotificationService
    
    /* Uncomment this if you are using Firebase Auth
    - (BOOL)application:(UIApplication *)app
                openURL:(NSURL *)url
                options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
      if ([[FIRAuth auth] canHandleURL:url]) {
        return YES;
      }
      return NO;
    }
    
    - (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
      for (UIOpenURLContext *urlContext in URLContexts) {
        [FIRAuth.auth canHandleURL:urlContext.URL];
      }
    }
    */
    
    - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
        self.contentHandler = contentHandler;
        self.bestAttemptContent = [request.content mutableCopy];
    
        // Modify the notification content here...
        [[FIRMessaging extensionHelper] populateNotificationContent:self.bestAttemptContent withContentHandler:contentHandler];
    }
    
    - (void)serviceExtensionTimeWillExpire {
        // Called just before the extension will be terminated by the system.
        // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
        self.contentHandler(self.bestAttemptContent);
    }
    
    @end
    

ステップ 4 - 画像をペイロードに追加する

これで、通知ペイロードに画像を追加できるようになりました。詳しくは、送信リクエストのビルド方法に関する iOS のドキュメントをご覧ください。なお、デバイスで適用される画像サイズは最大 300 KB です。