「テスト通知をバックグラウンドで送ると出るのに、アプリを開いている状態だと音もなく消える」——運用中の癒し系アプリで通知の A/B テストを始めた直後、私はこの現象を自分の手で再現してしまいました。最初は送信側のペイロードを疑い、サーバのログを一晩ぶん読み返しましたが、芳しい手がかりはありませんでした。行き当たったのは、iOS はアプリが前面にあるとき、通知を OS 側で自動表示しない仕様だということです。
expo-notifications 周りは SDK を上げるたびに表示まわりの API が少しずつ入れ替わるため、新しい SDK に載せ替えるたびに、一度はこの罠を思い出すことになります。落としどころさえ分かれば対応は数十行で済みますので、症状の見分け方から順に書き残します。
症状と再現条件
原因を取り違えないために、次の 3 パターンを試します。
- ホーム画面に戻して送信:バナーが表示される
- アプリを開いたまま送信:表示も音もなし
- 通知センターの確認:受信履歴には記録あり
この組み合わせなら、原因はほぼ「フォアグラウンドハンドラの未設定」か「表示プロパティの指定漏れ」です。サーバ側のペイロードやトークン取得の失敗ではありません。受信履歴にも入っていない場合に限って、APNs 証明書・FCM の Server Key・getExpoPushTokenAsync のスコープを疑ってください。
原因 1: setNotificationHandler を呼んでいない
iOS のフォアグラウンド通知は、アプリ側が「表示してください」と明示しない限り表示されません。expo-notifications では、この宣言を setNotificationHandler で行う設計になっています。
// app/_layout.tsx もしくはアプリ起動の最上流
import * as Notifications from "expo-notifications";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});呼び出し位置は、ルートレイアウトの最上部や registerRootComponent の直前など、最初のレンダーより前に必ず通る場所が安全です。useEffect の中に置くと、コールドスタート直後に届いた通知に間に合わないことがあります。私も最初はコンポーネントの中に置いていて、起動直後の 1 通だけ消える、という再現しにくい形で悩まされました。
原因 2: shouldShowAlert だけで書いている
ネット上のサンプルの多くは、いまも古い API のままです。
// 旧 API:新しい SDK では非推奨。shouldShowBanner / shouldShowList へ移行する
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),iOS 14 で通知の表示先がバナー(画面上部)と通知センター(リスト)に分かれたため、shouldShowAlert の 1 つでは両方を別々に制御できなくなりました。expo-notifications 側でも、0.31(SDK 53)で shouldShowAlert が非推奨となり、shouldShowBanner と shouldShowList の組み合わせが案内されています。古い版のまま動いているように見えても、内部で旧 API に寄せられているだけの場合があります。SDK を上げる前に書き方を揃えておくほうが安全だと感じています。
原因 3: 権限が provisional のまま
requestPermissionsAsync() を呼ばず、getPermissionsAsync() の結果だけを信用しているケースです。iOS には、通知センターへ控えめに届けるだけの「仮の許可(provisional)」という状態があります。この状態は status 上では granted として返るため、見落としやすいところです。
import * as Notifications from "expo-notifications";
async function ensureNotificationPermission() {
const current = await Notifications.getPermissionsAsync();
// provisional も granted として返るので、ios.status まで見る
if (
current.status === "granted" &&
current.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED
) {
return true;
}
const requested = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowSound: true,
allowBadge: true,
},
});
return requested.status === "granted";
}ios.status まで確認するのがポイントです。Android では granted だけで足りますが、iOS は権限の粒度が細かく、AUTHORIZED・PROVISIONAL・EPHEMERAL を区別する必要があります。
原因 4: ペイロードが薄くて描画するものがない
サーバから送るペイロードに title も body もなければ、ハンドラで shouldShowBanner: true を返しても、画面に出すものがありません。Expo Push API で送る場合の最小例です。
{
"to": "ExponentPushToken[xxxxxxxx]",
"title": "新しい壁紙が追加されました",
"body": "今日のおすすめ画像を見る",
"sound": "default",
"badge": 1
}古いサンプルでは _displayInForeground というフラグを付けた例も見かけますが、これは古い Expo クライアント向けの名残です。setNotificationHandler を使う現在の構成では、必要ないと考えています。
原因 5: 集中モード(フォーカス)に巻き込まれている
iOS 15 以降の集中モードで、アプリが「許可された App」に入っていないと、通知が表示されないことがあります。自分の端末で「設定 → 集中モード → 各モード → 許可された App」を確認してください。複数の集中モードを使い分けている方は、それぞれに登録漏れがないかも見ます。
開発者側で直せる範囲ではありませんが、サポートに同じ問い合わせが届いたとき、ユーザーの設定を切り分ける最初の質問として役に立ちます。
カテゴリと通知アクションの登録漏れ
通知に「保存」「開く」などのアクションボタンを付けるため setNotificationCategoryAsync を使っているアプリでは、サーバが送る categoryIdentifier がアプリ側で未登録だと、表示の挙動が不安定になることがあるかもしれません。再現が間欠的なときは、まずここを疑います。
await Notifications.setNotificationCategoryAsync("daily_pick", [
{ identifier: "save", buttonTitle: "保存", options: { opensAppToForeground: false } },
{ identifier: "open", buttonTitle: "開く", options: { opensAppToForeground: true } },
]);私のアプリでも、カテゴリを A/B テストで切り替えていた時期にこの種の不具合に出会いました。サーバ側だけで挙動を変えたため、古いビルドのユーザーにだけ通知が出ない、という観測しにくい状態になっていたのです。サーバで増やしたカテゴリは、先にアプリ側で配ってから使います。 この順番だけは崩さないようにしています。
切り分け用の最小再現コード
ここまでを 1 つにまとめた最小のコードです。ローカル通知を 3 秒後に発火させるだけなので、サーバを介さずに挙動を確認できます。
import * as Notifications from "expo-notifications";
import { Button, View } from "react-native";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export default function DebugScreen() {
return (
<View>
<Button
title="3秒後にローカル通知"
onPress={async () => {
const perm = await Notifications.requestPermissionsAsync();
if (perm.status !== "granted") return;
await Notifications.scheduleNotificationAsync({
content: { title: "テスト", body: "前面表示の確認", sound: "default" },
// 最近の版ではトリガーに type の指定が必要
trigger: {
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
seconds: 3,
},
});
}}
/>
</View>
);
}このコードで通知が出ないときは、ハンドラ未設定よりも手前の段階——権限の拒否や集中モード——を疑います。
ログで「ハンドラが呼ばれていない」を確かめる
setNotificationHandler は呼んでいるはずなのに直らない、というときは、ハンドラの中に console.log を置いて、Xcode のコンソールで観測します。
Notifications.setNotificationHandler({
handleNotification: async (notification) => {
console.log("[notify] foreground", notification.request.content.title);
return {
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: true,
};
},
});[notify] foreground が流れていれば、ハンドラ自体は呼ばれています。原因は表示プロパティの組み合わせか、権限の側にあります。ログが一度も出ない場合は、ハンドラの登録位置が遅すぎるか、expo-notifications のインポート漏れを疑います。実機で確かめるときは、TestFlight ではなく Development Build を使うと、Metro 側のログでも同じ出力を追えます。
Android との挙動差もあわせて確認する
Android では、通知チャンネル(setNotificationChannelAsync)の importance が HIGH 未満だと、フォアグラウンドでヘッドアップ通知が出ません。iOS の問題を片付けたあとで「Android でも同じことが起きている」と気づく流れが多いので、運用するなら最低限のチャンネル設定も一緒に入れておきます。
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
if (Platform.OS === "android") {
Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
sound: "default",
});
}Android 13 以降は通知許可のランタイム要求も必要です。requestPermissionsAsync をプラットフォーム共通で呼ぶ流れにしておくと、新しい OS が出るたびにコードを書き換えずに済みます。
予防策
- ルートレイアウトの最上部での
setNotificationHandler呼び出し - iOS 14 以降の表示キー(
shouldShowBanner/shouldShowList)への統一 - 権限取得時の
ios.statusまで含めた確認 - 設定画面の奥に置くローカル通知のテストボタン(ユーザー報告の切り分けが速くなります)
リリースビルドだけで再現する通知の不具合に出会ったとき、私はまずこの再現コードを設定画面の奥に仕込んで TestFlight で配り、原因がアプリ側か OS 側かを最初の数時間で切り分けるようにしています。ペイロードを疑う前に端末側を潰しておくだけで、調べる範囲がずいぶん狭まるのだと気づいたのは、何度かこの罠を踏んだあとでした。
まずは手元の setNotificationHandler が shouldShowBanner と shouldShowList で書かれているかを確かめるところから始めていただければと思います。同じ症状で時間を溶かしている方の、最短ルートになれば幸いです。