🐱

APNsを使ったSwiftのプッシュ通知実装まとめ

に公開

実装するためには結構あっちこっち設定が必要なので備忘も兼ねてメモしておく

プッシュ通知を受け取るための手順

プロジェクトにエンタイトルメント設定をする

デバイストークン取得にもプッシュ通知受信にもエンタイトルメント設定が必要なので設定する

Apple Developer Center側でPush Notification有効化

  • Apple Developer Centerにアクセスする
  • Certificates, IDs & Profiles > Identifiers > 設定する自身のアプリを選択 > Push Notificationsを選択してEnableにして保存

※プッシュ通知送信時には証明書方式かプライベートキー方式かの選択肢があるが、プライベートキー方式の方が推奨されており、その場合はApple Developer Center側の操作はここでは不要。証明書方式の場合はここでconfigure押下し、証明書発行を行う必要が出てくる

XCode側でPush Notification有効化

プロジェクト設定 > Signing & Capabilities

Capabilityのところで+押下でPush Notificationを追加

{アプリ名}.entitlementsが自動生成されるので、emvironmentをdevelopmentにする

クライアントアプリ実装

各種delegateに諸々実装していく

通知許可の取得

アプリ起動時に通知許可の取得を行う

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        
        // 通知センターのデリゲートを設定
        UNUserNotificationCenter.current().delegate = self
        
        // 通知許可をリクエスト
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
            if granted {
                print("✅ 通知許可が承認されました")
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            } else {
                print("❌ 通知許可が拒否されました")
            }
        }
        
        return true
    }

デバイストークン取得

今回はシンプル化のため取得したものをコンソール出力して、サーバ側には環境変数としてデバイストークンを参照させる
実際はここでサーバサイドに取得したデバイストークンを保存するリクエストを送る

    // デバイストークンの取得成功時
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("📱 デバイストークン取得成功:")
        print("Token: \(tokenString)")
        print("Token Length: \(deviceToken.count) bytes")
    }
    
    // デバイストークンの取得失敗時
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("❌ デバイストークン取得失敗:")
        print("Error: \(error.localizedDescription)")
    }

通知時のハンドリング

    // バックグラウンドで通知を受信した場合
    func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        print("📨 バックグラウンドで通知を受信:")
        print("UserInfo: \(userInfo)")
        
        // 通知の内容を処理
        if let aps = userInfo["aps"] as? [String: Any] {
            print("APS: \(aps)")
            
            // content-availableの確認
            if let contentAvailable = aps["content-available"] as? Int {
                print("Content-Available: \(contentAvailable)")
            } else {
                print("⚠️ Content-Available が設定されていません")
            }
            
            if let alert = aps["alert"] as? [String: Any] {
                print("Title: \(alert["title"] ?? "No title")")
                print("Body: \(alert["body"] ?? "No body")")
            } else if let alert = aps["alert"] as? String {
                print("Alert: \(alert)")
            }
            
            if let badge = aps["badge"] as? Int {
                print("Badge: \(badge)")
            }
            
            if let sound = aps["sound"] as? String {
                print("Sound: \(sound)")
            }
        }
        
        // カスタムデータの処理
        if let customData = userInfo["custom_data"] as? String {
            print("Custom Data: \(customData)")
        }
        
        // バックグラウンド処理の完了を通知
        // 新しいデータがある場合は .newData、ない場合は .noData、エラーの場合は .failed
        completionHandler(.newData)
    }
    
    // フォアグラウンドで通知を受信した場合
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        print("📨 フォアグラウンドで通知を受信:")
        print("Title: \(notification.request.content.title)")
        print("Body: \(notification.request.content.body)")
        print("UserInfo: \(notification.request.content.userInfo)")
        
        // フォアグラウンドでも通知を表示
        completionHandler([.banner, .sound, .badge])
    }
    
    // 通知をタップした場合
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        print("👆 通知がタップされました:")
        print("Title: \(response.notification.request.content.title)")
        print("Body: \(response.notification.request.content.body)")
        print("UserInfo: \(response.notification.request.content.userInfo)")
        
        completionHandler()
    }

全体像

import SwiftUI
import UserNotifications

