LoginSignup
21
15

More than 3 years have passed since last update.

Expo SDK37のBare WorkflowでNotificationsを使ってみる

Last updated at Posted at 2020-04-03

はじめに

4月1日にExpoのSDK37がリリースされました。詳しい変更内容は下記のURLから見ることができます。

https://dev.to/expo/expo-sdk-37-is-now-available-69g
https://github.com/expo/expo/blob/master/CHANGELOG.md#3700

大きな変化として、まずはEject後の選択肢がExpoKitからBare Workflowへ完全に移行した点が挙げられます。
そして単にリプレイスされただけという訳ではなく、これまでManaged Workflowのみ対応していたNotifications APIがBare Workflowでも使用できるようになり、APIの内容も変化しています。

この記事ではSDK37のNotifications APIをBare Workflowで使用するための導入方法や検証結果をまとめてみます。
ちなみに途中でエラー解決のためにソースをいじっていますが、私自身がAPIへの理解を深めることを目的とした其の場凌ぎの感があるので、あくまでそれら全てをお勧めしているわけではありません。

プロジェクトを作成

既存のプロジェクトをアップデートするのではなく、プロジェクトを作成するところからやってみます。
まずは(していなければ)expo-cliをアップデート。

$ npm update -g expo-cli

expo initしてBare workflowのテンプレートからプロジェクトを作成します。

$ expo init BareNotification
? Choose a template:
❯ minimal               bare and minimal, just the essentials to get you started

作成されたプロジェクトディレクトリを開き、package.jsonを見ると見慣れないexpo-updatesというパッケージがデフォルトでインストールされているのがわかります。後述しますがこのパッケージは今のところリリースビルドする時にエラーの原因になってしまいます。

バンドル/パッケージ名に注意

ここで注意したいのが、Expoの以前のバージョンでもそうですが、最初からBare Workflowのテンプレートでプロジェクトを作成した場合はiOSのbundleIdentifierやAndroidのパッケージ名がプロジェクト名を元に勝手に命名されてしまうという点です。変更したい場合は一括置換、およびAndroidのjavaディレクトリの修正などを適切に行ってください。
(なので、実際の開発ではManaged Workflowでプロジェクトを作成しapp.jsonのバンドル・パッケージ名、アイコン、スプラッシュ等を編集してからEjectというのがスマートです。)

パッケージをインストール

画面の実装の方は後述するとして設定を先に行いますが、最初に使用するパッケージをインストールしておきます。

$ npm install expo-notifications expo-web-browser \
@react-navigation/native @react-navigation/stack \
react-native-reanimated react-native-gesture-handler \
react-native-screens react-native-safe-area-context \
@react-native-community/masked-view

多く見えますが、react-navigationの依存パッケージが結構あります。

Notificationsの設定

公式のREADMEに沿って設定を行います。所々リンクが通っていないので、察しながらやっていきます。
https://github.com/expo/expo/tree/master/packages/expo-notifications#installation-in-bare-react-native-projects

iOSの設定

ライブラリをインストール

通常のプロセスですが、上記のnpmパッケージをインストールした上で、CocoaPodsでライブラリをインストールします。

ライブラリをインストール
$ cd ios
$ pod install
$ cd ..

Capabilityを追加

Xcodeでプロジェクト(.xcworkspace)を開き、Signing & CapabilitiesタブでPush NotificationsのCapabilityを追加します。
BareNotifications_xcodeproj.png

Team、Bundle IdentifierなどのSigningの設定も適宜行ってください。

プロジェクトにAPNSキーを紐付ける

Managed WorkflowではExpoが勝手にやってくれていた部分を手動で行います。(といっても、これもコマンドを叩けばいいだけです。)

$ expo credentials:manager
? What do you want to do?
  ---- Current project actions ----
