mcpField
A
axiom-push-notifications

axiom-push-notifications

github.com/charleswiltgen開発1.2k70 回インストール今日更新
リポジトリ

axiom-push-notifications について

iOS 開発者向けに、ローカル通知と APNs プッシュの実装を扱うスキルです。権限やトークン管理からリッチ通知、Live Activity の更新まで、実装パターンと注意点をまとめています。

主な機能

  1. 01権限リクエストと状態確認
  2. 02APNs トークン登録とサーバー同期
  3. 03ペイロード設計とサイズ制限
  4. 04アクション通知とリッチ通知
  5. 05コミュニケーション通知と集中モード対応
  6. 06Live Activity のプッシュ配信

活用シーン

  1. 01通知権限を求めるタイミングの検討
  2. 02通知未着や環境別の問題の調査
  3. 03通知アクションやメディア添付の追加
  4. 04リモートプッシュによる Live Activity 更新

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-push-notifications

インストール後、Claude Code で/axiom-push-notificationsを入力して読み込まれたか確認します。

詳細

プッシュ通知

iOS のリモート通知とローカル通知のパターンです。許可フロー、APNs への登録、トークン管理、ペイロード設計、アクション可能な通知、サービス拡張機能を使ったリッチ通知、コミュニケーション通知、集中モードとの連携、ライブアクティビティのプッシュ配信について説明します。

このスキルを使う場面

次のような場合に使用します。

  • ☑ リモート(APNs)プッシュ通知を実装する
  • ☑ 通知の許可をリクエストする
  • ☑ デバイストークンとサーバー同期を管理する
  • ☑ カテゴリーやアクションを使ったアクション可能な通知を追加する
  • ☑ サービス拡張機能を使ってリッチ通知を構築する
  • ☑ アバター付きのコミュニケーション通知(iOS 15 以降)
  • ☑ 時間指定通知または重大な通知
  • ☑ プッシュでライブアクティビティを更新する(配信レイヤー)
  • ☑ 大規模な対象者にブロードキャスト通知を送信する(iOS 18 以降)
  • ☑ ローカル通知をスケジュールする

プロンプトの例

「プッシュ通知を設定するにはどうすればよいですか?」 「通知の許可はいつリクエストすればよいですか?」 「プッシュ通知が届きません」 「通知にボタンを追加するにはどうすればよいですか?」 「プッシュ通知に画像を表示するにはどうすればよいですか?」 「ライブアクティビティにプッシュ更新を送信するにはどうすればよいですか?」 「APNs トークンと FCM トークンの違いは何ですか?」 「集中モード中でも通知を表示するにはどうすればよいですか?」 「開発環境では通知が届きますが、本番環境では届きません」 「通知をタップしたときに特定の画面を開くにはどうすればよいですか?」

注意すべき兆候

必要以上に難しくしている兆候は次のとおりです。

  • ❌ ユーザーが通知の価値を理解する前に、初回起動時に許可をリクエストしている
  • ❌ 起動ごとに新しいトークンをリクエストせず、デバイストークンをローカルにキャッシュしている
  • ❌ 同じトークンをサンドボックスと本番環境の両方の APNs に送信している
  • ❌ サイレントプッシュのスロットリング(約 1 時間あたり 2~3 回)を理解せずに content-available: 1 を使用している
  • ❌ serviceExtensionTimeWillExpire のフォールバックを実装していない
  • ❌ デバイストークンを強制アンラップしたり、登録が必ず成功すると想定したりしている
  • ❌ 環境に応じてサンドボックスと本番環境を切り替えず、APNs ホストをハードコードしている
  • ❌ すべての通知に apns-priority: 10 を設定している(バッテリーを消耗し、スロットリングされます)
  • ❌ APNs が通知なしに拒否することに気づかず、ペイロードが 4KB を超えている
  • ❌ カスタムのデリゲート処理を行っているのに、メソッドスウィズリングを無効にせず FCM を使用している
  • ❌ フォアグラウンドでの通知表示を処理していない(通知が表示されずに破棄されます)
  • ❌ 時間指定の中断レベルを使いすぎている(ユーザーの信頼を損ない、すべての通知を無効にされる可能性があります)

必須の最初の手順

プッシュ通知機能を実装する前に、次の手順を行ってください。

