Engineering

Chimrの「5分前通知」を秒単位で正確に出すために2段階タイマーにした

  • Chimr
  • Swift
  • macOS
  • Timer
  • カレンダー

Chimr のカレンダー予定通知を、「予定の5分前ぴったり」のような秒単位の精度で出せるようにしました。タイマーを2段階に分けて、30秒おきの periodic scan と、直近120秒以内の予定に対する one-shot timer を組み合わせています。

// 30秒おきの periodic scan
timerToken = timerProvider.scheduledTimer(
  withTimeInterval: 30.0,
  repeats: true
) { [weak self] in
  Task { @MainActor [weak self] in
    self?.checkForUpcomingEvents()
  }
}

// scan の中で、直近 scanLookAheadSeconds(=120秒) 以内に
// 通知時刻が来る予定には one-shot timer を仕込む
private let scanLookAheadSeconds: TimeInterval = 120
ChimrChimr - Never Miss Another MeetingMenu bar meeting reminder with MCP server for Mac. Floating notifications with one-click join for Zoom, Meet, Teams. Let Claude access your calendar.

この記事では、30秒 periodic だけだと精度が出ない理由、one-shot timer を全件分仕込まない理由、2段階に分けたときの動き、そしてシステムスリープと App Nap への対応をまとめます。

30秒 periodic だけだと「5分前通知」が最大29秒ずれる

カレンダー予定の通知は、ユーザー設定の「N分前」に出すのが基本です。素直に実装すると、一定間隔の repeating timer で「いまから N分後付近に始まる予定」を探して通知する形になります。Chimr では当初、30秒間隔の periodic timer がこの役割を担っていました。

timerToken = timerProvider.scheduledTimer(
  withTimeInterval: 30.0,
  repeats: true
) { [weak self] in
  Task { @MainActor [weak self] in
    self?.checkForUpcomingEvents()
  }
}

問題は、30秒間隔の scan だと通知のタイミングが最大29秒ずれることです。「10:00 開始の予定を5分前(=09:55)に通知したい」とき、scan が 09:54:31 と 09:55:01 で走ると、ユーザーから見ると通知は 09:55:01 に来ます。1秒の遅れは気にしませんが、09:54:30 と 09:55:00 のスケジュールだと、09:54:30 の時点では「まだ目標時刻ではない」と判定されて、09:55:00 で初めて通知が出ます。実際のずれは scan のタイミングに依存するため、ユーザーから見ると毎回違う秒数だけ遅れることになります。

scan 間隔を短くすれば精度は上がりますが、CPU と電池を食います。1秒間隔だとずれは1秒以内に収まる代わりに、アイドル時もメニューバーアプリが毎秒走るので、現実的ではありません。

一発 (one-shot) timer だけだと「先の予定まで全部仕込む」必要がある

逆に、すべての予定の通知時刻に対して Timer(fire:) の one-shot を仕込む方針もあります。fireDate を指定すると「その時刻に一度だけ発火」する timer になり、精度は秒単位で出せます。

ただし1日に予定が複数並ぶ状態で、すべての予定について「N分前」の絶対時刻を計算して全部 one-shot を仕込むのは無駄が多いです。10時間後の予定の通知を「いま」スケジュールしても、その時刻までにシステムスリープや復帰、カレンダー側の予定変更が入る可能性があり、結果として再スケジュールが要ります。

加えて、long timer はシステム的に発火の正確さに保証がありません。デバイスのスリープ復帰や App Nap で発火がずれる事例がよく知られています。

2段階に分けて「scan で当たりをつけて、近づいたら one-shot」にする

採用したのは、両方を組み合わせる形です。

  1. 粗く scan する repeating timer (30秒間隔) — 「いまから120秒以内に通知時刻が来る予定」を探す
  2. 見つかった予定に対して one-shot timer を仕込むfireDate ぴったりで発火する

scan が見つけたタイミングで仕込む one-shot は、scanLookAheadSeconds(120秒) 以内に発火するものだけです。それより遠い予定は、次の scan か、その次の scan が拾います。

private let scanLookAheadSeconds: TimeInterval = 120

