😺

【Flutterアプリ開発】プッシュ通知の実装で継続率(D1RR)が3倍になった話

に公開

突然ですが、皆さんはプッシュ通知について、どのような印象を持っていますか?
私は正直あまりいい印象を持っていないので、基本的にはオフにしてしまっています。
ところがこの度、自分が開発しているアプリにプッシュ通知を実装したところ、インストール翌日にもアプリを起動したユーザーの割合(D1RR = Day 1 Retention Rate)がこれまでの3倍に跳ね上がりました。

開発しているアプリはこちらのAIチャットアプリです。

Android

https://play.google.com/store/apps/details?id=org.oden.catgpt

iOS

https://apps.apple.com/jp/app/にゃんチャット/id6480329103

猫キャラクターのAIとチャットができるアプリです。

AIとのチャットというと、基本的にはユーザーからのインプットがトリガーとなって会話が開始されます。これはつまり、AIからユーザーに対して能動的に語りかけてくるという事は永遠に無いということになります。
折角可愛いキャラクターを作って仲良くなれたっていうのに、それではちょっと寂しいじゃないのって事で、猫からも積極的にメッセージ送ってきて欲しいーという私の願望を実現することにしました(^^)

猫からの自発的なメッセージ送信という事が実現できたとしたら、メッセージが来たという事をユーザーに通知したいところです。わざわざアプリを定期的にチェックしてもらうわけにもいかないですからね。
そこで登場するのがプッシュ通知という仕組みです。

このプッシュ通知の実装に意外と苦労してしまったので、ポイントをまとめておこうと思います。

通知の種類

実はアプリの通知には2種類あります。
ローカル通知とプッシュ通知です。
どっちも通知を表示させるためのものですが、技術的には明確に差異があるので、予め認識しておいた方が混乱せずに済みます。

ローカル通知

アプリのプログラム内から通知を発生させます。
インターネットに接続されていない環境下でも通知することができます。バックグラウンド処理で、任意のタイミングで通知を表示させるという使い方がほとんどだと思います。

プッシュ通知

FCM(Firebase Cloud Messaging)というGoogleのサービスを使い、サーバーからアプリに向けて通知を送信する事が出来ます。
Firebaseといえば、他にもFirestoreというデータベースやAuthenticationなどのユーザー認証サービスなどがあり、FCMもそんなFirebase内のサービスの一つです。ということは、もっと根源的な(OSよりの低レイヤーな)通知の仕組みがあって、他になんか選択肢があるのかな?と思ったりもしたのですが、どうやらAndroidでのプッシュ通知の仕組みはこれが本家のサービスって事で良いみたいです。というのも、元々はGoogle Cloud Messaging (GCM) というものが標準サービスとして提供されていましたが、GCMは2019年に完全に廃止され、後継サービスであるFCMへの移行が必須となりました。 そのため、現在Androidのプッシュ通知はFCMを利用するのが標準的な方法となっています。

(Flutterでの)実装にあたって気をつけといた方が良いこと

プッシュ通知にも2種類のメッセージタイプがある

プッシュ通知で送信出来るメッセージには、通知メッセージとデータメッセージの2種類があります。
通知メッセージには、アプリの通知に表示するメッセージをそのまま記述します。

プッシュ通知は通知のカスタマイズの自由度が低い

プッシュ通知は、単純に受信したメッセージを表示しつつ、データメッセージでアプリにパラメーターを渡します。
データメッセージを使って、アプリ側は何らかの処理を行う事が出来るのですが、通知そのものを変更するというような事は出来ません
この事は、プッシュ通知のメッセージというのは、到達した時にはアプリが処理を実行する前に、OSにより通知が描画されているため、通知そのものをカスタマイズするという用途には使えない、と理解すれば納得できます。
タップした時に特定の画面に遷移したり、通知音を細かくカスタマイズしたり、という事はできないのです。何故なら、そういうことをしようとする前に、既に通知メッセージが表示されてしまっているから。
ここが、ちょっと混乱したとというか、ベストプラクティスにたどり着くのに手間取った最大のポイントだったりします。
私がやりたかったことの一つが、通知を受信した時の着信音を変えるという事だったのですが、それすらプッシュ通知だけでは実現する事が出来なかったのです。

プッシュ通知からのローカル通知

ではどうしたかというと、そのようなことを実現するのがデータメッセージとローカル通知の組み合わせです。
プッシュ通知を受け取ったら、その通知は表示させず、代わりにローカル通知を発生させます。

ローカル通知であれば、音声の設定も出来ますし、表示するメッセージのカスタマイズも可能です。