❯ Use existing Push Notifications Key in current project
  Use existing Distribution Certificate in current project
  ---- Account level actions ----
  Remove Provisioning Profile
  Add new Push Notifications Key
  Remove Push Notification credentials
  Update Push Notifications Key
  Add new Distribution Certificate
  Remove Distribution Certificate
  Update Distribution Certificate

色々と情報が出たあとに何をしたいか聞かれるので、Expoを使ってすでにPush Notifications Keyが生成されている場合はUse existing Push Notifications Key in current projectを選択し、プロジェクトにキーを紐付けます。
Push Notifications Keyはデベロッパーごとに2つまでと決まっているので基本的にはプロジェクトを跨って使い回すことになります。まだ作成していない場合はAdd new Push Notifications Keyを選択したうえで生成されたキーをプロジェクトに紐付けてください。

Androidの設定

AndroidではFCM(Firebase Cloud Messaging)を使った実装になっているため、Firebaseプロジェクトが必要になります。ここでは、すでにFirebaseプロジェクトを作成しているという前提で進めます。

AndroidプロジェクトとFirebaseプロジェクトの紐付け

設定項目は具体的にはFirebase Android 構成ファイル(google-services.json)の設置とGradleファイルの編集などですが、アップデートが速いので、下記の公式のドキュメントに沿って行ってください。

Android プロジェクトに Firebase を追加する
https://firebase.google.com/docs/android/setup?hl=ja

FCMサーバーキーのアップロード

Expoから通知を送るためには、Expo側にFCMサーバーキーをアップロードする必要があります。
Firebase Consoleの設定 > 「クラウド メッセージング」タブ内のサーバーキー(下画像)をコピーし、
Test_Project_–_Firebase_console.png
下記のコマンドでこれをアップロードします。

$ expo push:android:upload --api-key <サーバーキー>

[参考]Uploading Server Credentials
https://docs.expo.io/versions/latest/guides/using-fcm/#uploading-server-credentials

画面の作成

さて、設定が完了したので確認のための画面を作りますが、ここでは以前下記の記事で作成した画面遷移のパターンを試してみることにします。
https://qiita.com/mildsummer/items/6e13800c04a91dd3c2d6
react-navigationがv4系からv5系で割と大きく変化しているようなので、それも修正します。ついでにHooksに書き直した結果、最終的なソースがこちらです。

App.js
import "react-native-gesture-handler";
import React, { useEffect, useState, createRef } from "react";
import { Text, View, Alert, Platform } from "react-native";
import { NavigationContainer, StackActions } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import * as Notifications from "expo-notifications";
import * as WebBrowser from "expo-web-browser";

function Top() {
  const [token, setToken] = useState(null);

  useEffect(() => {
    init().catch(console.error);
  }, []);

  async function init() {
    const settings = await Notifications.requestPermissionsAsync();
    if (settings.granted || settings.status === 1) {
      const { data: token } = await Notifications.getExpoPushTokenAsync({
        experienceId: "@<ユーザー名>/<プロジェクト名>"
      });
      console.log("[EXPO PUSH TOKEN]", token);
      console.log(
        `Try running the command "node pushNotification.js ${
          token.match(/\[(.+)]/)[1]
          } [SCREEN NAME(A|B|C) or URL] [ID]"`
      );
      console.log(
        `ex) $ node pushNotification.js ${token.match(/\[(.+)]/)[1]} A 123`
      );
      setToken(token);
    } else {
      console.log("permission denied");
    }
  }

  return (
    <View
      style={{
        flex: 1,
        width: "100%",
        justifyContent: "center",
        alignItems: "center"
      }}
    >
      <Text>{token || "getting token..."}</Text>
      <Text>Please look your console</Text>
    </View>
  );
}

const Stack = createStackNavigator();
const navigationRef = createRef();
const stackScreens = [
  { name: "A", color: "#6200EE" },
  { name: "B", color: "#03DAC6" },
  { name: "C", color: "#B00020" }
];
let currentNavigationState = null;

