メルカリ ハロのプッシュ通知と CRM integration の話(Android編)

こんにちは。メルカリのソフトウェアエンジニアの @sintario_2nd です。
この記事は、連載:メルカリ ハロ 開発の裏側 – Flutterと支える技術 –の4回目と、
Mercari Advent Calendar 2024 の10日目の記事です。

この記事について

メルカリ ハロは 2024年3月6日にサービスを開始しました。サービスローンチ後は開発チームの体制が変わり、わたしはGrowth Hackチームに配属されて Customer Relationship Management (CRM) のツールのインテグレーションに関わってきました。いろいろと一筋縄にはいかないこともあり試行錯誤したなかで、今回はプッシュ通知周りで遭遇した課題について、どのような調査を経てどのように解決したか、をご紹介したいと思います。

メルカリ ハロのプッシュ通知の当初構成

メルカリは各サービスへの通知を管理する microservice (Notification Service と呼ぶことにします) をすでに持っており、メルカリ ハロのバックエンド (Hallo Backend) から Notification Service 経由で Firebase Cloud Messaging (FCM) によるプッシュ通知が送られるという構成になっています。Hallo Backend からのメッセージがアプリの載っている端末にプッシュ通知として届くまでの図式としては下図のようになります。

Mercari Hallo's push notification flow (original)

サービスローンチまでは非常に短い時間であったということもあり、まずは最低限の機能を実現するべく、 Flutter で実装されたアプリ側は FlutterFire を使用して FlutterFire のガイドどおりに素直に実装されていました。

Braze を組み込む

サービスローンチ後は一般的にどんなサービスでも利用拡大を目指していくものかと思います。そのため、一定のターゲット属性に当てはまる利用者群に向けてキャンペーンのメッセージをお届けする、といった CRM 施策がよく行われます。メルカリ ハロでは、CRMの分野ではよく使われている Braze を採用しています。

Braze は各種のメッセージ手段でキャンペーンを実施する仕組みを持っていて、 In-App Messaging / Content Cards / e-mail / プッシュ通知 などに対応しています。この分野では老舗ということもあり、 Flutter向け Braze SDK の組み込みガイド も提供されていたので書かれた通りに作業すればすんなり動くものと高をくくっていました。実際 IAM などは軽作業のみですんなり使えて拍子抜けしましたが、プッシュ通知については我々がもともと実装済みだった構成との兼ね合いで、想定よりも苦戦することになりました。以下では Android にフォーカスしてご紹介したいと思います。

Braze は Android のプッシュ通知には FCM を使う(※)ので、Braze がサービスに組み込まれると下図のように FCM の送信をトリガーする経路が2系統になります。アプリから見るとプッシュ通知は一律 FCM が送ってくることになるのでただ受信するだけなら単純なわけですが、実際にはそれぞれのプッシュ通知の着信率や開封率を知りたかったり、通知タップ後に何かしら特別なアクションがしたかったりしますよね。そのため受信したプッシュ通知が Hallo Backend 由来か Braze 由来かはアプリ側で通知の中身を見分け、仕分ける必要があります。

※ iOS については Braze は APNs を直接扱うので少し異なるデータフローになりますが、本題からそれるので今回は説明省略します。

Mercari Hallo's push notification flow with Braze

実際、なにも考えずに組み立て終わってレビューと機能検証をしていると、エンジニアの同僚やQAから「プッシュ通知の2重受信が起きることがあるみたいだ」と指摘されました。Braze SDK側は Braze 由来のプッシュ通知かどうかを見分けられるものの、もともとあった Hallo Backend 由来のプッシュを扱う実装が Braze からのプッシュ通知を見分けることができておらず、 Braze 由来のプッシュ通知が Hallo Backend 由来のプッシュ通知用のハンドラにも処理される2重ハンドリングが起きていました。

Braze からするとこういった FCM 発行者が複数になるパターンはよくある事案として想定されているようで、Braze と関係ないメッセージを fallback で扱うための FirebaseMessagingService をAndroid プロジェクト側で指定できる ようになっています。braze.xml というリソースファイルに下記のように fallback の有効化と fallback サービス名を指定することになります。

<bool name="com_braze_fallback_firebase_cloud_messaging_service_enabled">true</bool>
<string name="com_braze_fallback_firebase_cloud_messaging_service_classpath">com.company.OurFirebaseMessagingService</string>

