おいしい健康 開発者ブログ

株式会社おいしい健康で働くエンジニア・デザイナーが社内の様子をお伝えします。

UNNotificationCenterの通知が発火しない

こんにちは、iOSエンジニアの山田です。

おいしい健康アプリでもローカルのプッシュ通知を実装しており、とくに食事や体重といった記録のリマインダーとして活用しています。 直近で既存のローカルのプッシュ通知の実装を大きく作り変える機会があり、その中で調査と検証を繰り返して得られた知見について紹介します。

大きく分けて以下の4つになります。

  • プッシュ通知で利用されるメソッドの新旧
  • プッシュ通知を削除する上での注意点
  • 通知が発火しない落とし穴
  • 簡便なデバッグ方法

プッシュ通知で利用されるメソッドの新旧

プッシュ通知の実装は以下の3ステップに大別されます。

  1. 通知リクエストの作成(UNNotificationRequest)
  2. 通知リクエストの登録
  3. 通知リクエストの削除

通知リクエストの作成に関しては、UNNotificationRequestというクラスを利用するため、過去と現在で大きな違いはありません。 一方で、「登録」と「削除」にはメソッドが提供されており、async/awaitの導入に伴い、新しいメソッドが追加されました。

おいしい健康アプリもasync/awaitの仕組みを積極的に使っています。 今回の改修においても元々あったクロージャをwithCheckedContinuationで対応させようと考えていましたが、書き換える中でasync/awaitに適合したメソッドが追加されていることに気づきました。 またスケジュールされた通知リクエストを取得するメソッドもasync/await対応されており、通知リクエストを削除する上で非常に便利になりました。

通知リクエストの登録

 await UNUserNotificationCenter.current().add(request)

スケジュールされている通知の取得

 await UNUserNotificationCenter.current().pendingNotificationRequests()

一方で、削除に関する以下のメソッドはasync/await非対応のままです。 というよりも、そもそも削除に関しては完了時のハンドラが提供されていないんですね。

というわけで、ざっくりと通知リクエストのasync/await対応をまとめると次のようになります。

通知リクエスト async/await対応
通知リクエストの登録 あり
スケジュールされた通知の取得 あり
通知リクエストの削除 なし(ハンドラなし)

プッシュ通知を削除する上での注意点

上記で挙げた通知リクエストの削除のメソッドについては、他と違って使い方に若干の注意が必要です。

たとえばすでに存在している通知リクエストの内容を更新して登録したいとしましょう。 まだ通知リクエストの理解が十分でない段階では、とりあえず既存のスケジュールされた通知リクエストを全て削除し、その上で新しく通知リクエストを登録し直すということを考えます。

実装としてはナイーブに登録する直前に全て削除するremoveAllPendingNotificationRequests()を呼び出せば良さそうに思います。 しかし、これは動作として一見問題ないかのように見えるのですが、テストでは容易に失敗します。 なぜでしょうか。

メソッドの新旧比較で紹介しましたが、削除のみ非同期処理の上で完了時のハンドラを提供していないメソッドだからです。 後から呼ばれる通知の登録が先に完了してしまい、その上で削除されるという事態が発生します。 これでは本来意図した通知のリクエストが行えません。

どこかで削除自体は短い時間で行われるよ!ということがどこかに書かれていた覚えがありますが、テストで失敗する以上登録の直前に削除する方法はよくありません。 また全て削除するというのも、他の通知との兼ね合いが出てきた際に考慮が必要になるのでよくないでしょう。

では、どうしたらよいのかというと、削除ではなく更新をする必要があります。 通知リクエストはidentifierを持っており、その値で識別されます。 同一のidentifierで登録すると、通知リクエストが置き換えられるので削除をする必要がなくなり、更新と削除を別に扱うことができるようになることでテストにも失敗することがなくなります。

if shouldUpdateRequest {
    // identifierを指定して登録
} else {
    // identifierを指定して削除
}

通知が発火しない落とし穴

実機ビルドでの検証の過程で遭遇した、通知が発火しない不具合を紹介します。

今回、プッシュ通知の実装を大幅に作り変えたこともあり、通知リクエストに関わる挙動全般のテストは設けるつもりで進めていました。

スケジュールされている通知はgetPendingNotificationRequests(completionHandler:)を利用することで取得することが可能です。

取得した値から予定されている値を直接的な参照できるようなプロパティは存在していませんが、 debugDescriptionからstringとして予定される日時を確認することはできます。

そこでSwift5.7で追加されたRegexLiteralで必要な要素を抽出して、 予定された日時と最終的に登録されるスケジュール日時が一致しているかを確認するテストを設けました。