private func scheduleOneShotTimers() {
  let now = Date()
  let notificationMinutes = settings.notificationTiming.rawValue
  let todayEvents = calendarViewModel.notificationEvents(for: now)

  for event in todayEvents {
    let key = NotificationKeyGenerator.generateKey(
      for: event, notificationMinutes: notificationMinutes)

    // 既に通知済み、もしくは既にスケジュール済みならスキップ
    guard !notifiedEvents.contains(key), oneShotTokens[key] == nil else { continue }

    // 通知時刻 (event.startDate の N分前) を計算
    guard let fireDate = Calendar.current.date(
      byAdding: .minute, value: -notificationMinutes, to: event.startDate
    ) else { continue }

    // 直近 scanLookAheadSeconds (=120秒) 以内に発火するものだけ仕込む
    let delay = fireDate.timeIntervalSince(now)
    guard delay > 0, delay <= scanLookAheadSeconds else { continue }

    // 厳密な時刻発火 (一度だけ)
    let token = timerProvider.scheduledTimer(fire: fireDate) { [weak self] in
      Task { @MainActor [weak self] in
        self?.fireOneShotNotification(for: event, key: key)
      }
    }
    oneShotTokens[key] = token
  }
}

fireDate ぴったりで発火する one-shot のおかげで、秒単位精度の通知になります。同時に long timer を全件分仕込まないので、メモリと将来の再計算コストも抑えられます。30秒の scan で「次の2分以内に通知時刻が来る予定」を見つけ次第、その時刻に向けた one-shot を仕込むだけです。

システムスリープとカレンダー変更にも追従する

one-shot timer はスケジュール時の状態に依存します。途中でシステムがスリープしたり、カレンダーの予定が変わったりすると、スケジュール済みの timer が無効化されたまま忘れられがちです。

// スリープ復帰時に再スケジュール
NSWorkspace.shared.notificationCenter.addObserver(
  self,
  selector: #selector(handleSystemWake),
  name: NSWorkspace.didWakeNotification,
  object: nil
)

// カレンダー変更時に再スケジュール
NotificationCenter.default.addObserver(
  self,
  selector: #selector(handleCalendarStoreChanged),
  name: NSNotification.Name.EKEventStoreChanged,
  object: nil
)

NSWorkspace.didWakeNotificationEKEventStoreChanged の2つを購読しておき、それぞれのタイミングで全 one-shot を invalidate してから scan を走らせ直します。これで「スリープ中に通知時刻を跨いだ」「予定の開始時刻が変わった」のどちらにも追従できます。

App Nap を抑える ProcessInfo activity を握る

macOS には App Nap という機能があり、フォアグラウンドにいないアプリの実行頻度が抑えられます。メニューバー常駐アプリの Chimr は基本的にバックグラウンドにいるので、放っておくと App Nap で timer の発火がずれます。

そこで、one-shot timer をスケジュール中は ProcessInfo.beginActivity で activity を握っておき、最後の one-shot timer が片付いたら endActivity で解放する設計にしました。

private var processInfoActivity: NSObjectProtocol?

private func updateProcessInfoActivity() {
  if oneShotTokens.isEmpty {
    if let activity = processInfoActivity {
      ProcessInfo.processInfo.endActivity(activity)
      processInfoActivity = nil
    }
  } else if processInfoActivity == nil {
    processInfoActivity = ProcessInfo.processInfo.beginActivity(
      options: .userInitiatedAllowingIdleSystemSleep,
      reason: "Precise notification timer pending"
    )
  }
}

オプションには .userInitiatedAllowingIdleSystemSleep を指定しています。アプリ側の App Nap や自動終了は抑止しつつ、ユーザーのマシン側のアイドルスリープまでは妨げない選択です。120秒以内に発火する分だけ activity を握る設計なので、電池への影響も1日のうちの累計で数分にとどまります。

App StoreChimr - Meeting Reminder App - App StoreDownload Chimr - Meeting Reminder by Labee LLC on the App Store. See screenshots, ratings and reviews, user tips and more games like Chimr - Meeting Reminder.

「予定の N分前ぴったりに通知が来る」のは、書いてみれば当たり前の挙動です。それでも、単純な repeating timer 一本だと秒単位の精度は出ませんし、すべて one-shot で持つと運用が重くなります。30秒 scan + 120秒以内の one-shot という2段階の組み合わせは、いまのところ Chimr の通知の体感速度に効いています。