class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        
        // 通知センターのデリゲートを設定
        UNUserNotificationCenter.current().delegate = self
        
        // 通知許可をリクエスト
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
            if granted {
                print("✅ 通知許可が承認されました")
                DispatchQueue.main.async {
                    application.registerForRemoteNotifications()
                }
            } else {
                print("❌ 通知許可が拒否されました")
            }
        }
        
        return true
    }
    
    // デバイストークンの取得成功時
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
        print("📱 デバイストークン取得成功:")
        print("Token: \(tokenString)")
        print("Token Length: \(deviceToken.count) bytes")
    }
    
    // デバイストークンの取得失敗時
    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("❌ デバイストークン取得失敗:")
        print("Error: \(error.localizedDescription)")
    }
    
    // バックグラウンドで通知を受信した場合(重要!)
    func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
        print("📨 バックグラウンドで通知を受信:")
        print("UserInfo: \(userInfo)")
        
        // 通知の内容を処理
        if let aps = userInfo["aps"] as? [String: Any] {
            print("APS: \(aps)")
            
            // content-availableの確認
            if let contentAvailable = aps["content-available"] as? Int {
                print("Content-Available: \(contentAvailable)")
            } else {
                print("⚠️ Content-Available が設定されていません")
            }
            
            if let alert = aps["alert"] as? [String: Any] {
                print("Title: \(alert["title"] ?? "No title")")
                print("Body: \(alert["body"] ?? "No body")")
            } else if let alert = aps["alert"] as? String {
                print("Alert: \(alert)")
            }
            
            if let badge = aps["badge"] as? Int {
                print("Badge: \(badge)")
            }
            
            if let sound = aps["sound"] as? String {
                print("Sound: \(sound)")
            }
        }
        
        // カスタムデータの処理
        if let customData = userInfo["custom_data"] as? String {
            print("Custom Data: \(customData)")
        }
        
        // バックグラウンド処理の完了を通知
        // 新しいデータがある場合は .newData、ない場合は .noData、エラーの場合は .failed
        completionHandler(.newData)
    }
    
    // フォアグラウンドで通知を受信した場合
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        print("📨 フォアグラウンドで通知を受信:")
        print("Title: \(notification.request.content.title)")
        print("Body: \(notification.request.content.body)")
        print("UserInfo: \(notification.request.content.userInfo)")
        
        // フォアグラウンドでも通知を表示
        completionHandler([.banner, .sound, .badge])
    }
    
    // 通知をタップした場合
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        print("👆 通知がタップされました:")
        print("Title: \(response.notification.request.content.title)")
        print("Body: \(response.notification.request.content.body)")
        print("UserInfo: \(response.notification.request.content.userInfo)")
        
        completionHandler()
    }
}

@main
struct APNsPushApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

プッシュ通知を発信するための手順

プライベートキー取得

  1. Apple Developer Centerにアクセスする
  2. Certificates, IDs & Profiles > Keys > +ボタン
  • Key Nameを入れて「Apple Push Notifications service (APNs)」にenableのチェックを入れ、「configure」押下
  • 「environment」でsandbox選択し、「Key Restriction」を設定してsave > continue > Register
  • Download押下でプライベートキーを取得(この一回しかダウンロード出来ないので注意)

サーバサイドの実装

今回はPythonでCloud Runで実装します。

requirement.txt

functions-framework==3.*
cryptography==41.0.7
httpx[http2]==0.25.2 
h2==4.1.0            
hpack==4.0.0         
pyjwt==2.8.0

ファイル全体配置

上記取得したプライベートキーもアップロードしておく

ソースコード

上記で確認したデバイストークンなどを環境変数で指定

import json
import jwt
import time
import httpx
from datetime import datetime, timedelta
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
import functions_framework

import os

# APNs設定(環境変数から取得)
TEAM_ID = os.environ.get('TEAM_ID') # Apple Developer Team ID
KEY_ID = os.environ.get('KEY_ID')     # APNs認証キーのID
BUNDLE_ID = os.environ.get('BUNDLE_ID')  # アプリのBundle ID

# デバイストークン(固定値 - 勉強用)
DEVICE_TOKEN = os.environ.get('DEVICE_TOKEN') 


APNS_URL = "https://api.sandbox.push.apple.com/3/device/"

# プライベートキーファイルパス
PRIVATE_KEY_PATH = os.environ.get('PRIVATE_KEY_PATH') 

def create_jwt_token():
    """
    APNs用のJWTトークンを作成
    """
    try:
        # プライベートキーを読み込み
        with open(PRIVATE_KEY_PATH, 'r') as key_file:
            private_key = key_file.read()
            print("privatekey")
            print(private_key)
        # プライベートキーをデコード
        key = serialization.load_pem_private_key(
            private_key.encode('utf-8'),
            password=None,
            backend=default_backend()
        )
        
        # JWTペイロードを作成
        payload = {
            'iss': TEAM_ID,
            'iat': int(time.time())
        }
        
        # JWTトークンを生成
        token = jwt.encode(
            payload,
            key,
            algorithm='ES256',
            headers={
                'kid': KEY_ID,
                'alg': 'ES256'
            }
        )
        
        return token
    
    except Exception as e:
        print(f"JWTトークン作成エラー: {e}")
        return None