1. プッシュ通知機能を有効にする
  • Xcode:Target → Signing & Capabilities → + Push Notifications
  • aps-environment エンタイトルメントが追加されます
  • Apple Developer Portal で、App ID のプッシュ通知が有効になっていることを確認します
2. リモート通知に登録する
// AppDelegate
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UNUserNotificationCenter.current().delegate = self
    UIApplication.shared.registerForRemoteNotifications()
    return true
}

func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02x", $0) }.joined()
    sendTokenToServer(token) // Never cache locally — tokens change
}

func application(_ application: UIApplication,
                 didFailToRegisterForRemoteNotificationsWithError error: Error) {
    // Simulator cannot register. Log, don't crash.
}
3. 許可をリクエストする(起動時ではなく、状況に応じて)
let center = UNUserNotificationCenter.current()
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
    await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}

通知の価値が明らかになるユーザー操作の後(例:リマインダーのスケジュール後、更新の登録後)にリクエストしてください。システムがプロンプトを表示するのは一度だけです。タイミングを誤ると、永久に拒否される可能性があります。

許可フロー

パターン 1:標準の許可

ユーザーが価値を理解した後、状況に応じてリクエストします。

func subscribeToUpdates() async {
    let center = UNUserNotificationCenter.current()
    let settings = await center.notificationSettings()

    switch settings.authorizationStatus {
    case .notDetermined:
        let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
        if granted == true {
            await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
        }
    case .authorized, .provisional:
        // Already have permission
        break
    case .denied:
        // Redirect to Settings
        promptToOpenSettings()
    case .ephemeral:
        break
    @unknown default:
        break
    }
}
パターン 2:暫定的な許可(iOS 12 以降)

通知は通知センターに静かに表示され、「Keep」/「Turn Off」ボタンが表示されます。ユーザーには許可ダイアログが表示されません。

let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])

ユーザーがまだ通知の価値に気づいていないアプリに適しています。通知が静かに表示され、ユーザーは通知を通常の表示に切り替えるかどうかを選択できます。

パターン 3:許可ステータスの確認

スケジュールを設定する前や、許可されていると想定する前に、必ず確認してください。

let settings = await UNUserNotificationCenter.current().notificationSettings()
guard settings.authorizationStatus == .authorized else {
    // Handle missing permission
    return
}
パターン 4:拒否への対応

ユーザーが拒否した場合は、「設定」へ誘導します。

func promptToOpenSettings() {
    // iOS 16+
    if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
        UIApplication.shared.open(url)
    } else {
        // Fallback: general app settings
        if let url = URL(string: UIApplication.openSettingsURLString) {
            UIApplication.shared.open(url)
        }
    }
}

トークン管理

トークンの形式
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02x", $0) }.joined()
    sendTokenToServer(token)
}
主なルール
  • ローカルにキャッシュしないでください — アプリを起動するたびに registerForRemoteNotifications() を介して最新の情報を取得します
  • サンドボックス ≠ 本番環境 — APNs 環境ごとにトークンは異なり、エンドポイントも異なります(api.sandbox.push.apple.com と api.push.apple.com)
  • サーバーとの同期 — トークン、バンドル ID、ユーザー ID、環境(サンドボックス/本番環境)をサーバーに送信します
  • トークンは変わります — バックアップからの復元、デバイスの移行、再インストール、OS のアップデート後に変わります

通知タイプの判断ツリー

