【習慣トラッカー開発記 #12】なぜ通知実装でWorkManagerとAlarmManagerを組み合わせたのか?

社員ブログ

はじめに

今回は、通知機能の実装中に少し躓いたことがあったため、番外編(第12回)としてまとめました。

習慣トラッカーアプリのリマインド通知において、なぜ WorkManager単体ではなく、WorkManagerとAlarmManager のハイブリッド構成で実装したかについて解説します。

1. 当初考えていた「WorkManager 一本化」について

開発当初は、Androidのバックグラウンド処理の推奨APIであるWorkManager一本で定期的なリマインド通知を実装しようとしていました。

なぜWorkManagerだけでは厳しかったのか?

  • 時間の不確実性(遅延の発生):
    WorkManager(PeriodicWorkRequestなど)は、OSのバッテリー最適化(Dozeモード等)の影響を強く受けます。
    例えば、「12時00分ちょうどに通知」という正確性が保証されず、数分〜数十分の遅延が発生することが多々ありました。
  • 習慣アプリにおける「遅延」について:
    例えば「AM6:00に筋トレをする」という習慣リマインドが「AM6:30」に届いても意味がありません。習慣化には「決められた時間ぴったりに通知が来る」正確性がどうしても捨てきれませんでした。

2. AlarmManager単体ではだめなのか?

指定時間ピッタリに発火できるAlarmManagerへの移行についても検討しました。

なぜAlarmManagerだけでも足りなかったのか?

  • 端末再起動やOSによるアラームのキャンセル:
    スマホが再起動されたり、アプリのプロセスが削除されたりすると、登録していた AlarmManager の予約が消失してしまう問題があります。
  • 状態変化(習慣のチェック済み・一時停止)の大変さ:
    「今日すでに習慣を完了した」や「特定の習慣の一時停止中である」といった動的な状態変化に合わせて、都度アラームを再計算・セットし直す運用コストが高くなってしまいます。

3. 解決策:「WorkManagerとAlarmManagerのハイブリッド構成」について

それぞれの強み・弱みを補うため、以下のように役割分担をしました。

技術担当する役割選定理由
WorkManager毎日のスケジューラーの登録1日1回など定期的に裏で起動し、データベースから「今日の習慣」をフェッチする。対象となる習慣のExactAlarmを一括セットする。
AlarmManager定時の時刻に通知WorkManagerから指定された時刻ぴったりにBroadcastReceiverを起動し、確実に通知を表示する。

処理のフローについて

  1. HabitWorker(WorkManager)が定期起動
    • 今日の習慣リスト(未チェックなど)を取得。
    • 当日の通知指定時刻を計算し、AlarmManager(scheduleExactAlarm)へ役割を渡す。
  2. HabitAlarmReceiver(AlarmManager / BroadcastReceiver)が発火
    • 指定時刻ぴったりに起動。
    • 念のための直前チェック(直前に完了ボタンが押されていないか、削除・通知がOFFになっていないか)を行い、通知を表示する。
    • 次回(翌週など)のアラームを自動セットする。

4. 実装のこだわり

① WorkManagerで今日の対象の習慣を抽出してAlarmをセットする

WorkManager(HabitWorker)で未完了かつ休止中でない習慣をチェックし、対象の時刻にAlarmManager を呼び出します。
Android12(API 31)以降の正確なアラーム権限(SCHEDULE_EXACT_ALARM)対応として、権限がない場合でもアプリがクラッシュしないよう setAndAllowWhileIdle へフォールバックするようにしました。

@HiltWorker
class HabitWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted workerParams: WorkerParameters,
    private val habitRepository: HabitRepository,
) : CoroutineWorker(context, workerParams) {

    override suspend fun doWork(): Result {
        val currentTime = System.currentTimeMillis()
        // 今日の習慣リストを取得(有効なもののみ)
        val habitsList = habitRepository.getTodayHabitsWithLog(/* ... */).first()
            .filter { it.habit.enable }

        habitsList.forEach { item ->
            val isChecked = item.completedAt != null
            val isSuspended = item.habit.pauseEndAt?.let { it > currentTime } ?: false

            // 未完了かつ休止中ではない場合のみアラームを予約
            if (!isChecked && !isSuspended) {
                val targetTime = calculateReminderTime(item.habit.startTime)
                if (targetTime > 0) {
                    try {
                        scheduleExactAlarm(item.habit.id, item.habit.title, targetTime)
                    } catch (e: SecurityException) {
                        Timber.e(e, "正確なアラームの権限がありません")
                    }
                }
            }
        }
        return Result.success()
    }

    // AlarmManagerで指定時間にBroadcastReceiverを起動する
    @RequiresPermission(Manifest.permission.SCHEDULE_EXACT_ALARM)
    private fun scheduleExactAlarm(id: Long, title: String, triggerTime: Long) {
        val alarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
        val intent = Intent(applicationContext, HabitAlarmReceiver::class.java).apply {
            putExtra("HABIT_ID", id)
            putExtra("HABIT_TITLE", title)
        }
        val pendingIntent = PendingIntent.getBroadcast(
            applicationContext, id.toInt(), intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        // Android12以上で正確なアラーム権限がある場合に実行
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) {
            alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
        } else {
            // 権限がない場合のフォールバック処理
            alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
        }
    }
}

② BroadcastReceiverで指定時間に安全に通知を表示する

指定時刻ぴったりに HabitAlarmReceiver が起動するが、ユーザーが直前にアプリ側で完了ボタンが押された可能性もあるため、DBを再確認してから通知を発火させる必要があります。

また、BroadcastReceiver 内で非同期処理を実行する際、pendingResult = goAsync() を呼び出すことで、処理中にOSからプロセスが途中で破棄されるのを防止しています。

@AndroidEntryPoint
class HabitAlarmReceiver : BroadcastReceiver() {

    @Inject
    lateinit var habitRepository: HabitRepository

    override fun onReceive(context: Context, intent: Intent) {
        val habitId = intent.getLongExtra("HABIT_ID", -1L)
        val habitTitle = intent.getStringExtra("HABIT_TITLE") ?: "習慣の時間です"

        // 非同期処理中にOSからプロセスが破棄されるのを防ぐ
        val pendingResult = goAsync()
        CoroutineScope(Dispatchers.IO).launch {
            try {
                // 削除済みチェック
                val habit = habitRepository.getHabitByIdOnce(habitId) ?: return@launch

                // 通知設定チェック
                if (!habit.enable) return@launch

                // 直前に完了済みになっていないかチェック
                val isCompleted = habitRepository.isHabitCompleted(habitId, startOfDay, endOfDay)
                if (isCompleted) return@launch

                // 条件クリアで通知を表示し、次回(翌週など)のアラームを再セット
                showNotification(context, habitId, habitTitle)
                scheduleNextAlarm(context, habit)
            } finally {
                pendingResult.finish()
            }
        }
    }

    private fun showNotification(context: Context, habitId: Long, title: String) {
        // NotificationChannelの生成とNotificationCompatによる通知生成・表示処理
        // ...
    }
}

おわりに

ハイブリットでの実装をする前では、とりあえず推奨されているから、通知機能はWorkManagerを使う、また、正確だからAlarmManagerを使うといった両者の良し悪しで選定をしていましたが、それぞれの得意・不得意を組み合わせて設計することが大切なんだなと改めて感じました。

タイトルとURLをコピーしました