[Xcode/Swift] APNs完全理解:iOSのプッシュ通知を設計・実装・運用までざっくり解説

1. はじめに

対象:iOS/Swift 初〜中級(Swift 5.9+/SwiftUI前提)
ゴール:APNsの全体像を掴み、権限取得 → デバイストークン取得 → サーバ送信 → 受信・表示 を最短で動かし、運用・デバッグ・落とし穴まで一通り学ぶこと。
用語

  • APNs(Apple Push Notification service):Appleの配信基盤
  • プロバイダ:あなたのサーバ(BaaS含む)。APNsへHTTP/2で送信
  • デバイストークン:端末固有の宛先
  • トピック(apns-topic):通常はアプリのBundle ID。Live Activities等は専用サフィックスあり(後述)

2. APNsの基礎:登場人物とデータフロー

  1. アプリが通知権限をリクエスト
  2. APNsへ登録しデバイストークン取得
  3. トークンを自社サーバへ登録
  4. サーバがAPNs Provider API(HTTP/2)にリクエスト
  5. 端末へ配信。開発環境は api.sandbox.push.apple.com、本番は api.push.apple.com。TLS1.2+とHTTP/2必須

Push Type は必須ヘッダ:alert / background / liveactivity / voip など。用途に合わない値は配信失敗の元。


3. Apple側の準備(Developerサイト & Xcode)

  1. App IDPush Notifications を有効化。
  2. 認証は Auth Key (.p8) 推奨(1つのKeyで複数アプリ可/有効期限なし・ローテ可)。Team ID / Key ID を控える。トークンは20分より短く、60分より長くならない間隔で更新(実質「最長1時間有効」)。
  3. Xcodeの Signing & CapabilitiesPush Notifications を追加。サイレントプッシュを使うなら Background Modes → Remote notifications をON

4. iOSクライアント実装(SwiftUI)

4.1 権限リクエスト

import SwiftUI
import UserNotifications

@main
struct APNsDemoApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        let center = UNUserNotificationCenter.current()
        center.delegate = self
        Task {
            // リクエストを投げて許可をもらう、requestAuthorization は UNUserNotificationCenter。必要に応じて .provisional も検討。
            let granted = try? await center.requestAuthorization(options: [.alert, .badge, .sound])
            if granted == true { await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } }
        }
        return true
    }
}

4.2 デバイストークン取得 & 送信

extension AppDelegate {
  func application(_ application: UIApplication,
                   didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = deviceToken.map { String(format: "%02x", $0) }.joined() // hex
    // 例: 自社APIへ登録する
    sendTokenToServer(token)
  }

  // 登録失敗
  func application(_ application: UIApplication,
                   didFailToRegisterForRemoteNotificationsWithError error: Error) {
    print("APNs registration failed: \(error)")
  }
}

4.3 フォアグラウンド表示

// アプリ前面でもバナー等を出したい場合
// .banner / .list / .badge / .sound などで制御。
func userNotificationCenter(_ center: UNUserNotificationCenter,
  willPresent notification: UNNotification,
  withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
  completionHandler([.banner, .sound, .badge]) // iOS 14+
}

4.4 アクション & ディープリンク

// 起動時にカテゴリ登録
func application(_ application: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
    let reply = UNTextInputNotificationAction(identifier: "reply", title: "返信", options: [])
    let open  = UNNotificationAction(identifier: "open", title: "開く", options: [.foreground])
    let category = UNNotificationCategory(identifier: "message", actions: [reply, open], intentIdentifiers: [])
    UNUserNotificationCenter.current().setNotificationCategories()
    ...
    return true
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {
    let userInfo = response.notification.request.content.userInfo
    // userInfoからURL等を取り出しDeep Linkへ
    completionHandler()
}

4.5 Notification Service Extension(リッチ通知)

画像・動画の添付や本文の差し替えは Service Extension で。mutable-content: 1 を付け、約30秒以内に処理& contentHandler を呼ぶ。

// NotificationService.swift
import UserNotifications

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

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

        // 例: 画像URLをダウンロードして添付
        if let urlString = request.content.userInfo["image"] as? String,
           let url = URL(string: urlString),
           let data = try? Data(contentsOf: url) {
            let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.jpg")
            try? data.write(to: tmp)
            if let attachment = try? UNNotificationAttachment(identifier: "image", url: tmp) {
                bestAttemptContent?.attachments = [attachment]
            }
        }
        if let modified = bestAttemptContent { contentHandler(modified) }
    }

    override func serviceExtensionTimeWillExpire() {
        if let bestAttemptContent { contentHandler?(bestAttemptContent) }
    }
}

