「テスト通知をバックグラウンドで送ると出るのに、アプリを開いている状態だと無音で消える」——これは私自身、運用中の癒し系アプリで通知 A/B テストを始めた直後に再現させてしまった現象です。原因は単純で、iOS は仕様としてアプリ前面時の通知を OS 側で自動表示しないというところに行き当たります。
2014年からの個人開発で 累計 5,000 万ダウンロードを超えるアプリ群を運用してきましたが、expo-notifications 周りはバージョンを跨ぐと API が静かに変わっていくため、毎回新しい SDK に上げるタイミングで一度はこの罠を踏みます。落としどころが分かれば対応は数十行で済みますので、症状の見分け方から書いていきます。
症状と再現条件
確実に切り分けるために、次の3パターンを試します。
- アプリをホーム画面に戻して通知を送る → バナーが出る
- アプリを開いたまま通知を送る → 何も表示されない、音もしない
- 通知センターを開く → 受信履歴には入っている
この組み合わせなら原因はほぼ「フォアグラウンドハンドラ未設定」または「iOS 14 以降の表示プロパティ未指定」です。サーバ側のペイロード問題やトークン取得失敗ではありません。受信履歴に入っていない場合だけ、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,
}),
});呼び出し位置は、ルートレイアウトの最上部や index.ts の registerRootComponent の直前など、最初のレンダー前に通る場所が安全です。useEffect の中に置くと、初回起動直後の通知に間に合わないことがあります。
原因 2: shouldShowAlert だけで書いている(iOS 14+ で非推奨)
ネット上に流通しているサンプルの多くは古い API を使っています。
// ❌ iOS 14 以降は警告が出て、新規 API への移行が推奨されています
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),iOS 14 で通知の表示先がバナー(画面上部)と通知センター(ロック画面・履歴)に分離されたため、shouldShowAlert は両方を同時にコントロールできなくなりました。expo-notifications v0.27 以降では shouldShowBanner と shouldShowList の併用が推奨されています。型エラーが出ない古いバージョンでも、内部的に旧 API がフォールバックされているだけなので、新しい書き方に揃えておくと安全です。
原因 3: 権限が provisional のまま
requestPermissionsAsync() を呼ばずに、getPermissionsAsync() の結果だけを信用しているケースです。iOS は初回インストール時に「サイレント通知のみ許可」の provisional 状態を返すことがあり、この状態ではフォアグラウンドのバナーが出ません。
import * as Notifications from "expo-notifications";
async function ensureNotificationPermission() {
const current = await Notifications.getPermissionsAsync();
// provisional でも granted 扱いになるので、明示要求が必要
if (current.status === "granted" && current.ios?.status === Notifications.IosAuthorizationStatus.AUTHORIZED) {
return true;
}
const requested = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowSound: true,
allowBadge: true,
// フォアグラウンドのバナー・サウンドを明示的に許可
allowDisplayInCarPlay: false,
allowAnnouncements: false,
},
});
return requested.status === "granted";
}ios.status まで確認するのがポイントです。Android では granted で十分ですが、iOS は権限の粒度が細かいため、AUTHORIZED か EPHEMERAL を区別する必要があります。
原因 4: ペイロードに sound や title が欠けている
サーバから送るペイロードが薄いと、ハンドラ側で shouldShowBanner: true を返しても画面に出るものがありません。title か body のどちらかが空文字だと iOS 側で破棄される挙動も確認しています。
Expo Push API で送る場合の最小例です。
{
"to": "ExponentPushToken[xxxxxxxx]",
"title": "新しい壁紙が追加されました",
"body": "今日のおすすめ画像を見る",
"sound": "default",
"badge": 1,
"_displayInForeground": true
}_displayInForeground は Expo 側のフラグで、setNotificationHandler を併用している場合は冗長ですが、古い SDK との混在環境では入れておくと挙動が安定します。
原因 5: focus 通知やフォーカスモードに巻き込まれている
iOS 15 以降の集中モード(フォーカス)で「通知を許可するアプリ」から外されていると、フォアグラウンドでも通知が静かに飲まれます。自分の端末で「設定 → 集中モード → 仕事中 → 許可された App」を確認してください。複数の集中モードを切り替えて使っている方は、それぞれに登録漏れがないかをチェックします。
開発者として配布側で気をつけたい範囲ではありませんが、サポートに同じ問い合わせが届いたときに、ユーザーの設定を切り分ける質問として使えます。
カテゴリと通知アクションを設定すると挙動が変わることがある
通知に「返信」「あとで読む」などのアクションボタンを付けるために setNotificationCategoryAsync を呼んでいるアプリでは、カテゴリ未登録のペイロードを送ると iOS が判断に迷ってフォアグラウンドでの表示を遅延・破棄することがあります。再現が間欠的な場合は、サーバ側のペイロードに渡している categoryIdentifier がアプリ側で登録済みかを確認してください。
await Notifications.setNotificationCategoryAsync("daily_pick", [
{ identifier: "save", buttonTitle: "保存", options: { opensAppToForeground: false } },
{ identifier: "open", buttonTitle: "開く", options: { opensAppToForeground: true } },
]);私のアプリでも、カテゴリを A/B テストで切り替えていた時期にこの現象を踏みました。サーバ側だけで挙動を変えると、古いビルドのユーザーにだけ通知が出ない、という観測しにくいバグになります。
切り分け用の最小再現コード
ここまでを統合した最小コードです。ローカル通知を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" },
trigger: { 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 を使うと、react-native log-ios でも同じログが流れます。
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 以降の新 API(
shouldShowBanner/shouldShowList)に統一する - 権限取得時は
ios.statusまで参照する - ローカル通知のテストボタンを設定画面に隠しておくと、ユーザー報告時の切り分けが速い
私の壁紙アプリでは、リリースビルドだけで再現する通知不具合に出会うと、まずこの再現コードを設定画面の奥に仕込んで TestFlight で配り、原因がアプリ側か OS 側かを最初の数時間で切り分けるようにしています。
ここまで読んでくださりありがとうございました。同じ症状で時間を溶かしている方の最短ルートになれば幸いです。