def send_push_notification(device_token, title, body, badge=None, sound="default"):
    """
    APNsプッシュ通知を送信(HTTP/2対応)
    """
    try:
        # デバッグ情報を出力
        print(f"🔧 デバッグ情報:")
        print(f"  - APNS_URL: {APNS_URL}")
        print(f"  - BUNDLE_ID: {BUNDLE_ID}")
        print(f"  - TEAM_ID: {TEAM_ID}")
        print(f"  - KEY_ID: {KEY_ID}")
        print(f"  - DEVICE_TOKEN: {device_token[:20]}...{device_token[-20:]}")
        
        # JWTトークンを取得
        jwt_token = create_jwt_token()
        if not jwt_token:
            return {"success": False, "error": "JWTトークンの作成に失敗しました"}
        
        # ヘッダーを設定
        headers = {
            'Authorization': f'bearer {jwt_token}',
            'apns-topic': BUNDLE_ID,
            'Content-Type': 'application/json'
        }
        
        print(f"  - Headers: {headers}")
        
        # ペイロードを作成
        payload = {
            'aps': {
                'alert': {
                    'title': title,
                    'body': body
                },
                'badge': badge,
                'sound': sound,
                "content-available": 1
            }
        }
        
        print(f"  - Payload: {payload}")
        
        # HTTP/2でリクエストを送信
        url = f"{APNS_URL}{device_token}"
        print(f"  - Request URL: {url}")
        
        # httpxクライアントを作成
        # 注意: APNsはHTTP/2のみをサポートするため、http2=Trueが必要
        # ただし、依存関係の問題がある場合は一時的にhttp2=Falseでテスト可能
        try:
            with httpx.Client(
                http2=True, 
                timeout=30.0,
                limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
            ) as client:
                response = client.post(
                    url,
                    headers=headers,
                    json=payload
                )
        except Exception as http2_error:
            # HTTP/2が利用できない場合のエラーハンドリング
            return {
                "success": False,
                "error": f"HTTP/2接続エラー: {str(http2_error)}",
                "note": "APNsはHTTP/2のみをサポートします。httpx[http2]の依存関係を確認してください。"
            }
        
        print(f"  - Response Status: {response.status_code}")
        print(f"  - Response Protocol: {response.http_version}")
        print(f"  - Response Headers: {dict(response.headers)}")
        print(f"  - Response Body: {response.text}")
        
        if response.status_code == 200:
            return {
                "success": True,
                "message": "プッシュ通知が正常に送信されました",
                "status_code": response.status_code,
                "protocol": response.http_version
            }
        else:
            return {
                "success": False,
                "error": f"プッシュ通知の送信に失敗しました: {response.status_code}",
                "response": response.text,
                "protocol": response.http_version,
                "debug_info": {
                    "bundle_id": BUNDLE_ID,
                    "team_id": TEAM_ID,
                    "key_id": KEY_ID,
                    "device_token_length": len(device_token)
                }
            }
    
    except Exception as e:
        return {
            "success": False,
            "error": f"エラーが発生しました: {str(e)}"
        }

@functions_framework.http
def send_apns_push(request):

    # CORSヘッダーを設定
    if request.method == 'OPTIONS':
        headers = {
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET, POST',
            'Access-Control-Allow-Headers': 'Content-Type',
            'Access-Control-Max-Age': '3600'
        }
        return ('', 204, headers)
    
    headers = {
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'application/json'
    }
    
    try:
  
        # デフォルト値を使用
        title = "テスト通知"
        body = "これはテスト用のプッシュ通知です"
        badge = 1
        sound = "default"
        device_token = DEVICE_TOKEN
        
        # プッシュ通知を送信
        result = send_push_notification(device_token, title, body, badge, sound)
        
        return (json.dumps(result, ensure_ascii=False), 200, headers)
    
    except Exception as e:
        error_result = {
            "success": False,
            "error": f"関数実行エラー: {str(e)}"
        }
        return (json.dumps(error_result, ensure_ascii=False), 500, headers)


動作確認

curl "https://{cloud Runエンドポイント}"

Discussion