export default function App() {
  let subscription = null;

  useEffect(() => {
    Notifications.setNotificationHandler({
      handleNotification: async (notification) => {
        // console.log(notification);
        return {
          shouldShowAlert: true,
          shouldPlaySound: true,
          shouldSetBadge: true,
        };
      }
    });
    Notifications.addNotificationResponseReceivedListener(response => {
      console.log("response", response);
      respond(response.notification);
      Alert.alert("通知が届きました", JSON.stringify(response.notification.request.content.data));
    });
    subscription = Notifications.addNotificationReceivedListener((notification) => {
      console.log("received notification");
      console.log(notification);
      Alert.alert("通知が届きました", JSON.stringify(notification.request.content.data), [
        {
          text: "キャンセル",
          style: "cancel"
        },
        {
          text: "移動する",
          onPress: () => {
            respond(notification);
          }
        }
      ]);
    });
  }, []);

  function respond(notification) {
    const data = Platform.OS === 'ios' ? notification.request.content.data?.body : notification.request.content.data;
    if (data && data.url) {
      WebBrowser.openBrowserAsync(data.url).catch(console.error);
    } else if (data && navigationRef.current) {
      const routeName = data.routeName || "top";
      const params = data.params;
      if (currentNavigationState && currentNavigationState.routes.length > 1) {
        // 同階層の場合はスタック追加せず差し替える
        navigationRef.current.dispatch(StackActions.replace(routeName, params));
      } else {
        navigationRef.current.navigate(routeName, params);
      }
    }
  }

  return (
    <NavigationContainer
      ref={navigationRef}
      onStateChange={(currentState) => {
        currentNavigationState = currentState;
      }}
    >
      <Stack.Navigator initialRouteName="top">
        <Stack.Screen name="top" options={{ headerShown: false }} component={Top} />
        {stackScreens.map((screen) => (
          <Stack.Screen
            key={screen.name}
            name={screen.name}
            options={({ route }) => {
              headerTitle: `${screen.name} ID:${route.params.id}`
            }}
          >
            {({ route }) => (
              <View
                style={{
                  flex: 1,
                  width: "100%",
                  justifyContent: "center",
                  alignItems: "center",
                  backgroundColor: screen.color
                }}
              >
                <Text style={{ color: "#FFF", fontSize: 16 }}>screen name</Text>
                <Text style={{ color: "#FFF", fontSize: 24 }}>{screen.name}</Text>
                <Text style={{ color: "#FFF", fontSize: 16, marginTop: 24 }}>ID</Text>
                <Text style={{ color: "#FFF", fontSize: 24 }}>
                  {route.params.id || "-"}
                </Text>
              </View>
            )}
          </Stack.Screen>
        ))}
      </Stack.Navigator>
    </NavigationContainer>
  );
}

挙動は変わりませんが、v5ではReactの要素として(JSXの中に)スクリーンを定義するようになっています。
expoパッケージ内のNotificationsとexpo-notificationsでAPIが結構変わっているのがわかるでしょうか。詳しい内容はREADMEを参照してください。
また、後述する検証の結果、iOSとAndroidでnotificationオブジェクト内のdataの入り方が違っていたので、Platform.OSを見て処理を分けています。
getExpoPushTokenAsyncしている箇所では、オプションのexperienceId@<ユーザー名>/<プロジェクト名>の形式で入れてください。プロジェクト名は正確にはapp.json内のexpo.slugの値(のはず)です。

ExpoのpushAPIを叩く処理

上述した以前の記事と同じように、便宜的にAPIを叩く処理を用意しておきます。

pushNotification.js
const request = require("request");

const [, , token, routeName, id] = process.argv;

const isWebView = /http/.test(routeName);

request(
  {
    url: "https://expo.io/--/api/v2/push/send",
    method: "POST",
    json: {
      to: `ExponentPushToken[${token}]`,
      title: "通知サンプル",
      body: "ここに説明文が入ります",
      data: isWebView
        ? {
            url: routeName,
            params: { id }
          }
        : {
            routeName,
            params: { id }
          },
      _displayInForeground: true
    }
  },
  function(error, response, body) {
    if (error) {
      console.log(error);
    } else {
      console.log(body);
    }
  }
);