RegexLiteralを利用すると、まるでSwiftUIのような形で正規表現を書くことができます (NSRegularExpressionに比べると実行処理に時間がかかるとのことで利用に関しては考慮必要かも)

    func extraDateTimeFromNotificationRequest(from input: String) -> DateComponents? {
        /*
         --- UNNotificationRequest.UNNotificationTrigger.debugDescription ---
         // 日付が指定されている場合
         Optional(<UNCalendarNotificationTrigger: 0x6000006b7da0; dateComponents: <NSDateComponents: 0x60000000b6f0> {
            Calendar: <_NSSwiftCalendar: 0x60000000b720>
            Calendar Year: 2025
            Month: 2
            Day: 2
            Hour: 20
            Minute: 0, repeats: NO>)
    
         // 日付の指定がない場合
         Optional(<UNCalendarNotificationTrigger: 0x6000006b84c0; dateComponents: <NSDateComponents: 0x600000012d00> {
             Calendar: <_NSSwiftCalendar: 0x600000012cf0>
             Hour: 13
             Minute: 0, repeats: YES>)
         */
    
        let month = Reference(Int?.self), day = Reference(Int?.self), hour = Reference(Int?.self), minute = Reference(Int?.self)
    
        let regex = Regex {
            /// yearも含めて抽出したいところであるがOptionallyが3つ以上ある場合にcompileが通らなくなるため除外
    //        Optionally {
    //            "Calendar Year:"
    //            ZeroOrMore(.whitespace)
    //            Capture(OneOrMore(.digit), as: year) { Int($0) }
    //            ZeroOrMore(.any, .reluctant)
    //        }
    
            Optionally {
                "Month:"
                ZeroOrMore(.whitespace)
                Capture(OneOrMore(.digit), as: month) { Int($0) }
                ZeroOrMore(.any, .reluctant)
            }
    
            Optionally {
                "Day:"
                ZeroOrMore(.whitespace)
                Capture(OneOrMore(.digit), as: day) { Int($0) }
                ZeroOrMore(.any, .reluctant)
            }
    
            "Hour:"
            ZeroOrMore(.whitespace)
            Capture(OneOrMore(.digit), as: hour) { Int($0) }
            ZeroOrMore(.any, .reluctant)
    
            "Minute:"
            ZeroOrMore(.whitespace)
            Capture(OneOrMore(.digit), as: minute) { Int($0) }
        }
    
        let match = input.firstMatch(of: regex)!
        // UNNotificationRequestにhour, minuteが必須
        if let hour = match[hour], let minute = match[minute] {
            if let month = match[month], let day = match[day] {
                return DateComponents(month: month, day: day, hour: hour, minute: minute)
            } else {
                return DateComponents(hour: hour, minute: minute)
            }
        } else {
            print("failed to extract request date: \(input)")
            return nil
        }
    }

RegexLiteralに不慣れな部分もありつつも、一通りのテストを用意した上で意図通り成功するところまで修正を繰り返しました。 これで大丈夫だろうと思い実機ビルドでの検証に移りました。

が!!!実機ビルドで確認したところ一向に通知が発火しません.....。 もっと詳細にdumpで調べていく他ないかと思いつつ調べていたら、同様の問題にあたっている方がちらほらいることがわかりました。

最終的になにが問題であったかというと、通知リクエストの発火のタイミングとして渡すDateComponentsに含まれるquarterプロパティでした。 quarterの値を指定しておらずnilとして扱われるものだと思っていましたが、実際には意図せず値が存在している状態でインスタンスが作られていました。 そのために実際に通知が発火されなくなっており、明示的にnilを指定することで通知が発火することが確認できるようになりました。

var dateComponents = DateComponents()
dateComponents.quarter = nil
// 他のプロパティを設定

ずいぶん以前に報告されている事象のようで、なぜそうなるのかに関しては調査しても明確な答えを得られませんでした。

参考:http://www.openradar.me/35247464

もし実機で発火しないということがあれば、ぜひこの点を確認してみてください。

簡便なデバッグ方法

通知に関しては、設定の日付と時刻で時間帯を変えることで、通知の発火を確認することができます。 通知リクエストの登録は未来の日付に対してのみ行われるので、過去の日時に戻っても通知は飛びませんが、戻った過去の時点から未来の日付に進む分には通知が行われます。

おわりに

以上、ローカルプッシュ通知の改修で得られた知見について紹介させていただきました。 簡易的なローカルプッシュに関しては実装した経験があったのですが、思いの外考慮することが多くテストコードのありがたみを感じました。

今後もiOS開発における知見を共有していければと思います。