4.6 Notification Content Extension(カスタムUI)

  1. 新しいターゲット → Notification Content Extension を追加
  2. NSExtensionUNNotificationExtensionCategory に対象カテゴリID(例 "message")を設定する
  3. UIはUIViewControllerベースで実装

4.7 Live Activities(ActivityKit)

  • アプリ側で Live Activity を開始し、APNs経由で更新も可能
  • APNs更新時は apns-push-type: liveactivityapns-topic: <BundleID>.push-type.liveactivity を必ず指定
  • 送信先はデバイスのトークンではなく Live Activity の push token
import ActivityKit

struct OrderAttributes: ActivityAttributes {
  public struct ContentState: Codable, Hashable {
    var progress: Double
  }
  var orderID: String
}

// 開始
let activity = try? Activity<OrderAttributes>.request(
  attributes: .init(orderID: "1234"),
  contentState: .init(progress: 0.1),
  pushType: .token) // ← push token を取得可能に
// pushTokenの監視
Task {
  for await tokenData in activity?.pushTokenUpdates ?? AsyncStream { _ in } {
    let token = tokenData.map { String(format: "%02x", $0) }.joined()
    // サーバへ登録
  }
}

5. サーバ実装の基礎(Provider API)

5.1 必須ヘッダとエンドポイント

  • apns-topic:通常はBundle ID、Live Activitiesは <BundleID>.push-type.liveactivity
  • apns-push-typealert / background / liveactivity / voip 等。
  • apns-priority10=即時、5=省電力(サイレントは5を使う)。
  • apns-expiration:有効期限(UNIX秒)。APNsは最長30日保持する場合あり。
  • authorization: bearer <JWT>:.p8 で署名したES256のJWTを使用。

5.2 代表的なペイロード

// アラート通知
{
  "aps": {
    "alert": { "title": "お知らせ", "body": "本文です" },
    "badge": 1,
    "sound": "default",
    "interruption-level": "active",    // or "time-sensitive"/"critical"
    "relevance-score": 0.8
  },
  "deepLink": "myapp://article/42"
}

// サイレント(バックグラウンド更新)
// content-available だけにし、Background Modes → Remote notifications を有効化しておく。
{ "aps": { "content-available": 1 } }

5.3 curl最小例(Sandboxでアラート)

curl -v --http2 \
  -H "apns-topic: com.example.myapp" \
  -H "apns-push-type: alert" \
  -H "apns-priority: 10" \
  -H "authorization: bearer $JWT" \
  --data '{"aps":{"alert":{"title":"Hi","body":"From curl"},"sound":"default","badge":1}}' \
  https://api.sandbox.push.apple.com/3/device/<DEVICE_TOKEN>

サイレントなら apns-push-type: backgroundapns-priority: 5 にし、本文は {"aps":{"content-available":1}}

5.4 Live Activities 更新のcurl

curl -v --http2 \
  -H "apns-topic: com.example.myapp.push-type.liveactivity" \
  -H "apns-push-type: liveactivity" \
  -H "authorization: bearer $JWT" \
  --data '{"aps":{"timestamp": 1724550000}, "content-state":{"progress":0.6}}' \
  https://api.push.apple.com/3/device/<LIVE_ACTIVITY_PUSH_TOKEN>

※送信先はLive Activityのpushトークン