実機で検証してみる

Expo Client

READMEには、Expo Clientではまだ使用できないと書いてあります。
https://github.com/expo/expo/tree/master/packages/expo-notifications#installation-in-managed-expo-projects

実際に確認すると、expo-notificationsをインポートしている時点でNative module cannot be null.となり、使用できません。
PNGイメージ 48.png

Xcode + iPhoneでデバッグビルドを確認

iOSアプリをXcodeで動かしてみます。端末を繋ぎ、Product→Runします。
トークンが表示され、
PNGイメージ_48.png

先ほどのスクリプトで通知を送ってみます。

$ node pushNotification.js <トークン文字列部分> A 123

PNGイメージ_50.png
PNGイメージ 51.png

アプリのforeground/bacground状態共に問題なく通知が届き、通知に含まれるデータを取得することでページ遷移の実装も正常に動いています。

私の場合は最初は上手くいかなかったのですが、上述したexpo credentials:managerコマンドによって既存のPush Notifications Keyを削除し、新しく生成しなおすと成功しました。

Androidでデバッグビルドを確認

USBデバッグが有効になっている端末をつなぎadb devicesでデバイスが検知できているのを確認したうえで、react-nativeコマンドを叩いて起動します。

$ npx react-native run-android

PNGイメージ_50.png
FirebaseとFCMとの連携が上手くいっていれば、Androidでも問題なくトークンが表示されます。

しかし実際に通知を送ってみると、アプリがクラッシュしてしまいました。
adb logcatで探ってみると、このようなエラーが発生しています。

java.lang.UnsupportedOperationException: Could not put a value of class android.os.Bundle to bundle.

どうやらこのエラーは、通知のパラメータにtitlebodyだけでなくdataを入れると発生することがわかりました。
環境依存などではなく純粋にネイティブモジュールのバグのようなので、モジュール自体に手を入れる以外解決方法はありません。
余裕があればPRを送るとして、一時的な解決方法としてnode_modules/expo-notificationsの中を編集してみます。

dataエラー(Could not put a value of class android.os.Bundle to bundle.)回避

dataパラメータを指定したい場合、まずはunimodules内のMapArgumentsを新しいファイルで置き換えます。

node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/MapArguments.java
package expo.modules.notifications.notifications;

import android.os.Bundle;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.unimodules.core.arguments.ReadableArguments;

public class MapArguments implements ReadableArguments {
  private Map<String, Object> mMap;

  public MapArguments() {
    mMap = new HashMap<>();
  }

  public MapArguments(Map<String, Object> map) {
    mMap = map;
  }

  @Override
  public Collection<String> keys() {
    return mMap.keySet();
  }

  @Override
  public boolean containsKey(String key) {
    return mMap.containsKey(key);
  }

  @Override
  public Object get(String key) {
    return mMap.get(key);
  }

  @Override
  public boolean getBoolean(String key) {
    return getBoolean(key, false);
  }