通知が2種類あるという事を知らないと中々辿り着けないと思います(私はそうでした)ので、これから実装していく方は気をつけて頂ければと思います。

この手法により通知音のカスタマイズ以外にも、

  • 動的なコンテンツ生成: サーバーから送られたデータをもとに、アプリ内で翻訳したり、ユーザーの名前を入れたり
  • リッチな通知表現: Androidで画像付きの通知やアクションボタン付きの通知を柔軟に作ったり
    という事にも割と柔軟に対応する事ができます。

なお、この方法を確実に行うには、バックグラウンドや終了状態でもメッセージを受け取ってローカル通知を出すために、サーバーから送信するデータメッセージで、Androidでは priority: 'high' を、iOSでは content-available: 1、apns-push-type:'background'に設定する必要があります。
※ただし、iOSではバッテリー消費などを考慮して、OSの判断でバックグラウンド処理が遅延したり、実行されない場合もあるようです。

サンプルコード

実際にコードがあった方が分かりやすいと思いますので、主要な部分を貼っておきます。
※FlutterプロジェクトへのFirebaseの追加設定は完了しているものとします。

  • サーバー側 (index.ts): FCMへデータメッセージを送信します。
  • Flutter側 (notification_service.dart): Firebaseの初期化、FCMトークン管理、バックグラウンドメッセージのハンドリングなど、通知関連の初期設定を統括します。
  • Flutter側 (local_notification_service.dart): flutter_local_notificationsを使い、実際にデバイスに通知を表示する処理を担当します。
  • Flutter側 (main.dart): アプリ起動時に通知サービスを初期化します。

サーバー側のコード

index.ts

import { getMessaging } from "firebase-admin/messaging";
async function sendPushNotification(
  fcmToken: string,
  catId: string,
  catName: string,
  lang?: string
): Promise<boolean> {
  try {
    const message = {
      token: fcmToken,
      // notificationフィールドを削除してデータメッセージのみに
      data: {
        catId,
        catName,
        type: 'cat_message',
        lang: lang || 'en'  // 言語コードを追加
      },
      // Androidの高優先度設定(データメッセージでも配信保証のため)
      android: {
        priority: 'high' as const,	//アプリがスリープ状態の時にもメッセージが届くように
        ttl: 86400000, // 24時間のTTL
      },
      // APNsの設定(データメッセージでもiOSで確実に配信されるように)
      apns: {
        headers: {
          'apns-priority': '10', // 高優先度
          'apns-push-type': 'background'	//iOS 13以降、バックグラウンドでアプリを起動させるサイレントプッシュ通知のために必要
        },
        payload: {
          aps: {
            'content-available': 1 // バックグラウンド処理を有効化
          }
        }
      }
    };

    const response = await messaging.send(message);
    return true;
  } catch (error) {
    console.error('Failed to send push notification:', error);
    return false;
  }
}

アプリ(Flutter)のコード

pubspec.yaml

    # Firebase関連
    firebase_core: ^2.24.2
    firebase_messaging: ^14.7.10

    # ローカル通知
    flutter_local_notifications: ^16.3.0

※バージョンは執筆時点のもので、今は古くなっているかもしれません。最新の推奨バージョンは各ライブラリの公式ページで確認してください

notification_service.dart

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

import 'local_notification_service.dart'; // 修正

/// 通知初期化サービス - アプリ全体の通知設定を管理
class NotificationInitService {
  /// アプリ起動時の通知初期化(main.dartで呼び出し)
  static Future<void> initialize(GlobalKey<NavigatorState> navigatorKey) async {
    try {
      // 1. ローカル通知サービス初期化(navigatorKeyを渡す)
      await LocalNotificationService.initialize(navigatorKey);

      // 2. 通知権限リクエスト(Android 13以降で権限リクエストが必要)
      await _requestNotificationPermissions();

      // 3. FCMトークン取得・保存(ログイン済みの場合)
      await _saveCurrentFcmToken();

      // 4. 認証状態変化時のトークン保存設定
      FirebaseAuth.instance.authStateChanges().listen((user) {
        if (user != null) {
          _saveCurrentFcmToken();
        }
      });

      // 5. バックグラウンドメッセージハンドラ設定
      FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

      // 6. トークン更新時のハンドラ設定
      FirebaseMessaging.instance.onTokenRefresh.listen((token) {
        _saveFcmTokenToFirestore(token);
      });

      // 7. フォアグラウンドメッセージハンドラ設定
      _setupForegroundMessageHandler();
    } catch (e) {
      debugPrint('❌ Error initializing notifications: $e');
    }
  }