5.5 JWTの作り方(Swift/サーバ側・概念)

  • Header:{"alg":"ES256","kid":"<KeyID>"}
  • Claims:{"iss":"<TeamID>","iat":<nowEpochSec>}
  • ES256で署名し1時間以内で再利用(作り直しは20分より短すぎない)。

Swift(サーバ)でのざっくり例(CryptoKit):

import Foundation
import CryptoKit

func makeJWT(keyID: String, teamID: String, p8PrivateKeyPEM: String) throws -> String {
    let header = ["alg":"ES256", "kid": keyID]
    let claims = ["iss": teamID, "iat": Int(Date().timeIntervalSince1970)]

    func b64(_ obj: Any) -> String {
        let data = try! JSONSerialization.data(withJSONObject: obj)
        return data.base64EncodedString().replacingOccurrences(of: "=", with: "")
            .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "+", with: "-")
    }

    let signingInput = [b64(header), b64(claims)].joined(separator: ".")
    // p8 -> SecKey の生成は割愛(既存ユーティリティを利用推奨)
    let privateKey = try loadECPrivateKeyFromP8(p8PrivateKeyPEM) // 実装は各環境に合わせて
    let signature = try signES256(input: signingInput, privateKey: privateKey)
    return signingInput + "." + signature
}

6. ユースケース別レシピ

6.1 アラート通知(基本)

  • apns-push-type: alertaps.alertsoundbadge
  • 重要度制御:interruption-level: "passive" | "active" | "time-sensitive" | "critical"、通知要約の並びに影響する relevance-score: 0.0...1.0

6.2 サイレントプッシュ(バックグラウンド更新)

  • aps = {"content-available":1}apns-push-type: backgroundapns-priority: 5
  • 端末状況で実行されないこともあるので過信しない。必要ならBGTaskと併用。

6.3 リッチ通知(画像・ボタン)

  • mutable-content: 1Service Extension を用意。
  • 画像は拡張でDL→UNNotificationAttachment約30秒の制限あり。

6.4 Live Activities

  • apns-push-type: liveactivity と専用トピック(<BundleID>.push-type.liveactivity)が必須。

6.5 Time Sensitive/Critical

  • interruption-level: "time-sensitive""critical"Criticalは別途エンタイトルメントとサウンド辞書({"critical":1,"name":"xxx","volume":1.0})が必要。

6.6 VoIP/PushKit の現状

  • iOS 13+ は VoIP Push受信後に即CallKitで着信報告が必須。報告しないとアプリ終了・配信停止のリスク。VoIP以外の用途でのPushKit濫用はNG

7. テスト & デバッグ

7.1 シミュレータでの受信テスト

.apns ファイル(JSON)を用意して:

{ "aps": { "alert":"Test", "sound":"default", "badge":1 }, "deeplink":"myapp://foo" }

送信:

xcrun simctl push booted com.example.myapp payload.apns

複数起動時は xcrun simctl list でDevice IDを確認。