What type of notification?
│
├─ Alert (user-visible)
│  ├─ Passive — informational, no sound, appears in history
│  │  interruption-level: "passive"
│  │
│  ├─ Active — default, sound + banner
│  │  interruption-level: "active" (or omit, it's default)
│  │
│  ├─ Time Sensitive — breaks through scheduled summary, not Focus
│  │  interruption-level: "time-sensitive"
│  │  Requires: Time Sensitive Notifications capability
│  │
│  └─ Critical — breaks through Do Not Disturb and mute switch
│     interruption-level: "critical"
│     Requires: Apple entitlement approval (medical, safety, security)
│
├─ Communication (iOS 15+)
│  Shows sender avatar, name, breaks Focus for allowed contacts
│  Requires: INSendMessageIntent + Communication Notifications capability
│  Configured in service extension via content.updating(from: intent)
│
├─ Silent / Background
│  content-available: 1, no alert/sound/badge
│  Throttled to ~2-3 per hour
│  apns-priority: 5 (MUST be 5, not 10)
│  App gets ~30 seconds background execution
│
└─ Live Activity
   apns-push-type: liveactivity
   apns-topic: {bundleID}.push-type.liveactivity
   Updates/starts/ends Live Activities remotely

ペイロードの設計パターン

基本的なアラート
{
  "aps": {
    "alert": {
      "title": "New Message",
      "subtitle": "From Alice",
      "body": "Hey, are you free for lunch?"
    },
    "sound": "default",
    "badge": 3
  }
}
サウンドのオプション
{
  "aps": {
    "alert": { "title": "Notification", "body": "With custom sound" },
    "sound": "custom-sound.aiff"
  }
}

クリティカルアラート(Apple のエンタイトルメントが必要です):

{
  "aps": {
    "alert": { "title": "Emergency", "body": "Critical alert" },
    "sound": {
      "critical": 1,
      "name": "alarm.aiff",
      "volume": 0.8
    }
  }
}
バッジ
{
  "aps": {
    "badge": 5
  }
}

バッジを消すには 0 に設定します。

ローカライズされたコンテンツ
{
  "aps": {
    "alert": {
      "loc-key": "NEW_MESSAGE_FORMAT",
      "loc-args": ["Alice", "lunch"]
    }
  }
}
カスタムデータ

カスタムデータは aps 辞書の外に配置します。

{
  "aps": {
    "alert": { "title": "Order Update", "body": "Your order shipped" }
  },
  "orderId": "12345",
  "deepLink": "/orders/12345"
}
関連度スコアとスレッド ID
{
  "aps": {
    "alert": { "title": "Breaking News", "body": "..." },
    "relevance-score": 0.8,
    "thread-id": "news-breaking",
    "interruption-level": "time-sensitive"
  }
}
  • relevance-score(0.0~1.0):通知の要約でのランキングに使用されます(iOS 15 以降)
  • thread-id:通知センターで通知を会話ごとにまとめます
ペイロードのサイズ制限
タイプ最大サイズ
標準プッシュ4KB
VoIP プッシュ5KB
Live Activity4KB

APNs はサイズ超過のペイロードを通知なく拒否します。送信者にエラーは返されません。

カテゴリとアクション

起動時にカテゴリを登録する
func registerNotificationCategories() {
    let replyAction = UNTextInputNotificationAction(
        identifier: "REPLY_ACTION",
        title: "Reply",
        options: [])

    // iOS 15+: actions with icons
    let likeIcon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
    let likeAction = UNNotificationAction(
        identifier: "LIKE_ACTION",
        title: "Like",
        options: [],
        icon: likeIcon)

    let messageCategory = UNNotificationCategory(
        identifier: "MESSAGE",
        actions: [replyAction, likeAction],
        intentIdentifiers: [],
        options: [.customDismissAction])

    UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
}
ペイロードにカテゴリを設定する
{
  "aps": {
    "alert": { "title": "Alice", "body": "Are you free?" },
    "category": "MESSAGE"
  }
}
アクションへの応答を処理する
extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo

        switch response.actionIdentifier {
        case "REPLY_ACTION":
            if let textResponse = response as? UNTextInputNotificationResponse {
                handleReply(text: textResponse.userText, userInfo: userInfo)
            }
        case "LIKE_ACTION":
            handleLike(userInfo: userInfo)
        case UNNotificationDefaultActionIdentifier:
            // User tapped the notification itself
            handleNotificationTap(userInfo: userInfo)
        case UNNotificationDismissActionIdentifier:
            // User dismissed (requires .customDismissAction on category)
            handleDismiss(userInfo: userInfo)
        default:
            break
        }

        completionHandler()
    }
}

サービス拡張のパターン

パターン 1:メディアの追加

画像、音声、動画をダウンロードして通知に添付します。

ペイロードの要件:"mutable-content": 1 を含める必要があります。

{
  "aps": {
    "alert": { "title": "Photo", "body": "Alice sent a photo" },
    "mutable-content": 1
  },
  "imageURL": "https://example.com/photo.jpg"
}