  /// 通知権限リクエスト
  static Future<void> _requestNotificationPermissions() async {
    final messaging = FirebaseMessaging.instance;
    final settings = await messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false,
    );
    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      debugPrint('✅ 通知権限が許可されました');
    } else {
      debugPrint('⚠️ 通知権限が拒否されました');
    }
  }

  /// フォアグラウンドメッセージハンドラ設定
  static void _setupForegroundMessageHandler() {
    FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
      debugPrint('Foreground message received: ${message.data}');
      // データメッセージを受信したらローカル通知を表示
      await LocalNotificationService.showFromFirebaseData(message);
    });
  }

  /// 現在のFCMトークンを取得してFirestoreに保存
  static Future<void> _saveCurrentFcmToken() async {
    try {
      final user = FirebaseAuth.instance.currentUser;
      if (user == null) return;

      final token = await FirebaseMessaging.instance.getToken();
      if (token != null) {
        await _saveFcmTokenToFirestore(token);
      }
    } catch (e) {
      debugPrint('FCMトークン取得エラー: $e');
    }
  }

  /// FCMトークンをFirestoreに保存
  static Future<void> _saveFcmTokenToFirestore(String token) async {
    try {
      final user = FirebaseAuth.instance.currentUser;
      if (user == null) return;

      await FirebaseFirestore.instance.collection('users').doc(user.uid).set(
        {'fcmToken': token},
        SetOptions(merge: true),
      );
      debugPrint('FCM Token saved to Firestore.');
    } catch (e) {
      debugPrint('❌ FCMトークン保存エラー: $e');
    }
  }
}

/// バックグラウンドメッセージハンドラ(トップレベル関数必須)
//最適化による削除防止のために@pragma('vm:entry-point')を指定します
('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  debugPrint("Handling a background message: ${message.messageId}");
  // データメッセージを受信したらローカル通知を表示
  await LocalNotificationService.showFromFirebaseData(message);
}```

### local_notification_service.dart
```dart
import 'dart:convert';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

class LocalNotificationService {
  static final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
  static bool _initialized = false;
  static String? _pendingPayload; // コールドスタート(アプリ終了状態)からの起動時に使う
  static GlobalKey<NavigatorState>? _navigatorKey; // 画面遷移に使う

  /// 初期化
  static Future<void> initialize(GlobalKey<NavigatorState> navigatorKey) async {
    if (_initialized) return;
    _navigatorKey = navigatorKey;

    // Android/iOSの初期化設定
    const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
    const DarwinInitializationSettings iOSSettings = DarwinInitializationSettings();

    await _notifications.initialize(
      const InitializationSettings(android: androidSettings, iOS: iOSSettings),
      // 通知がタップされた時の処理(アプリがフォアグラウンド・バックグラウンドの時)
      onDidReceiveNotificationResponse: (response) {
        if (response.payload != null) {
          _navigateToRoute(response.payload!);
        }
      },
    );

    // Androidの通知チャンネル作成
    await _createNotificationChannel();
    
    // アプリが終了状態から通知によって起動された場合の処理
    await _checkLaunchDetails();
    
    _initialized = true;
    debugPrint('LocalNotificationService initialized.');
  }

  static Future<void> _createNotificationChannel() async {
    const AndroidNotificationChannel channel = AndroidNotificationChannel(
      'default_channel',
      'Default Channel',
      description: 'This is the default channel for notifications.',
      importance: Importance.max, // 重要度を最大に設定
    );
    await _notifications
        .resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(channel);
  }

  /// アプリが通知によって起動されたかチェック
  static Future<void> _checkLaunchDetails() async {
    final details = await _notifications.getNotificationAppLaunchDetails();
    if (details?.didNotificationLaunchApp == true) {
      _pendingPayload = details?.notificationResponse?.payload;
    }
  }

  /// 画面遷移を実行
  static void _navigateToRoute(String payload) {
    debugPrint('Navigating with payload: $payload');
    try {
      // payloadはJSON文字列を想定
      final data = jsonDecode(payload);
      final route = data['route'] as String?;
      if (route != null) {
        // navigatorKeyを使って画面遷移
        _navigatorKey?.currentState?.pushNamed(route);
      }
    } catch (e) {
      debugPrint('Error navigating from notification: $e');
    }
  }

  /// コールドスタートからの起動時に保留されていた画面遷移を実行
  static void processPendingNavigation() {
    if (_pendingPayload != null) {
      _navigateToRoute(_pendingPayload!);
      _pendingPayload = null;
    }
  }