7.2 ログ & トレース

  • 端末側:Console.appos_loguserNotificationCenter(_:willPresent:)等。
  • サーバ側:APNsのレスポンスヘッダ apns-id(または apns-unique-id で追跡。WWDCのPush Notifications Consoleでも確認可能。

7.3 代表的なHTTPステータスと原因

  • 400:Payload不正/ヘッダ不整合(例:apns-push-typeミスマッチ、apns-collapse-id長すぎ)。
  • 403InvalidProviderTokenExpiredProviderToken(JWT署名やiatが不正)。
  • 410Unregistered(トークン失効、timestamp付きで返る)。

8. 運用設計・信頼性・セキュリティ

  • キー管理:.p8は厳重に保管。ローテ時は新キー作成→切替→旧キーRevoke。
  • トークン寿命:端末の再インストール等で変わる。差分検知して都度サーバ更新。
  • 再送戦略:一時エラーは指数バックオフ。
  • 配信最適化relevance-scoreinterruption-levelapns-collapse-id(同一IDの重複通知をまとめる)。
  • スループット:HTTP/2でコネクションを極力維持。ヘッダのHTTP/2 PRIORITYはAPNsが無視するため送らないでよい。

9. 落とし穴とアンチパターン

  • Sandbox/Productionの混同BadDeviceToken
  • apns-push-type/apns-topic不一致(特にLive Activitiesのトピック)。
  • サイレント濫用 → 実行保証なし。過剰に頼らずBGTask等と設計。
  • Service Extensionの時間切れ(~30秒)。重い処理・大容量DLは避ける。

10. チェックリスト & コピペ用テンプレ

10.1 Developer設定チェック

  • App IDで Push Notifications 有効化
  • .p8 Key作成・Key ID/Team ID控える(Auth Token運用)
  • Xcode Capabilities: Push Notifications / (必要なら)Background → Remote notifications

10.2 iOS初期化コード(抜粋)

// willPresent / didReceive / token登録 など本記事のサンプルをコピペ

10.3 curl テンプレ

# alert
curl --http2 -H "apns-topic: <BUNDLE_ID>" \
 -H "apns-push-type: alert" -H "apns-priority: 10" \
 -H "authorization: bearer <JWT>" \
 --data '{"aps":{"alert":{"title":"Title","body":"Body"}}}' \
 https://api.push.apple.com/3/device/<DEVICE_TOKEN>

# background
curl --http2 -H "apns-topic: <BUNDLE_ID>" \
 -H "apns-push-type: background" -H "apns-priority: 5" \
 -H "authorization: bearer <JWT>" \
 --data '{"aps":{"content-available":1}}' \
 https://api.push.apple.com/3/device/<DEVICE_TOKEN>

# liveactivity
curl --http2 -H "apns-topic: <BUNDLE_ID>.push-type.liveactivity" \
 -H "apns-push-type: liveactivity" \
 -H "authorization: bearer <JWT>" \
 --data '{"aps":{"timestamp": 1724550000}, "content-state":{"progress":0.5}}' \
 https://api.push.apple.com/3/device/<LIVE_ACTIVITY_PUSH_TOKEN>

11. 参考リンク

  • APNs Provider API(送信・ヘッダ・保持期間)
    Sending notification requests to APNs(apns-push-type/apns-topic/apns-expiration 等)Apple Developer
    Establishing a connection to APNs(HTTP/2/TLS、エンドポイント)Apple Developer
    Sending broadcast push notification requests to APNs(優先度)Apple Developer
  • トークン認証(.p8/JWT)
    Establishing a token-based connection to APNs(20〜60分/最長1時間)Apple Developer
    Communicating with APNs(JWTの有効1時間・ES256)Apple Developer
  • ペイロード
    Generating a remote notification(content-available、ペイロードキー)Apple Developer
    Payload Key Reference / Creating the Notification Payload(最大4KB、VoIPは5KB)Apple Developer+1
  • ユーザー通知(クライアントAPI)
    UNUserNotificationCenter / requestAuthorization / willPresent(表示制御)Apple Developer+2Apple Developer+2
  • 拡張
    UNNotificationServiceExtension と時間制限(~30秒)Apple Developer+1
  • Live Activities(ActivityKit)
    Starting and updating Live Activities with ActivityKit push notifications(apns-push-type: liveactivity とトピック)Apple Developer
  • サイレントプッシュ
    Pushing background updates to your app(設計とCapabilities手順)Apple Developer
  • エラー & トレース
    Handling notification responses from APNs(apns-id、ステータス/理由)Apple Developer
    Meet Push Notifications Console(apns-unique-id で追跡)WWDC Notes
  • シミュレータテスト
    xcrun simctl push の使い方(実例)SarunwSwiftLee
  • VoIP / PushKit
    Responding to VoIP Notifications from PushKit(CallKit必須の注意)Apple Developer
  • ライブラリ
    APNSwift(Swift)GitHub / Vapor APNS(Swift)GitHub / PyAPNs2(Python)