  @Override
  public boolean getBoolean(String key, boolean defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Boolean) {
      return (Boolean) value;
    }
    return defaultValue;
  }

  @Override
  public double getDouble(String key) {
    return getDouble(key, 0);
  }

  @Override
  public double getDouble(String key, double defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Number) {
      return ((Number) value).doubleValue();
    }
    return defaultValue;
  }

  @Override
  public int getInt(String key) {
    return getInt(key, 0);
  }

  @Override
  public int getInt(String key, int defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Number) {
      return ((Number) value).intValue();
    }
    return defaultValue;
  }

  @Override
  public String getString(String key) {
    return getString(key, null);
  }

  @Override
  public String getString(String key, String defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof String) {
      return (String) value;
    }
    return defaultValue;
  }

  @Override
  public List getList(String key) {
    return getList(key, null);
  }

  @Override
  public List getList(String key, List defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof List) {
      return (List) value;
    }
    return defaultValue;
  }

  @Override
  public Map getMap(String key) {
    return getMap(key, null);
  }

  @Override
  public Map getMap(String key, Map defaultValue) {
    Object value = mMap.get(key);
    if (value instanceof Map) {
      return (Map) value;
    }
    return defaultValue;
  }

  @Override
  public boolean isEmpty() {
    return mMap.isEmpty();
  }

  @Override
  public int size() {
    return mMap.size();
  }

  @Override
  public ReadableArguments getArguments(String key) {
    Map value = getMap(key);
    if (value != null) {
      return new MapArguments(value);
    }
    return null;
  }

  @Override
  public Bundle toBundle() {
    Bundle bundle = new Bundle();
    for (String key : mMap.keySet()) {
      Object value = mMap.get(key);
      if (value instanceof String) {
        bundle.putString(key, (String) value);
      } else if (value instanceof Integer) {
        bundle.putInt(key, (Integer) value);
      } else if (value instanceof Double) {
        bundle.putDouble(key, (Double) value);
      } else if (value instanceof Long) {
        bundle.putLong(key, (Long) value);
      } else if (value instanceof Boolean) {
        bundle.putBoolean(key, (Boolean) value);
      } else if (value instanceof ArrayList) {
        bundle.putParcelableArrayList(key, (ArrayList) value);
      } else if (value instanceof Map) {
        bundle.putBundle(key, new MapArguments((Map) value).toBundle());
      } else if (value instanceof Bundle) { // Bundleクラスインスタンスをセットする場合を追加
        bundle.putBundle(key, (Bundle) value);
      } else {
        throw new UnsupportedOperationException("Could not put a value of " + value.getClass() + " to bundle.");
      }
    }
    return bundle;
  }
}

続いてnode_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.javaを編集します。

node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/NotificationSerializer.java
package expo.modules.notifications.notifications;

import android.os.Bundle;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
- import org.unimodules.core.arguments.MapArguments; // この行を削除

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import androidx.annotation.Nullable;
+ import expo.modules.notifications.notifications.MapArguments; // この行を追加
import expo.modules.notifications.notifications.interfaces.NotificationTrigger;
import expo.modules.notifications.notifications.model.Notification;
import expo.modules.notifications.notifications.model.NotificationContent;
import expo.modules.notifications.notifications.model.NotificationRequest;
import expo.modules.notifications.notifications.model.NotificationResponse;
import expo.modules.notifications.notifications.model.triggers.FirebaseNotificationTrigger;
import expo.modules.notifications.notifications.triggers.DateTrigger;
import expo.modules.notifications.notifications.triggers.TimeIntervalTrigger;

public class NotificationSerializer {
  // 省略
}

こちらはimportしているモジュールを置き換えているだけです。

再度確認してみる

ネイティブモジュールを修正した後、再度確認してみると通知を処理できるようになりました。
この際、リスナー関数の引数になるNotificationオブジェクトの構造がiOSと違ったので、上述したように処理を分けています。
Screenshot_20200403-165400
Screenshot_20200403-165400

Androidにおいてはアプリが終了している状態で届いた通知をタップしたときに、リスナー関数が呼ばれないなどiOSと少し挙動が異なります。これについては別記事で改めて詳しく調べようと思います。

Xcode + TestFlight + iPhoneでリリースビルドを確認

プロジェクトをArchiveしApp Storeにアップロード、TestFlightで確認というプロセスでiOSのリリースビルドを確認します。
この時点でまずはApp Store Connectで同じバンドル名の新規Appを追加しておく必要があります。

さて、このまま普通にProduct→Archiveしてすんなりビルドできればいいのですが、
もしnvmでNode.jsのバージョン管理をしている場合は最後の方でこのようなエラーが発生してしまいます。

No specify using Node.js version.
Command PhaseScriptExecution failed with a nonzero exit code