またこれを前提にしているからか、Dart 向けの braze_plugin.dart では RemoteMessage が Braze 由来のものかを判別するメソッドを提供していないようです。

ということは、FlutterFire が Dart 層までメッセージを流し込む役目をしている FirebaseMessagingService の実装クラスをこの fallback サービスに指定すればいいのかな、、、となるわけですが、 アプリの AndroidManifest.xml をみてもそんなクラスはどこにも見当たらず、 🤔どうしたものか、となったわけです。

FlutterFire を読む

読者の皆さんはもうお気づきでしょうが、私たちは今 Flutter (Dart の世界)の範疇では解決できない領域に足を踏み入れました。ライブラリの Dart の実装部分だけを読んでいてもどうにもなりません。ネイティブアプリの開発知識を駆使して向き合う必要があります。

プッシュ通知のようなネイティブの機能と密接に関わるものはライブラリにもネイティブ実装部分があります。Android の場合は FCM のハンドリングをするサービスを AndroidManifest.xml に宣言する必要があり、アプリ側の manifest ではなくライブラリ側にも AndroidManifest.xml があってビルド時にマージされるので、つまりは FlutterFire のリポジトリにある AndroidManifest.xml を調べてみるのが良いだろう、と当たりをつけたわけです。

ありました。
https://github.com/firebase/flutterfire/blob/_flutterfire_internals-v1.3.35/packages/firebase_messaging/firebase_messaging/android/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="io.flutter.plugins.firebase.messaging">
  <uses-permission android:name="android.permission.INTERNET"/>
  <uses-permission android:name="android.permission.WAKE_LOCK"/>
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
  <!-- Permissions options for the `notification` group -->
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
  <application>
    <service
      android:name=".FlutterFirebaseMessagingBackgroundService"
      android:permission="android.permission.BIND_JOB_SERVICE"
      android:exported="false"/>
    <service android:name=".FlutterFirebaseMessagingService"
      android:exported="false">
      <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT"/>
      </intent-filter>
    </service>
    <receiver
      android:name=".FlutterFirebaseMessagingReceiver"
      android:exported="true"
      android:permission="com.google.android.c2dm.permission.SEND">
      <intent-filter>
        <action android:name="com.google.android.c2dm.intent.RECEIVE" />
      </intent-filter>
    </receiver>
    <service android:name="com.google.firebase.components.ComponentDiscoveryService">
      <meta-data android:name="com.google.firebase.components:io.flutter.plugins.firebase.messaging.FlutterFirebaseAppRegistrar"
                 android:value="com.google.firebase.components.ComponentRegistrar" />
    </service>
    <provider
      android:name=".FlutterFirebaseMessagingInitProvider"
      android:authorities="${applicationId}.flutterfirebasemessaginginitprovider"
      android:exported="false"
      android:initOrder="99" /> <!-- Firebase = 100, using 99 to run after Firebase initialises (highest first) -->
  </application>
</manifest>

FlutterFirebaseMessagingService というのがそれなのかな?と思って開いてみると、これ自身は FCM token のリフレッシュを Dart の世界に引っ張り込む入口程度の薄いクラスなのがわかります。

public class FlutterFirebaseMessagingService extends FirebaseMessagingService {
  @Override
  public void onNewToken(@NonNull String token) {
    FlutterFirebaseTokenLiveData.getInstance().postToken(token);
  }

  @Override
  public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
    // Added for commenting purposes;
    // We don't handle the message here as we already handle it in the receiver and don't want to duplicate.
  }
}

We don't handle the message here as we already handle it in the receiver and don't want to duplicate. というコメントの通り、 RemoteMessage としてやってきているはずの通知メッセージの実処理は、実は FlutterFirebaseMessagingReceiver のほうが担っています。

なお、

    <receiver
      android:name=".FlutterFirebaseMessagingReceiver"
      android:exported="true"
      android:permission="com.google.android.c2dm.permission.SEND">
      <intent-filter>
        <action android:name="com.google.android.c2dm.intent.RECEIVE" />
      </intent-filter>
    </receiver>

という記述を見て Android 開発のキャリアの長い方々はお気づきでしょうが、これは FCM ではなくそれ以前にプッシュ通知に使われていた GCM という仕組みの BroadcastReceiver の実装になります。現在は直接的にアプリや一般のライブラリがこれを使用するのは非サポートとなっているので、ご注意を。