サービス拡張:

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler:
                                @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent

        guard let bestAttemptContent,
              let imageURLString = bestAttemptContent.userInfo["imageURL"] as? String,
              let imageURL = URL(string: imageURLString) else {
            contentHandler(request.content)
            return
        }

        // Download image
        let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] url, _, error in
            guard let self, let url, error == nil else {
                contentHandler(self?.bestAttemptContent ?? request.content)
                return
            }

            // Move to tmp with proper extension
            let tmpURL = FileManager.default.temporaryDirectory
                .appendingPathComponent(UUID().uuidString)
                .appendingPathExtension("jpg")
            try? FileManager.default.moveItem(at: url, to: tmpURL)

            if let attachment = try? UNNotificationAttachment(identifier: "image",
                                                               url: tmpURL,
                                                               options: nil) {
                bestAttemptContent.attachments = [attachment]
            }

            contentHandler(bestAttemptContent)
        }
        task.resume()
    }

    override func serviceExtensionTimeWillExpire() {
        // 30-second window exceeded — deliver what we have
        if let contentHandler, let bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }
}
パターン 2:エンドツーエンド暗号化の復号

表示前にサービス拡張でペイロードを復号します。

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler:
                            @escaping (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent

    guard let bestAttemptContent,
          let encryptedBody = bestAttemptContent.userInfo["encryptedBody"] as? String else {
        contentHandler(request.content)
        return
    }

    if let decrypted = decrypt(encryptedBody) {
        bestAttemptContent.body = decrypted
    } else {
        bestAttemptContent.body = "(Encrypted message)"
    }

    contentHandler(bestAttemptContent)
}

override func serviceExtensionTimeWillExpire() {
    if let contentHandler, let bestAttemptContent {
        bestAttemptContent.body = "(Encrypted message)"
        contentHandler(bestAttemptContent)
    }
}

処理時間は 30 秒間:didReceive が約 30 秒以内に contentHandler を呼び出さない場合、serviceExtensionTimeWillExpire が呼び出されます。フォールバックとして常に bestAttemptContent を渡してください。どちらのメソッドもハンドラーを呼び出さない場合、通知は完全に消失します。

コミュニケーション通知(iOS 15 以降)

送信者のアバターと名前を表示します。許可された連絡先については集中モードを突破できます。

// In your Notification Service Extension
import Intents

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler:
                            @escaping (UNNotificationContent) -> Void) {
    guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
        contentHandler(request.content)
        return
    }

    // 1. Create sender persona
    let senderImage = INImage(url: avatarURL) // or INImage(imageData:)
    let sender = INPerson(
        personHandle: INPersonHandle(value: "[email protected]", type: .emailAddress),
        nameComponents: nil,
        displayName: "Alice",
        image: senderImage,
        contactIdentifier: nil,
        customIdentifier: "user-alice-123"
    )

    // 2. Create message intent
    let intent = INSendMessageIntent(
        recipients: nil,       // nil for 1:1, set for group
        outgoingMessageType: .outgoingMessageText,
        content: bestAttemptContent.body,
        speakableGroupName: nil, // set for group conversations
        conversationIdentifier: "conversation-123",
        serviceName: nil,
        sender: sender,
        attachments: nil
    )

    // 3. Donate interaction
    let interaction = INInteraction(intent: intent, response: nil)
    interaction.direction = .incoming
    interaction.donate(completion: nil)

    // 4. Update content with intent
    do {
        let updatedContent = try bestAttemptContent.updating(from: intent)
        contentHandler(updatedContent)
    } catch {
        contentHandler(bestAttemptContent)
    }
}

要件:

  • Xcode の Communication Notifications 機能
  • Notification Service Extension ターゲット
  • ペイロード内の mutable-content: 1

集中モードの突破:集中モードの設定でユーザーが許可した連絡先からのコミュニケーション通知は、集中モードを突破します。使いすぎると信頼を損なうため、控えめに使用してください。

フォアグラウンドでの通知処理

このデリゲートメソッドがない場合、アプリがフォアグラウンドにあるときに受信した通知は通知なく破棄されます。

extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler:
                                    @escaping (UNNotificationPresentationOptions) -> Void) {
        // Show notification even when app is in foreground
        completionHandler([.banner, .sound, .badge])
    }
}

アプリの起動が完了する前に、didFinishLaunchingWithOptions で UNUserNotificationCenter.current().delegate = self を設定します。

Live Activity のプッシュ転送

APNs を介して Live Activity をリモートから更新します。

