はじめに
Firebase の Cloud Messaging を利用して、iPhone アプリにプッシュ通知を実装し、アプリ内で受信設定を切り替えられるようにしたい。
ネットを検索すると、Firebase によるプッシュ通知に関しては親切な記事が出てくるが、コピペでも動かない…とか、どうやってテストするの…とか、バックグラウンド・フォアグラウンドで受信した時に、どういう動きになるの?など、ワンストップでサクっと解決しなかった。
Firebase のドキュメントにあるサンプルコードで、SwiftUI ベースのものが良いのか UIKit ベースのものが良いのか分からない。アプリ内での受信設定の切り替えについては、調べた限り具体的なコード付きの解説は見つからなかった。
なので、必要そうなことを備忘録としてまとめる。
Firebase で iOS にプッシュ通知は初めましてなので、おかしいところはあるかも知れない。シミュレータと実機テストで期待通りの動きにはなったので、そこまでの備忘録。
実証環境
- Xcode: 16.2
- Simulator: iPhone 16 Pro Max (18.2)
- iPhone(実機): iPhone 14 Pro / iOS 18.3
そろそろ新しい iPhone 欲しいなぁ。でも、iPad が割れた…
方針
- Push 通知は
AppDelegate
に実装する - アプリ内受信設定は、
UserDefaults
に保存する
セットアップ
まずは、XCode でまっさらな iOS アプリを作る。
Firebase SDK の追加
Apple アプリに Firebase をインストールするオプション を参考に Firebase iOS SDK をインストールする。
今回は、Xcode のメニューの File > Add Package Dependencies...
から Firebase-ios-sdk
を追加した。
GitHub からダウンロードするので、Xcode で GitHub とまだ連携していない場合は、メニューの Xcode > Settings...
で設定を開き、Accounts から GitHub を追加して連携する。
(Firebase の設定ではないが、同じ画面なので、必要なので Apple ID は追加しておく。)
SDK はダウンロードしただけでは使えないので、設定する。
アプリ設定の TARGETS > General > Frameworks, Libraries, and Embedded Content
で、FirebaseMessaging
を追加する。
Signing & Capabilities の設定
設定は、TARGETS > Signing & Capabilities
で行う。
Firebase で、APNs(Apple Push Notification Service)の証明書を使うので、Team
, Bundle Identifier
を設定する。後述の APNs は、選択した Team で発行する必要がある。
今回は、Bundle Identifier は dev.takt.Push
とした。
+ Capability
をクリックし、Background Modes
を追加。Remote notifications
にチェックを入れる。
+ Capability
をクリックし、Push Notifications
を追加。
これらの操作を行うと、プロジェクトのルートに、Info.plist
と Push.entitlements
というファイルが作られる。
APNs の登録とダウンロード
まず、App Store Connect でアプリを追加する(必要があるはず)。
(嘘かも知れないけど)登録しないと APNs が発行できないはず。
(嘘かも知れないけど)登録した App の数より多くの APNs はダウンロードできない(ダウンロード済みと言われる)はず。
Bundle ID
を選択するところでは、先程 Bundle Identifier
で設定したものが XC dev takt Push - dev.takt.Push
などと選択肢に出てくるのでそれを選ぶ。
次に、Developer サイトの Certificates, Identifiers & Profiles > Keys で APNs を追加する。
キャプチャがイマイチだが、Key Name を適当に入れて(Push とか)、APNs にチェックを入れ、AuthKey_XXXXX.p8
をダウンロードする。作った時にしかダウンロードできないので、確実にダウンロードする。
この後、Firebase で APNs をアップロードし、Key ID, Team ID を登録するが、各 ID は、
↑の赤いところに書いてあるものを入れる。
Firebase に登録する
Firebase Console でプロジェクトを作成する。適当に作る。
まずは、プロジェクト画面で、アプリを追加で iOS+
というボタンがあるので、追加する。
プロジェクトの設定 > Cloud Messaging
で追加した Apple アプリを選択して、APNs 認証キー を追加する。先程ダウンロードした p8 ファイルと Key ID, Team ID は先述の通りにいれる。
どこかで自然に出てきた気がするが、プロジェクトの設定 > 全般
画面から、GoogleService-info.plist
をダウンロードし、Xcode のプロジェクトのルート(info.plist
と同じところ)に追加する。
GoogleService-info.plist
は、秘匿情報を含んでいる(気がする・積極的には公開したくない)ので、Git で管理する場合は、.gitignore
に追加し、README.md にダウンロードして build してねと注意書きを加えておこう。
以上でセットアップは完了。
AppDelegate を実装する
まずは、アプリ内での受信設定を考慮せずに実装する。Firebase のドキュメントにサンプルがあって、SwiftUI, Swift, Objective-c などの例があるが、AppDelegate.swift
が UI じゃないので、Swift(UIKit ベース)で実装する。
まずは、firebase/quickstart-ios の AppDelegate のサンプルをザッと見ておく。
それを元に、以下のように実装する。
import UIKit
import UserNotifications
import FirebaseCore
import FirebaseMessaging
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication
.LaunchOptionsKey: Any]? = nil
) -> Bool {
FirebaseApp.configure()
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().requestAuthorization(options: [
.alert, .sound, .badge,
]) {
granted, error in
print("DEBUG: Permission granted: \(granted)")
}
UNUserNotificationCenter.current().delegate = self
Messaging.messaging().delegate = self
}
application.registerForRemoteNotifications()
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (
UNNotificationPresentationOptions
) -> Void
) {
completionHandler([[.banner, .list, .sound]])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
NotificationCenter.default.post(
name:
Notification.Name("didReceiveRemoteNotification"), object: nil,
userInfo: userInfo
)
}
}
extension AppDelegate: MessagingDelegate {
@objc func messaging(
_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?
) {
#if DEBUG
print("DEBUG: Firebase token: \(String(describing: fcmToken))")
#endif
}
}
if #available(iOS 10.0, *) {}
は、現在 iOS 10 より古いアプリは申請できないので、必要ない。
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
}
に関しては、Info.plist
で FirebaseAppDelegateProxyEnabled
を設定した場合に Messaging.messaging().apnsToken = deviceToke
と書く必要があるが、設定しない場合、もしくは YES を設定する場合は書く必要がある。FirebaseAppDelegateProxyEnabled
は設定しなくても動くが、毎度 Warning がうるさいので、設定しておいた。これだけしかやらないのであれば、Info.plist
に設定しなくても良いと思われる。が、うるさいので設定した。設定しない場合は、
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {}
こうしておけば良い。削除してしまうと、後々動かなくなるので(理由は深堀りしてない)、どちらかで。
なお、Firebase からの Warning は、
11.8.1 - [FirebaseMessaging][I-FCM001000] FIRMessaging Remote Notifications proxy enabled, will swizzle remote notification receiver handlers. If you'd prefer to manually integrate Firebase Messaging, add "FirebaseAppDelegateProxyEnabled" to your Info.plist, and set it to NO. Follow the instructions at:
https://firebase.google.com/docs/cloud-messaging/ios/client#method_swizzling_in_firebase_messaging
to ensure proper integration.
こんなものが出る。非常にうるさい。
つぎに、
extension AppDelegate: MessagingDelegate {
@objc func messaging(
_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?
) {
#if DEBUG
print("DEBUG: Firebase token: \(String(describing: fcmToken))")
#endif
}
}
この部分だが、後で Firebase からテストメッセージを送る時に、FCM Token が必要になるため、受け取った Token を print した。リリースバージョンでは、余計なものは出さないようにしておいた方が良いので、#if DEBUG
を付けた。
正しく FCM Token を受信すると、以下のようなものが出力される。
DEBUG: Firebase token: Optional("f9jkYmSMwksR....")
作成した AppDelegate
を、App に登録する。
import SwiftUI
@main
struct PushApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
これで Firebase からの Push 通知を受けられるようになった。シミュレーターや実機に build して立ち上げると、requestAuthorization
が最初に通信許可を求めてくれるので、その辺りは個別に実装しなくて良い。今回の実装では、.badge
は使わないが、後で使いたくなった時に再度 Request するのも面倒なので、入れておいた。
テストメッセージを送る
Firebase Console でプロジェクトを選択し、実行 > Messaging
から、最初のキャンペーンを作成
ボタンを押して通知を作成する。
Firebase Notification メッセージを選択して作成
タイトルとテキストを適当に入力して、テストメッセージを送信ボタンを押す
FCM 登録トークンを追加というところに、先程 print した FCM Token をコピペして、+ボタンを押す。
DEBUG: Firebase token: Optional("f9jkYmSMwksR....")
こういうヤツ。これが出ていなければ、何かがおかしい。戻ってひとつずつ確認。
これで、テストボタンを押すと、数秒後に Push 通知が届く。
通知をタップすると、ロック画面・バックグラウンド(アプリを立ち上げてない時)では、アプリが開かれて通知がクリアされ(通知のリストから消える)、フォアグラウンドでは通知がクリアされる。
個別の View を開いたりなどもできるようなので、やりたいことに応じて実装を。
プッシュ通知の Request を拒否した場合は、メッセージは届かない。実機の場合は、設定から許可に変更すれば届くようになる。テストはヌケモレなく。
アプリ内での受信設定を行う
Push 通知が送れるのは、便利だが、ユーザが通知をオフにする方法が見つけづらい(iOS の設定から探すのみ)のは、ユーザフレンドリーとは言えないので、アプリ内で受信設定を実装した方が良い。
なお、以下で紹介する内容は、簡易的?な実装例で、本当に完全な意味で通知を拒否する場合は、iOS の設定でないとできないので、iOS の設定画面へ誘導するようにする必要がある。
さて、View の方は、適当なトグルを作って、設定するようにする。
import SwiftUI
struct ContentView: View {
@State private var receiveNotification: Bool = true
var body: some View {
NavigationStack {
VStack {
Toggle("Enable Notification", isOn: $receiveNotification)
.padding()
Spacer()
}
.navigationTitle("Push Notification")
}
}
}
#Preview {
ContentView()
}
UserDefaults
の操作は、別途 Settings.swift
ファイルにする。
import Foundation
class Settings {
static var receiveNotification: Bool {
get {
return UserDefaults.standard.bool(forKey: "receiveNotification")
}
set {
UserDefaults.standard.set(newValue, forKey: "receiveNotification")
}
}
}
UserDefaults の操作は、@AppStorage
, @Published + didSet
, get/set
など色々オプションがあるが、後々 AppDelegate 内で使いたいので、get/set
にした。
この Settings.swift
の receiveNotification
を ContentView で直接バインドする。
import SwiftUI
struct ContentView: View {
@State private var receiveNotification: Bool = Settings.receiveNotification
var body: some View {
NavigationStack {
VStack {
Toggle("Enable Notification", isOn: $receiveNotification)
.padding()
.onChange(of: receiveNotification) {
Settings.receiveNotification = receiveNotification
}
Spacer()
}
.navigationTitle("Push Notification")
}
}
}
#Preview {
ContentView()
}
以上で、受信設定を UserDefaults
に永続的に保存することができるようになったので、拒否された時に受信しないように、また、拒否から許可に復帰した時に受信するように処理を加える。
AppDelegate
内に全て書ければそれが良いのだが、AppDelegate
は、UserDefaults
の値が変更されるたびに呼び出されたりはしないので、Settings.swift
内で set
する際にその処理を行う。
import Foundation
import UIKit
class Settings {
static var receiveNotification: Bool {
get {
if UserDefaults.standard.object(forKey: "receiveNotification") == nil {
return true
}
return UserDefaults.standard.bool(forKey: "receiveNotification")
}
set {
updateNotificationStatus(isEnabled: newValue)
UserDefaults.standard.set(newValue, forKey: "receiveNotification")
}
}
private static func updateNotificationStatus(isEnabled: Bool) {
DispatchQueue.main.async {
if isEnabled {
UIApplication.shared.registerForRemoteNotifications()
print("DEBUG: プッシュ通知を登録しました")
} else {
UIApplication.shared.unregisterForRemoteNotifications()
print("DEBUG: プッシュ通知を解除しました")
}
}
}
}
set
時に registerForRemoteNotifications
, unregisterForRemoteNotifications
を切り替えれば良い。
先出しになるが、UserDefaults
の値は、ユーザが設定しない限り nil になるが、オプショナルで設定しない場合は、nil は Bool では false になる。AppDelegate
の処理で使う際に、それだと都合が悪いので、初期値的に true を返すようにしてある。今回の場合、弊害はなさそうだが、問題がある場合は、Bool?
にして、nil
の場合の処理を AppDelegate
で書けば良いと思う。
ここで、View に戻り、
Toggle("Enable Notification", isOn: $receiveNotification)
.padding()
.onChange(of: receiveNotification) {
Settings.receiveNotification = receiveNotification
}
の onChange
は Settings.receiveNotification
を直接バインドしているのに必要なのか?という疑問が残る。なくても receiveNotification
の値は正しく変更されるが、その際、set
メソッドは通らず、そこで追加した updateNotificationStatus
は実行されない模様。同様に @Published + disSet
, AppStorage
もうまく行かない。ダサい書き方になっているが、どうせ一箇所だけと目を瞑ることにする。
Refactoring で削除されちゃいそうなので、// DO NOT REMOVE ME!!
などとコメントしておいた方が良いかも知れない。
最後に、AppDelegate
で、アプリ起動時の念の為の処理を加える。
application.registerForRemoteNotifications()
の部分を
if Settings.receiveNotification {
application.registerForRemoteNotifications()
} else {
application.unregisterForRemoteNotifications()
}
と書き換える。
ここで、Messaging.messaging().isAutoInitEnabled
という、FCM Token を自動で生成するかフラグを、受信拒否の場合は false にして禁止した方がより良いとも思うが、UserDefaults
の set
内で true/false
の切り替えをやったところ、うまく反映されなかったので、やらないことにした。プッシュ通知の Request は許可している状態なので、特に問題にはならないと思われる。拒否している場合は、そもそも Firebase まで行かないし。
シミュレーター、実機等で過不足なくテストしてみよう。
Privacy Manifests の設置
実装は完了だが、現在 iOS 17 以降のアプリを App Store に提出する場合、Private Manifests が必要で、今回のように、UserDefaults
を使ったり、Firebase を使う場合書かないといけない。これは実装者の仕事なので、ちゃんとやろう。
何を書くべきかというと、
- アクセスする API
-
NSPrivacyAccessedAPINotificaitons
(プッシュ通知を利用する)- 通常のプッシュ通知(トラッキングなし)なら不要
- 広告やターゲティング通知を Firebase で行うなら記載
-
- 収集するデータ
-
NSPrivacyCollectedDataDeviceID
(Firebase の FCM Token を取得する) -
NSPrivacyCollectedDataUserPreferences
(UserDefaults に設定を保存する)
-
- トラッキング関連
-
NSPrivacyTrackingDomains
(Firebase の通知送信サーバーのドメイン)
-
- 3rd Party SDK
-
NSPrivacyThirdPartySDKs
(Firebase Cloud Messaging の利用)- Firebase Analytics を併用する場合は、それも必要(今回はなし)
-
{
"NSPrivacyAccessedAPITypes": {
"NSPrivacyAccessedAPINotifications": true
},
"NSPrivacyCollectedDataTypes": {
"NSPrivacyCollectedDataDeviceID": {
"NSPrivacyCollectedDataTypeSensitive": false,
"NSPrivacyCollectedDataPurposes": ["AppFunctionality"]
},
"NSPrivacyCollectedDataUserPreferences": {
"NSPrivacyCollectedDataTypeSensitive": false,
"NSPrivacyCollectedDataPurposes": ["AppFunctionality"]
}
},
"NSPrivacyTrackingDomains": [
"firebaseinstallations.googleapis.com",
"fcm.googleapis.com"
],
"NSPrivacyThirdPartySDKs": [
{
"NSPrivacyThirdPartySDKName": "Firebase Cloud Messaging",
"NSPrivacyThirdPartySDKVersion": "11.18.1",
"NSPrivacyThirdPartySDKUsage": ["Push Notifications"]
}
]
}
たぶん、こんな内容になる。分かりやすいように? + 内容が「たぶん」でコピペしないで欲しいので json で書いているが、実際は plist な xml だ。それぞれちゃんとやってください。
PrivacyInfo.xcprivacy
は、plist
のひとつなので、アプリのルート (Info.plist
と同じ階層) に置く。
(2025年2月11日追記) FCM Token を 2 回受信する件
View をレンダリングすると、FCM Token を 2回受けとることがある。無駄に複数回リクエストしてしまっているのではないか?と調べてみたが、2回リクエストするのが正しい動きのようだ。
Firebase iOS SDK の Issue によれば、
his is expected. We recently discover that sometimes, the APNS token is collected from Apple with a slight delay after first token request was issued, so client has to issue a second token request with the apns token mapping in it. So you are getting that scenario which is why you are getting the token callback twice.
It wasn't happening before because there was a bug that the second token request is not triggered and was recently fixed in #6669.
This should not affect your work as long as you always keep your token updated with the Messaging didReceiveRegistrationToken: callback.
via. Messaging didReceiveRegistrationToken called twice on startup after deleting and reinstalling app
APNs トークンがちょっと遅れて受信されるため、APNs トークンのマッピングを含む 2 回目のトークンリクエストを発行する必要がある。以前は 2 回目がトリガーされないというバグがあり、修正済み。didReceiveRegistrationToken
コールバックで FCM Token を監視している限り問題ない。
とのこと。ちょっと鬱陶しいけど、まずい実装で Firebase に無駄な負荷をかけているわけではないようなので、気にしなくて良い。中の人が、そう言って Issue を閉じてるので、大丈夫。
おわり
以上で、Firebase Cloud Messaging を使って iOS アプリにプッシュ通知を送り、かつアプリ内でも受信設定を切り替えることができるようになった。テストも。
こんなもんでプッシュ通知できるなんて素晴らしい!と思いつつ、もうちょっと簡単でも良いのでは?とも思ったりする。
もっとスマートなやり方をご存知の方は、優しく教えて下さい。