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の基礎:登場人物とデータフロー
- アプリが通知権限をリクエスト
- APNsへ登録しデバイストークン取得
- トークンを自社サーバへ登録
- サーバがAPNs Provider API(HTTP/2)にリクエスト
- 端末へ配信。開発環境は
api.sandbox.push.apple.com
、本番はapi.push.apple.com
。TLS1.2+とHTTP/2必須
Push Type は必須ヘッダ:alert
/ background
/ liveactivity
/ voip
など。用途に合わない値は配信失敗の元。
3. Apple側の準備(Developerサイト & Xcode)
- App IDで Push Notifications を有効化。
- 認証は Auth Key (.p8) 推奨(1つのKeyで複数アプリ可/有効期限なし・ローテ可)。Team ID / Key ID を控える。トークンは20分より短く、60分より長くならない間隔で更新(実質「最長1時間有効」)。
- Xcodeの Signing & Capabilities で Push 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)
- 新しいターゲット → Notification Content Extension を追加
NSExtension
のUNNotificationExtensionCategory
に対象カテゴリID(例"message"
)を設定する- UIは
UIViewController
ベースで実装
4.7 Live Activities(ActivityKit)
- アプリ側で Live Activity を開始し、APNs経由で更新も可能
- APNs更新時は
apns-push-type: liveactivity
、apns-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-type
:alert
/background
/liveactivity
/voip
等。apns-priority
:10=即時、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: background
と apns-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: alert
、aps.alert
/sound
/badge
。- 重要度制御:
interruption-level: "passive" | "active" | "time-sensitive" | "critical"
、通知要約の並びに影響するrelevance-score: 0.0...1.0
。
6.2 サイレントプッシュ(バックグラウンド更新)
aps = {"content-available":1}
、apns-push-type: background
、apns-priority: 5
。- 端末状況で実行されないこともあるので過信しない。必要ならBGTaskと併用。
6.3 リッチ通知(画像・ボタン)
mutable-content: 1
と Service 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.app
/os_log
、userNotificationCenter(_:willPresent:)
等。 - サーバ側:APNsのレスポンスヘッダ
apns-id
(またはapns-unique-id
) で追跡。WWDCのPush Notifications Consoleでも確認可能。
7.3 代表的なHTTPステータスと原因
- 400:Payload不正/ヘッダ不整合(例:
apns-push-type
ミスマッチ、apns-collapse-id
長すぎ)。 - 403:
InvalidProviderToken
/ExpiredProviderToken
(JWT署名やiat
が不正)。 - 410:
Unregistered
(トークン失効、timestamp付きで返る)。
8. 運用設計・信頼性・セキュリティ
- キー管理:.p8は厳重に保管。ローテ時は新キー作成→切替→旧キーRevoke。
- トークン寿命:端末の再インストール等で変わる。差分検知して都度サーバ更新。
- 再送戦略:一時エラーは指数バックオフ。
- 配信最適化:
relevance-score
、interruption-level
、apns-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)