プッシュトークンの監視
let activity = try Activity<OrderAttributes>.request(
    attributes: attributes,
    content: initialContent,
    pushType: .token
)

Task {
    for await pushToken in activity.pushTokenUpdates {
        let pushTokenString = pushToken.reduce("") {
            $0 + String(format: "%02x", $1)
        }
        try await sendPushToken(pushTokenString: pushTokenString)
    }
}
Live Activity 用の APNs ヘッダー
ヘッダー値
apns-push-typeliveactivity
apns-topic{bundleID}.push-type.liveactivity
apns-priority5(通常)または 10(時間依存)
更新ペイロード
{
  "aps": {
    "timestamp": 1234567890,
    "event": "update",
    "content-state": {
      "currentStep": "outForDelivery",
      "estimatedArrival": "2:30 PM"
    },
    "stale-date": 1234571490,
    "dismissal-date": 1234575090,
    "relevance-score": 75.0
  }
}
イベントタイプ
イベント用途
updatecontent-state を更新します
start新しい Live Activity をリモートから開始します(iOS 17.2 以降)
endアクティビティを終了します
主なルール
  • content-state は ActivityAttributes.ContentState と完全に一致する必要があります — カスタム JSON エンコード戦略は使用できず、プロパティ名も同一でなければなりません
  • timestamp は必須です — APNs はこれを使用して古い更新を破棄します
  • stale-date はデータが古いことを示す視覚的なインジケーターを表示します
  • dismissal-date は、終了したアクティビティがロック画面から消えるタイミングを制御します
  • relevance-score は複数のアクティブな Live Activity の順序を決めます
  • 優先度の予算が適用されます — apns-priority: 10 を過剰に使用すると、スロットリングされます
  • 高頻度で更新するアプリ(スポーツ、ナビゲーション)では、Info.plist に NSSupportsLiveActivitiesFrequentUpdates を追加します

ActivityKit の UI、属性、Dynamic Island のレイアウトについては、axiom-extensions-widgets を参照してください。

Broadcast Push(iOS 18+)

大勢のユーザーに配信する、チャンネルベースの方式です(スポーツのスコア、フライト状況、速報など)。

セットアップ
  1. Apple Developer Portal で Broadcast Push Notifications 機能を有効にします
  2. Apple の Broadcast Push API を使用してチャンネルを作成します
チャンネルをサブスクライブする
let activity = try Activity<ScoreAttributes>.request(
    attributes: attributes,
    content: initialContent,
    pushType: .channel(channelId)
)
サーバーからチャンネルに送信する
POST /4/broadcasts/apps/{TOPIC}
主なルール
  • Live Activities でのみ利用できます(通常のプッシュ通知では利用できません)
  • チャンネルのストレージポリシー:No Storage(より大きな予算)と Most Recent Message(最後の更新を保存)
  • チャンネルのライフサイクルを管理し、使われていないチャンネルを削除します(有効なチャンネルの合計数には上限があります)
  • チャンネルは、ユーザー向けの名前ではなく、不透明な ID で識別されます
  • 1 対多のシナリオでは、デバイスごとのトークン配信より効率的です

プロバイダーとしての FCM

プッシュ通知プロバイダーとして Firebase Cloud Messaging を使用する場合は、次の注意点に気を付けてください。

1. スウィズリングの落とし穴

FCM はデフォルトで UNUserNotificationCenterDelegate のメソッドと didRegisterForRemoteNotifications をスウィズリングします。カスタムのデリゲート処理がある場合、競合します。

解決方法:Info.plist に設定します。

<key>FirebaseAppDelegateProxyEnabled</key>
<false/>

その後、APNs トークンを手動で FCM に渡します。

func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    Messaging.messaging().apnsToken = deviceToken
}
2. 2 種類のトークンの混同

FCM トークン ≠ APNs デバイストークンです。これらはまったく別のものです。正しいトークンを正しいバックエンドに送信してください。

  • FCM トークン → サーバー(FCM 経由で送信するため)
  • APNs トークン → APNs 経由で直接送信する場合にのみ必要です
3. APNs 認証キーのアップロード

.p8 APNs 認証キーを Firebase Console → Project Settings → Cloud Messaging にアップロードします。これを行わない場合、開発ビルドは動作します(FCM が自動的にサンドボックスを使用するため)が、本番ビルドは何も通知せずに失敗します。