FlutterFire の AndroidManifest に登場していた他のクラスについても少し触れておくと

  • FlutterFirebaseMessagingBackgroundService
    • FlutterFirebaseMessagingReceiver がバックグラウンドでプッシュ通知を受け取った場合に内部的にこのサービスを enqueue して、Dart の世界に通知を取り次ぐ役割をします。 BroadcastReceiver が長時間かかる処理をしないようにこのような構成になっているものと思われます。FlutterFirebaseMessagingReceiver を使う限りは AndroidManifest に宣言しておかないといけないもの、ということになります。

こうして以下のことがわかったわけです。

  • FlutterFire は FirebaseMessagingService の実装クラスを持っていたが、困ったことに onMessageReceived(RemoteMessage) の実装が空なので Braze からの fallback にはそのまま使えない。
  • また FlutterFirebaseMessagingReceiver を単純に組み込むとすべてのメッセージが Dart の世界まで引き込まれてしまう。フィルターアウトしたいメッセージの選別する手段を差し込む口が提供されていない。

さてどうしよう。。。となりました。

Braze SDK も読んでみる

困ったときは実装を読み込むしかないな、ということで Braze SDK の中も見てみます。

Flutter 向けには braze_plugin として提供されていますが、こちらも各OS向けのネイティブライブラリに依存していて、 Android については braze-android-sdk の android-sdk-ui モジュールにプッシュ通知周りの実装が入っているのがわかりました。このあたり です

implementation "com.braze:android-sdk-ui:30.4.0"

Braze 自身は標準的な FCM の仕組みに則っていて、 Kotlin で実装された open class の BrazeFirebaseMessagingService により FCM Token と RemoteMessge がハンドリングされるようになっています。このクラスは必ず必要ということになります。うーん。。。ただ onMessageReceived はすごく薄い実装になっていて

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        handleBrazeRemoteMessage(this, remoteMessage)
    }

