📱

EASでプッシュ通知を送る

2024/12/25に公開

この記事はReact Native 全部俺 Advent Calendar 22目の記事です。

https://adventar.org/calendars/10741

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的にReact NativeおよびExpoの公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼はXにお願いします。

EASでプッシュ通知を送る

プッシュ通知は多くのアプリで必要不可欠な機能ですが、iOSとAndroidでは仕組みが大きく異なり、実装が難しくなりがちです。今回はその違いを説明しながら、Expo Application Services(EAS)を使って簡単にプッシュ通知を実装する方法を紹介します。

プッシュ通知の仕組み

iOS (APNs)

iOSのプッシュ通知はApple Push Notification service (APNs)を通して配信されます。

  1. アプリ起動時にAPNsへデバイストークンをリクエスト
  2. APNsからデバイストークンを受け取る
  3. デバイストークンをサーバーに送信して保存
  4. 通知を送るときにサーバーからAPNsに通知データを送信
  5. APNsからデバイスに通知が配信される

APNsを使うにはApple Developer Programへの登録($99/年)が必要です。また、プッシュ通知用の証明書も作成する必要があり、この作業はかなり面倒です。Apple Developer Consoleでの証明書作成や、Mac上でのキーチェーンアクセスの操作など、いくつものステップを踏む必要があります。

以前のプロジェクトでは証明書の更新を忘れて本番環境でプッシュ通知が止まってしまうという問題がありました。EASを使えばこういった運用の手間を大きく減らすことができます。

Android (FCM)

AndroidではFirebase Cloud Messaging (FCM)を使ってプッシュ通知を送ります。iOSと比べると比較的簡単です。

  1. アプリ起動時にFCMへデバイストークンをリクエスト
  2. FCMからデバイストークンを受け取る
  3. デバイストークンをサーバーに送信して保存
  4. 通知を送るときにサーバーからFCMに通知データを送信
  5. FCMからデバイスに通知が配信される

FCMはGoogleが提供する無料のサービスですが、Firebaseプロジェクトの作成と設定が必要です。手順は多いものの、iOSほど複雑ではありません。

プッシュ通知のトークンはデバイスごとに発行される一意の文字列です。アプリをアンインストールしたり、長期間使用されていない場合は無効になることがあります。FCMの場合は通知送信時のレスポンスで無効になったトークンを教えてくれるので、そのタイミングでデータベースから削除するといった対応が必要になります。

EASでのプッシュ通知の概要

EASのプッシュ通知システムを使うと、iOS(APNs)とAndroid(FCM)の違いを意識することなく、統一的なインターフェースでプッシュ通知を実装できます。

仕組みとしては以下のようになっています:

  1. Expoアプリが起動時にEASからプッシュ通知用のトークンを取得
  2. EASがAPNsとFCMそれぞれのトークンを内部で管理
  3. サーバーはEASのトークンのみを保存
  4. 通知送信時はEASのエンドポイントにリクエストを送るだけ
  5. EASが適切なプラットフォーム(APNsまたはFCM)に通知を配信

このように、開発者はプラットフォームごとの違いを意識する必要がなく、また証明書の管理なども不要になります。

以上の内容を踏まえて、具体的な実装方法を見ていきましょう。

クライアントサイド(React Native)の実装

まずはExpoアプリ側に必要なパッケージをインストールします。

$ npm install expo-notifications

通知の初期設定

アプリの起動時に通知の設定を行います。

import * as Notifications from 'expo-notifications';

export const initializeNotifications = () => {
  Notifications.setNotificationHandler({
    handleNotification: async () => ({
      shouldShowAlert: true,
      shouldPlaySound: true,
      shouldSetBadge: true,
    }),
  });
};

権限の取得とトークンの登録

通知を送るために必要な権限を取得し、トークンをサーバーに登録します。

import * as Notifications from 'expo-notifications';

export const usePushNotification = () => {
  const registerForPushNotifications = async () => {
    const { status: existingStatus } = await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;
    
    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }
    
    if (finalStatus !== 'granted') {
      Alert.alert('プッシュ通知の許可が必要です');
      return;
    }

    const token = await Notifications.getExpoPushTokenAsync({
      projectId: Constants.expoConfig.extra.eas.projectId,
    });
    
    // トークンをサーバーに送信
    await api.post('/push-tokens', { token: token.data });
  };

  return { registerForPushNotifications };
};

通知の受信処理

通知を受け取ったときの処理を実装します。

import { useNavigation } from '@react-navigation/native';

export const useNotificationHandler = () => {
  const navigation = useNavigation();

  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
      const data = response.notification.request.content.data;
      
      // 通知タップ時の画面遷移
      if (data.type === 'chat') {
        navigation.navigate('ChatRoom', { id: data.chatRoomId });
      }
    });

    return () => subscription.remove();
  }, [navigation]);
};

バッジの管理

iOSではアプリアイコンのバッジを管理する必要があります。

src/utils/badge.ts
export const resetBadgeCount = async () => {
  await Notifications.setBadgeCountAsync(0);
};

サーバーサイド(Node.js)の実装

トークンの管理

プッシュ通知のトークンをデータベースで管理します。

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export class PushTokenRepository {
  async saveToken(userId: string, token: string) {
    await prisma.pushToken.create({
      data: {
        token,
        userId,
      },
    });
  }

  async removeToken(token: string) {
    await prisma.pushToken.delete({
      where: { token },
    });
  }

  async getTokensByUserId(userId: string) {
    const tokens = await prisma.pushToken.findMany({
      where: { userId },
    });
    return tokens.map(t => t.token);
  }
}

通知の送信

EASのAPIを使って通知を送信します。

export class NotificationService {
  async sendPushNotification(token: string, title: string, body: string, data?: Record<string, unknown>) {
    const message = {
      to: token,
      sound: 'default',
      title,
      body,
      data,
    };

    await fetch('https://exp.host/--/api/v2/push/send', {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(message),
    });
  }

  async sendBatchNotifications(tokens: string[], title: string, body: string, data?: Record<string, unknown>) {
    const messages = tokens.map(token => ({
      to: token,
      sound: 'default',
      title,
      body,
      data,
    }));

    const chunks = _.chunk(messages, 100);
    
    for (const chunk of chunks) {
      await fetch('https://exp.host/--/api/v2/push/send', {
        method: 'POST',
        headers: {
          Accept: 'application/json',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(chunk),
      });
    }
  }
}

エラー処理

通知送信時のエラーを適切に処理します。

try {
  await notificationService.sendPushNotification(token, title, body);
} catch (error) {
  if (error.code === 'PUSH_TOKEN_INVALID') {
    await pushTokenRepository.removeToken(token);
  }
  logger.error('Failed to send push notification:', error);
}

このようにEASを使えば、プラットフォームごとの違いを意識することなく、統一的なインターフェースでプッシュ通知を実装できます。

特に証明書の管理から解放されるだけでも大きなメリットがあります。開発にかかる時間を大幅に減らせるので、個人開発やスタートアップの開発者にはおすすめの選択肢です。

Discussion