  /// Firebaseのデータメッセージからローカル通知を表示
  static Future<void> showFromFirebaseData(RemoteMessage message) async {
    final data = message.data;
    final catName = data['catName'] ?? 'あなたの猫';
    
    // ペイロードに遷移情報をJSON文字列として格納
    final payload = jsonEncode({
      'route': '/chat', // チャット画面に遷移
      'catId': data['catId'],
    });

    return showNotification(
      title: '$catNameからメッセージです',
      body: '「${_createMessageFromBody(data)}」', // メッセージ本文を生成
      payload: payload,
    );
  }
  
  /// テスト用・汎用のローカル通知表示
  static Future<void> showNotification({
    required String title,
    required String body,
    required String payload,
  }) async {
    if (!_initialized) await initialize(_navigatorKey!); // 万が一初期化されていなければ実行
    
    const notificationDetails = NotificationDetails(
      android: AndroidNotificationDetails(
        'default_channel',
        'Default Channel',
        importance: Importance.max,
        priority: Priority.high,
      ),
      iOS: DarwinNotificationDetails(
        presentAlert: true,
        presentBadge: true,
        presentSound: true,
      ),
    );

    await _notifications.show(
      DateTime.now().millisecondsSinceEpoch.remainder(100000), // ユニークなID
      title,
      body,
      notificationDetails,
      payload: payload,
    );
  }

  // データから簡単なメッセージを生成するヘルパー関数(例)
  static String _createMessageFromBody(Map<String, dynamic> data) {
    // サーバーから送られてきた言語コード(lang)に応じてメッセージを切り替える
    final lang = data['lang'] ?? 'en';
    switch (lang) {
      case 'ja':
        return "元気にしてる?";
      default:
        return "How are you doing?";
    }
  }
}

main.dart での使用例

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'notification_service.dart';
import 'local_notification_service.dart';

// ダミーページ
class ChatPage extends StatelessWidget {
  
  Widget build(BuildContext context) => Scaffold(appBar: AppBar(title: Text('チャット')));
}
class ProfilePage extends StatelessWidget {
  
  Widget build(BuildContext context) => Scaffold(appBar: AppBar(title: Text('プロフィール')));
}


void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();

  // 🔔 通知システム全体を初期化(navigatorKeyを渡す)
  await NotificationInitService.initialize(MyApp.navigatorKey);

  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // アプリ全体で共有するNavigatorKey
  static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey, // MaterialAppに設定
      home: HomePage(),
      routes: {
        '/chat': (context) => ChatPage(),
        '/profile': (context) => ProfilePage(),
      },
    );
  }
}

class HomePage extends StatefulWidget {
  
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  
  void initState() {
    super.initState();
    // 最初のフレーム描画後に、コールドスタート時の保留中ナビゲーションを実行
    WidgetsBinding.instance.addPostFrameCallback((_) {
      LocalNotificationService.processPendingNavigation();
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('通知テストアプリ')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // テスト通知を送信
            LocalNotificationService.showNotification(
              title: 'テスト通知',
              body: 'タップしてチャット画面へ移動します。',
              payload: jsonEncode({'route': '/chat'}),
            );
          },
          child: Text('テスト通知を送信'),
        ),
      ),
    );
  }
}

プラットフォーム毎の追加設定

iOS側の設定 (Xcode)

iOSでバックグラウンドプッシュ通知を確実に受け取るには、Xcodeでの設定が必要になります。
XCodeの左のナビゲーターからプロジェクトのルート(Runner)を選択し、中央のエディタで Runner ターゲットを選択します。
上部にある Signing & Capabilities タブを開きます。

  • Capability ボタンをクリックして、以下の2つを追加してください。
    Push Notifications: これを追加しないと、そもそもプッシュ通知が一切届きません。
    Background Modes: 今回のようにバックグラウンドで処理を動かしたい場合に必要です。追加したら、中にある Remote notifications の項目にチェックを入れてください。

Android側の設定

firebase_messagingライブラリがビルド時に自動でAndroidManifest.xmlに設定を追加してくれるようです。
ただし、通知エリアに表示されるデフォルトのアイコンを変更したい場合は、別途 AndroidManifest.xml ファイルに以下のメタデータを <application> タグ内に追記する必要があります。

<meta-data
  android:name="com.google.firebase.messaging.default_notification_channel_id"
  android:value="high_importance_channel" />
<meta-data
  android:name="com.google.firebase.messaging.default_notification_icon"
  android:resource="@drawable/ic_notification" />

※アイコンはandroid/app/src/main/res/drawableに格納します。

まとめ

これまで敬遠してきたプッシュ通知ですが、アプリの継続率向上に役立つものということが(私の中では)明らかになりました。実装は中々大変でしたので、この記事が少しでもアプリ開発者の皆様のお役に立てれば幸いです🐱

Discussion