handleBrazeRemoteMessage(this, … という記述から Java や Kotlin に詳しい方にはお察しいただける通り、このクラスは Java の static method (Kotlin の companion object の method)としてメッセージのハンドリング実体を提供する

        /**
         * Consumes an incoming [RemoteMessage] if it originated from Braze. If the [RemoteMessage] did
         * not originate from Braze, then this method does nothing and returns false.
         *
         * @param remoteMessage The [RemoteMessage] from Firebase.
         * @return true iff the [RemoteMessage] originated from Braze and was consumed. Returns false
         * if the [RemoteMessage] did not originate from Braze or otherwise could not be handled by Braze.
         */
        @JvmStatic
        fun handleBrazeRemoteMessage(context: Context, remoteMessage: RemoteMessage): Boolean {
            if (!isBrazePushNotification(remoteMessage)) {

およびその冒頭でちらっと見えていますが Braze 由来の RemoteMessage かを判別する static method の

        /**
         * Determines if the Firebase [RemoteMessage] originated from Braze and should be
         * forwarded to [BrazeFirebaseMessagingService.handleBrazeRemoteMessage].
         *
         * @param remoteMessage The [RemoteMessage] from [FirebaseMessagingService.onMessageReceived]
         * @return true iff this [RemoteMessage] originated from Braze or otherwise
         * should be passed to [BrazeFirebaseMessagingService.handleBrazeRemoteMessage].
         */
        @JvmStatic
        fun isBrazePushNotification(remoteMessage: RemoteMessage): Boolean {

を発見できました…!! これでなんとかなりそうです。

解決編

いろいろやり方はあると思いますが、以下のような方法をとりました。

Step1: FlutterFire の FlutterFirebaseMessagingReceivertools:node=”remove” を使って除去

FlutterFirebaseMessagingReceiver が直接 RemoteMessage を拾ってしまう限りは Braze 由来のメッセージを誤ってハンドリングするのを防げないので、
Managing manifest files にしたがって以下の記述をアプリ側の AndroidManifest.xml に追加し、 FlutterFire によって宣言されてしまう分を除去させます。

       <receiver
           android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
           xmlns:tools="http://schemas.android.com/tools"
           tools:node="remove"
           android:exported="true"
           android:permission="com.google.android.c2dm.permission.SEND">
           <intent-filter>
               <action android:name="com.google.android.c2dm.intent.RECEIVE" />
           </intent-filter>
       </receiver>

Step2: BrazeFirebaseMessagingService の派生クラスとして HalloFirebaseMessagingService を実装

BrazeFirebaseMessagingService 相当の機能はすべて残さないといけないし、一方で FirebaseMessagingService の実装クラスが複数いて読解に苦慮するのもなと思ったので、以下のような薄いクラスを書きました。

/**
 * We are using both FlutterFire and Braze to handle push notifications.
 * Unfortunately we found that Braze notifications could be handled twice
 * when integrating both of them following their official ways simply,
 * therefore we resolved the issue by having our own [FirebaseMessagingService].
 *
 * # About Token refresh
 *
 * This class inherits from [BrazeFirebaseMessagingService] intentionally,
 * since [BrazeFirebaseMessagingService.onNewToken] is needed to register a new FCM token to Braze.
 * Note that our notification service will also receive a new FCM token in dart layer
 * via [FlutterFirebaseMessagingService.onNewToken].
 */
class HalloFirebaseMessagingService: BrazeFirebaseMessagingService() {

  /**
   * FCM from Hallo backend through notification service should be handled by
   * [FlutterFirebaseMessagingReceiver] which will redirect messages to dart layer.
   * But braze_plugin provided by Braze for Flutter users doesn't provide any measure
   * to filter Braze messages, therefore we need to filter out Braze messages in native layer before
   * [FlutterFirebaseMessagingReceiver] works.
   *
   * [BrazeFirebaseMessagingService] can have a fallback service to handle messages from other than
   * Braze, but we don't use the mechanism to not have multiple [FirebaseMessagingService]
   * implementations.
   *
   * Fortunately [BrazeFirebaseMessagingService.handleBrazeRemoteMessage] is provided publicly
   * to construct own FCM handling. If it doesn't consume FCM then we should delegate it to
   * [FlutterFirebaseMessagingReceiver.onReceive].
   *
   * Also we removes [FlutterFirebaseMessagingReceiver] from AndroidManifest
   * to prevent it from handing FCM directly.
   */
  override fun onMessageReceived(remoteMessage: RemoteMessage) {
    if (handleBrazeRemoteMessage(this, remoteMessage)) {
      return
    }
    FlutterFirebaseMessagingReceiver().onReceive(this, remoteMessage.toIntent())
  }
}

handleBrazeRemoteMessage が Braze 由来のメッセージでないとみなしてハンドルしなかった場合は FlutterFirebaseMessagingReceiver のインスタンスを直接作って FlutterFire の処理に乗せ直す、ということをやっています。 AndroidManifest には FlutterFirebaseMessagingReceiver がもう宣言されていない状態で、この場で必要なときだけ明示的に仕事を渡す、というかたちにしました。

Step3: アプリの AndroidManifest.xml に、 BrazeFirebaseMessagingService のかわりに HalloFirebaseMessagingService を宣言

<!--
      <service
          android:name="com.braze.push.BrazeFirebaseMessagingService"
          android:exported="false">
          <intent-filter>
            <action android:name="com.google.firebase.MESSAGING_EVENT" />
          </intent-filter>
       </service>
-->
       <service
           android:name=".HalloFirebaseMessagingService"
           android:exported="false">
           <intent-filter>
               <action android:name="com.google.firebase.MESSAGING_EVENT" />
           </intent-filter>
       </service>

これでめでたく Braze 由来のメッセージは2重ハンドルされることがなくなり、 Hallo Backend 由来のメッセージも従来通り動作する状態を実現することができました。

結び

メルカリ ハロは Flutter アプリです。画面や機能の大部分は1ソースで Android / iOS 両方を実現しており、開発コストの圧縮と十分な性能の両立に Flutter という技術選択が寄与しました。それでも、 Flutter の範疇で解決できない問題に直面するケースはまれにあります。プッシュ通知のようなネイティブの処理に強く影響を受ける個別性の高い機能を扱うなかで、外部提供のSDKやライブラリの組み合わせによって発生した課題を、ライブラリのネイティブ実装部分を解読し工夫することによって乗り切った事例をご紹介させていただきました。

この話は iOS 編も実はありまして、method swizzling 満載の Objective-C を読むもう少し大変な冒険譚があるのですが、今回は Android だけで盛りだくさんになってしまいましたので、またの機会があればお話したいと思います。


明日の記事は danny さんと simon さんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加