4. サイレントプッシュのペイロードサイズ

FCM の content_available は APNs の content-available に対応しますが、FCM がペイロードに追加のフィールドを加える場合があります。4KB の上限を超えないよう、合計サイズを監視してください。

アンチパターン

アンチパターン 1:アプリ起動時に許可を求める

誤り:

func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
    return true
}

正しい方法:

// After user taps "Subscribe to updates" or completes onboarding
func subscribeButtonTapped() async {
    let granted = try? await UNUserNotificationCenter.current()
        .requestAuthorization(options: [.alert, .sound, .badge])
    if granted == true {
        await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
    }
}

重要な理由:システムが許可ダイアログを表示するのは一度だけです。ユーザーがまだ価値を実感していなければ、反射的に「許可しない」をタップします。許可しなかったユーザーの約 60% は、設定から再度有効にすることはありません。チャンスは一度きりです。

アンチパターン 2:デバイストークンのキャッシュ

誤り:

func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    let tokenString = token.map { String(format: "%02x", $0) }.joined()
    UserDefaults.standard.set(tokenString, forKey: "pushToken") // Stale after restore
}

正しい方法:

func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    let tokenString = token.map { String(format: "%02x", $0) }.joined()
    sendTokenToServer(tokenString) // Fresh every launch
}

重要な理由:バックアップからの復元、デバイスの移行、再インストール後、また OS のアップデート後にも、トークンが変わることがあります。古いキャッシュ済みトークンを使うと、サーバーが APNs に認識されなくなったトークン宛てに送信し、通知が気付かれないまま消えてしまいます。

アンチパターン 3:サービスエクステンションのタイムアウトを無視する

誤り:

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    // Download large image, no timeout handling
    downloadImage(from: url) { image in
        // If this takes >30 seconds, notification vanishes entirely
        contentHandler(modifiedContent)
    }
}
// serviceExtensionTimeWillExpire not implemented

正しい方法:

override func serviceExtensionTimeWillExpire() {
    if let contentHandler, let bestAttemptContent {
        // Deliver whatever we have — text without image is better than nothing
        contentHandler(bestAttemptContent)
    }
}

重要な理由:サービスエクステンションの処理時間は約 30 秒です。didReceive も serviceExtensionTimeWillExpire もコンテンツハンドラーを呼び出さない場合、通知は完全に消えてしまい、ユーザーには表示されません。

アンチパターン 4:あらゆる通知に Time Sensitive を使う

誤り:

{
  "aps": {
    "alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
    "interruption-level": "time-sensitive"
  }
}

正しい方法:

{
  "aps": {
    "alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
    "interruption-level": "passive"
  }
}

重要な理由:iOS は Time Sensitive を過剰に使用しているアプリをユーザーに知らせます。通知に煩わしさを感じたユーザーは、Time Sensitive だけでなく、アプリからのすべての通知を無効にすることがあります。Time Sensitive は、本当に時間に制約のあるイベント(配達の到着、会議の開始、セキュリティアラートなど)に限って使用してください。Apple がこの機能の使用権限を取り消すこともあります。

プレッシャーのかかる状況

シナリオ 1:「金曜日までにプッシュ通知をリリースする」

状況:PM はデモに向けてプッシュ通知を動作させる必要があります。

プレッシャー:「起動時に許可を求めればいいです。あとで直しましょう。」

現実:システムが許可を求めるのは一度だけです。ユーザーが拒否した場合、設定で手動で有効にしてもらう必要があります。約 60%のユーザーはそのまま有効にしません。「あとで直す」は、オプトイン率を永久に低下させることを意味します。

正しい対応:

  1. まず許可のプロンプトなしで、プッシュ通知の登録と配信を実装する
  2. 通知の価値が明確に伝わるユーザー操作の後に、状況に応じた許可リクエストを追加する
  3. 許可する場合と拒否する場合の両方のフローを、エンドツーエンドでテストする

反論のテンプレート:「許可を求めるタイミングは、オプトイン率に直接影響します。今、2 時間かけておけば、通知の到達率が恒久的に 30%低下するのを防げます。適切なタイミングで許可を求める実装をさせてください。コード量は同じで、配置する場所が適切になるだけです。」