これ自体はTARGET設定のBuild Phasesの中にあるBundle Expo Assetsというフェーズの行頭に、nvmへのPATHを通している.bashrcなり.bash_profileなりへの参照を追加すれば解決します。
BareNotifications_xcodeproj.png

しかしこれで一旦はビルドが通りTestFlightで確認しようと思っても、アプリを起動した瞬間にクラッシュしてしまうと思います。
Expo SDK37以降、Bare Workflowではデフォルトでexpo-updatesパッケージがインストールされており(これはインストールの時にも書きましたが)、この設定を正しく行わないと起動時にエラーが発生してしまうからです。

expo-updates自体はBare WorkflowでOTAアップデートができるようになるありがたいパッケージです。ただ、今現在expo-updatesREADMEを見ても設定方法がいまいちよくわからない(特に、app.manifestの書き方など)のと、今回はNotificationsの検証がしたいだけなので、expo-updatesを使用している部分を削除することで検証を進めたいと思います。

expo-updates使用部分を削除

npmパッケージやPodsをアンインストールするかどうかはお任せするとして、コード内のexpo-updates使用部分を削除します。基本的にはREADMEに書いてあるInstallationの逆のことをすればよいはずです。

AppDelegate.mを修正

expo-updatesが導入される前の状態に戻します。
Githubでみると2019年4月のこの辺りです(わりと最近まで変化無いようです)。コピペして、モジュール名がHelloWorldになっている部分をmainに直します。
https://github.com/expo/expo/blob/c9802eba34e4b88335aac24e76f8e24d990d2bd1/templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.m

AppDelegate.hを修正

こちらも同様。
https://github.com/expo/expo/blob/c9802eba34e4b88335aac24e76f8e24d990d2bd1/templates/expo-template-bare-minimum/ios/HelloWorld/AppDelegate.h

Build Phasesを修正

Bundle Expo Assetsのコードを置き換えます。

export NODE_BINARY=node
../node_modules/react-native/scripts/react-native-xcode.sh

BareNotifications_xcodeproj.png

これでコード内にあるexpo-updatesの使用箇所がなくなりました。
その他、Expo.plistapp.manifestなどは不要になるので削除して大丈夫です。

再度確認

Archive、アップロードが完了し、TestFlightで確認してみると、無事通知を送ることができました。挙動はデバッグビルドと変わりありません。
PNGイメージ_52.png

Androidでリリースビルドを確認

Androidの場合も同じく、そのままリリースビルドするとクラッシュしてしまうので、expo-updates部分を削除します。
こちらもConfigure for Androidの逆をすればいいんですが、iOSと違って行の削除・フラグの変更だけなので簡単だと思います。

編集するファイルはapp/build.gradleMainApplication.javaAndroidManifest.xmlです。(MainApplication.javaの一部をコメントアウトするだけでも大丈夫です。)

Androidのリリースビルドの方法は人によって色々ありそうなので割愛します。
私の場合はAndroid Studioではnvmの関係でビルドできなかったりしたので、署名を含めてコマンドラインでやっています。

Androidでもリリースビルドの挙動が確認できました。
PNGイメージ_52.png

まとめ

  • expoパッケージ内のNotificationsAPIと今回新たに追加されたexpo-notificationsでは実装方法が異なる
  • AndroidではFCMを使用するのでFirebaseプロジェクトが必須
  • Expo Clientでは使用できない
  • iOSのデバッグビルドは正常に動作
  • Androidではネイティブモジュールの不具合でdataパラメータを使用できない
    • モジュールを上書きすることで無理やり対処することは可能
  • iOS、Android共にデフォルトでインストールされているexpo-updatesの影響でそのままではリリースビルドがクラッシュする。
    • expo-updatesの設定が必要だが、よくわからない場合はこれを使用しないという選択肢もある
  • Androidではそのほか、background/foregroundの挙動やNotification Iconの設定方法などよくわからない部分が残っている
21
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
15