0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

expo-notificationsのDATEトリガーは、iOSでカレンダートリガーにならない

0
Posted at

対象バージョン: expo-notifications 0.32.17 / iOS
挙動がバージョンに依存する話なので、読む前にご自身の package.json を確認してください。

結論

Notifications.SchedulableTriggerInputTypes.DATE は、名前から想像されるような「日付を指定するトリガー」ではありません。iOSネイティブ側では UNCalendarNotificationTrigger ではなく、UNTimeIntervalNotificationTrigger に変換されます。

つまり 0.32.17 の時点では、DATE と TIME_INTERVAL の最終的な挙動に差はありません。

ただし私は、絶対時刻の通知には TIME_INTERVAL を明示的に使うルールにしています。理由は記事の後半に書きます。

なぜ気になったか

株主優待のクロス取引(つなぎ売り)を管理するiOSアプリを個人開発しています。このアプリの中心機能は「現渡しを忘れないためのリマインダー通知」です。

現渡しは、権利落ち日の寄り付き前に注文を出す必要があります。東証が開くのは9時なので、通知は日本時間の朝8時に鳴らなければ意味がありません。

ここで問題になるのが、ユーザーが海外にいるケースです。時差のある場所に滞在していても、通知は日本時間の8時に鳴ってほしい。「現地時刻の8時」に鳴っても、そのときには日本の寄り付きは終わっています。

要件を整理すると、こうなります。

  • 欲しいもの: 特定の絶対時刻(JSTの2026-09-29 08:00という一瞬)に発火する通知
  • 困るもの: 端末のタイムゾーンに応じて発火時刻が変わる通知

expo-notifications のトリガーには DATE と TIME_INTERVAL があります。名前だけ見ると、DATE は「日付を指定する」=カレンダー的なもので、端末のタイムゾーンに引っ張られそうに見えます。実際のところどうなのか、ソースを読んで確認しました。

JS側を追う

scheduleNotificationAsync.ts に、トリガー引数をネイティブへ渡す形式に変換する処理があります。

DATE を渡すと parseDateTrigger() が呼ばれ、{ type: 'date', timestamp } という形になります。この時点で日付はエポックミリ秒に潰れています。

TIME_INTERVAL のほうは parseTimeIntervalTrigger() が処理し、{ type: 'timeInterval', seconds, repeats } になります。

JS層では、まだ両者は別物です。

Swift側を追う

分岐点は SchedulerModule.swift の triggerFromParams() にある switch文です。type の値を見て、対応する TriggerRecord の実装へ振り分けています。

TIME_INTERVAL 側(Records.swift の TimeIntervalTriggerRecord)は素直です。

trigger = UNTimeIntervalNotificationTrigger(timeInterval: self.seconds, repeats: self.repeats)

問題は DATE 側(同じく Records.swift の DateTriggerRecord)です。

public func toUNNotificationTrigger() throws -> UNNotificationTrigger? {
  let timestamp: Int = Int(self.timestamp / 1000)
  let date: Date = Date(timeIntervalSince1970: TimeInterval(timestamp))
  var trigger: UNNotificationTrigger?
  try EXUtilities.catchException {
    trigger = UNTimeIntervalNotificationTrigger(timeInterval: date.timeIntervalSinceNow, repeats: false)
  }
  return trigger
}

(expo-notifications はMITライセンスです)

UNCalendarNotificationTrigger は出てきません。タイムスタンプから Date を復元したあと、timeIntervalSinceNow で「スケジュールを実行した瞬間から何秒後か」を計算し、その秒数を interval トリガーに渡しています。

DATE も TIME_INTERVAL も、最終的には同じ UNTimeIntervalNotificationTrigger に収束します。差は、秒数を自分で計算するか、ライブラリに計算させるかだけです。

そして interval トリガーは、スケジュール時点を起点にした純粋な経過時間で発火します。端末のタイムゾーンを途中で変えても、発火する瞬間は動きません。

つまり冒頭の心配は、少なくとも 0.32.17 では杞憂でした。DATE を使っても、通知は絶対時刻で鳴ります。

では、なぜDATEを使わないのか

ここからは私の判断であり、事実というより方針の話です。

私は自分のプロジェクトのCLAUDE.mdに「DATEを使わない」と書いています。ソースを読んだ結果として挙動に差がないと分かった今でも、このルールは残しました。理由は3つあります。

1. これは公開APIの契約ではなく、実装詳細だから

ドキュメントやJSDocのコメントは、DATEトリガーを「次の正時に一度だけ発火させる」といったカレンダー的な用途で例示しています。「内部的には経過時間に変換されます」とは書かれていません。書かれていないということは、変えても契約違反ではないということです。

2. 実際に何度も変更されている箇所だから

CHANGELOGを遡ると、DateTrigger の扱いは繰り返し手が入っています。古いところでは「Fixed interpretation of Date and number triggers on iOS」という記述があり、比較的新しいバージョンでも Android側の DateTrigger 簡略化などが入っています。安定して固定された部分ではありません。

将来のアップデートで「DATEなんだから素直に UNCalendarNotificationTrigger にすればいい」という、ある意味より自然な実装に変わる可能性は否定できません。**これは推測です。**そうなるという情報を持っているわけではありません。

3. もし変わった場合、国内のテストでは絶対に気づけないから

ここが一番怖い点です。

仮に DATE がカレンダートリガーになったとして、日本国内で開発している限り、現地時刻=JSTなので何も起きません。単体テストも通ります。実機で触っても正常に鳴ります。壊れていることが観測できるのは、海外に滞在しているユーザーの端末だけです。

こちらのQAをすり抜けて、ユーザー側でだけサイレントに壊れる。しかも壊れ方が「通知が来ない」なので、ユーザーからは「アプリが動かない」としか見えません。

ライブラリ更新のたびに node_modules の Swift を読み直すのは現実的ではないので、そもそも依存しないことにしました。

実際の実装

秒数は自分で計算して TIME_INTERVAL に渡しています。

const seconds = Math.max(1, Math.round((notification.fireAt.getTime() - now.getTime()) / 1000));

Math.max(1, ...) は、発火時刻が「1秒未満の未来」だったときに秒数が0になるのを防ぐためのガードです。

この書き方には、意図を明示できるという副次的な効果もあります。fireAt - now という式そのものが「絶対時刻を経過時間に変換している」という設計判断を表しているので、あとから読んだときに何をやっているか分かります。DATEを渡していたら、この判断はライブラリの中に隠れたままです。

まとめ

  • expo-notifications 0.32.17 の iOS実装では、DATE も TIME_INTERVAL も UNTimeIntervalNotificationTrigger になる
  • 絶対時刻の通知が必要なら、現時点ではどちらでも動く
  • ただしDATE→interval変換は実装詳細であり、依存すると将来のアップデートで壊れる可能性がある
  • しかもその壊れ方は、開発環境のタイムゾーンでは再現しない

ライブラリのAPI名から挙動を推測するのをやめて、ソースを読むと10分で答えが出ました。node_modules を読むのは面倒ですが、名前と実装が食い違っている箇所は、読まないと一生気づけません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?