はじめに
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を追加します。
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の設定 > 「クラウド メッセージング」タブ内のサーバーキー(下画像)をコピーし、
下記のコマンドでこれをアップロードします。
$ 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に書き直した結果、最終的なソースがこちらです。
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を叩く処理を用意しておきます。
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.
となり、使用できません。
Xcode + iPhoneでデバッグビルドを確認
iOSアプリをXcodeで動かしてみます。端末を繋ぎ、Product→Runします。
トークンが表示され、
先ほどのスクリプトで通知を送ってみます。
$ node pushNotification.js <トークン文字列部分> A 123
![PNGイメージ_50.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F514229%2F9b4af5ff-0f3f-5943-d6ab-a1c2b61b48e2.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=68f91662f9bc872ebbedda0a9b400faa)
![PNGイメージ 51.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F514229%2Ff6b4f0dc-3685-94b7-396c-d4f866d638a8.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=920492f16e950edfcd7220affde5d203)
アプリのforeground/bacground状態共に問題なく通知が届き、通知に含まれるデータを取得することでページ遷移の実装も正常に動いています。
私の場合は最初は上手くいかなかったのですが、上述したexpo credentials:manager
コマンドによって既存のPush Notifications Keyを削除し、新しく生成しなおすと成功しました。
Androidでデバッグビルドを確認
USBデバッグが有効になっている端末をつなぎadb devices
でデバイスが検知できているのを確認したうえで、react-native
コマンドを叩いて起動します。
$ npx react-native run-android
![PNGイメージ_50.png](https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F514229%2F79e02e3a-0d56-8fe4-7425-6105dd66ed93.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&s=a37381248bd8245276f0010c4f04a70b)
しかし実際に通知を送ってみると、アプリがクラッシュしてしまいました。
adb logcat
で探ってみると、このようなエラーが発生しています。
java.lang.UnsupportedOperationException: Could not put a value of class android.os.Bundle to bundle.
どうやらこのエラーは、通知のパラメータにtitle
やbody
だけでなくdata
を入れると発生することがわかりました。
環境依存などではなく純粋にネイティブモジュールのバグのようなので、モジュール自体に手を入れる以外解決方法はありません。
余裕があればPRを送るとして、一時的な解決方法としてnode_modules/expo-notifications
の中を編集してみます。
data
エラー(Could not put a value of class android.os.Bundle to bundle.)回避
data
パラメータを指定したい場合、まずはunimodules内のMapArgumentsを新しいファイルで置き換えます。
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
を編集します。
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と違ったので、上述したように処理を分けています。
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
なりへの参照を追加すれば解決します。
しかしこれで一旦はビルドが通りTestFlightで確認しようと思っても、アプリを起動した瞬間にクラッシュしてしまうと思います。
Expo SDK37以降、Bare Workflowではデフォルトでexpo-updates
パッケージがインストールされており(これはインストールの時にも書きましたが)、この設定を正しく行わないと起動時にエラーが発生してしまうからです。
expo-updates
自体はBare WorkflowでOTAアップデートができるようになるありがたいパッケージです。ただ、今現在expo-updates
のREADMEを見ても設定方法がいまいちよくわからない(特に、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を修正
Build Phasesを修正
Bundle Expo Assets
のコードを置き換えます。
export NODE_BINARY=node
../node_modules/react-native/scripts/react-native-xcode.sh
これでコード内にあるexpo-updates
の使用箇所がなくなりました。
その他、Expo.plist
やapp.manifest
などは不要になるので削除して大丈夫です。
再度確認
Archive、アップロードが完了し、TestFlightで確認してみると、無事通知を送ることができました。挙動はデバッグビルドと変わりありません。
Androidでリリースビルドを確認
Androidの場合も同じく、そのままリリースビルドするとクラッシュしてしまうので、expo-updates
部分を削除します。
こちらもConfigure for Androidの逆をすればいいんですが、iOSと違って行の削除・フラグの変更だけなので簡単だと思います。
編集するファイルはapp/build.gradle
、MainApplication.java
、AndroidManifest.xml
です。(MainApplication.java
の一部をコメントアウトするだけでも大丈夫です。)
Androidのリリースビルドの方法は人によって色々ありそうなので割愛します。
私の場合はAndroid Studioではnvmの関係でビルドできなかったりしたので、署名を含めてコマンドラインでやっています。
まとめ
-
expo
パッケージ内のNotifications
APIと今回新たに追加されたexpo-notifications
では実装方法が異なる - AndroidではFCMを使用するのでFirebaseプロジェクトが必須
- Expo Clientでは使用できない
- iOSのデバッグビルドは正常に動作
- Androidではネイティブモジュールの不具合で
data
パラメータを使用できない- モジュールを上書きすることで無理やり対処することは可能
- iOS、Android共にデフォルトでインストールされている
expo-updates
の影響でそのままではリリースビルドがクラッシュする。-
expo-updates
の設定が必要だが、よくわからない場合はこれを使用しないという選択肢もある
-
- Androidではそのほか、background/foregroundの挙動やNotification Iconの設定方法などよくわからない部分が残っている