シナリオ 2:「開発環境では通知が動くのに、本番環境では動かない」

状況:App Store ビルドでプッシュ通知を受信できません。

プレッシャー:「APNs に問題があります。レーダーを提出しましょう。」

現実:95%のケースでは、サンドボックスと本番環境のトークンの不一致が原因です。開発ビルドでは api.sandbox.push.apple.com を、本番環境では api.push.apple.com を使用します。環境ごとにトークンが異なります。同じトークンを誤ったエンドポイントに送信すると、エラーなしで失敗します。

正しい対応:

  1. サーバーがビルドの種類に応じた正しい APNs エンドポイントを使用していることを確認する
  2. トークンが本番ビルドから取得されたことを確認する(本番エンドポイントに開発版/TestFlight のトークンを送信していないこと)
  3. FCM を使用している場合は、APNs 認証キー(.p8)が Firebase Console にアップロードされていることを確認する

反論のテンプレート:「レーダーを提出する前に、トークンと環境の設定を確認させてください。これは『開発環境では動くが、本番環境では動かない』問題の最大の原因で、確認にかかる時間は 5 分です。」

シナリオ 3:「すべての通知を『時間的制約あり』として送信する」

状況:プロダクトチームは、通知の視認性を最大化したいと考えています。

プレッシャー:「ユーザーには通知をすぐに見てもらう必要があります。」

現実:iOS は、時間的制約ありの通知を多用しているアプリをユーザーに知らせます。通知に邪魔されていると感じたユーザーは、時間的制約ありの通知だけでなく、アプリからのすべての通知を無効にしてしまいます。Apple が乱用を理由にエンタイトルメントを取り消す可能性もあります。

正しい対応:

  1. 通知を実際の緊急度に基づいて分類する
  2. 情報提供には passive、通常のエンゲージメントには active(デフォルト)、真に時間制約のあるイベントにのみ time-sensitive を使用する
  3. バックエンドエンジニアが適切なレベルを適用できるよう、チーム向けに分類を文書化する

反論のテンプレート:「時間的制約ありを多用すると、ユーザーが通知をすべて無効にしてしまいます。緊急度に基づいて分類しましょう。ほとんどの通知には active を使い、時間的制約のある通知は配達の到着や期限切れ間近のオファーなど、本当に時間制約のあるイベントに限定するべきです。」

チェックリスト

プッシュ通知をリリースする前に:

エンタイトルメント:

  • ☑ Xcode でプッシュ通知機能を追加
  • ☑ プロビジョニングプロファイルに aps-environment が含まれている
  • ☑ Communication Notifications 機能(通信タイプを使用する場合)

権限:

  • ☑ 適切なタイミングで許可をリクエスト(起動時ではない)
  • ☑ 拒否された場合に適切に対応(設定への誘導、または機能を制限した体験)
  • ☑ スケジュール設定前に許可状態を確認
  • ☑ 試用期間に暫定的な許可を検討

トークン管理:

  • ☑ 起動のたびにトークンをサーバーに送信(キャッシュしない)
  • ☑ サーバーで環境ごとにトークンを保存(サンドボックス/本番)
  • ☑ トークンの更新に対応(Live Activities の pushTokenUpdates)

ペイロード:

  • ☑ ペイロードを 4KB 未満にする(VoIP の場合は 5KB)
  • ☑ カテゴリー識別子が登録済みのカテゴリーと一致
  • ☑ コンテンツの緊急度に適した割り込みレベルを設定
  • ☑ カスタムデータを aps 辞書の外に配置

サービス拡張(該当する場合):

  • ☑ ペイロードに mutable-content: 1 を設定
  • ☑ serviceExtensionTimeWillExpire でフォールバックコンテンツを配信
  • ☑ 共有データにアクセスできるよう App group を設定

テスト:

  • ☑ Push Notifications Console または curl でテスト
  • ☑ フォアグラウンドとバックグラウンドの両方で配信をテスト
  • ☑ 実機でテスト(シミュレーターには APNs トークンがありません)

リソース

WWDC:2021-10091、2023-10025、2023-10185、2024-10069

ドキュメント:/usernotifications、/usernotifications/unusernotificationcenter、/activitykit

スキル:axiom-push-notifications-ref、axiom-push-notifications-diag、axiom-extensions-widgets、axiom-background-processing

コメント