本文へスキップ
すべての記事

Mintly を作る: Android がプロセスを殺そうとする中で集中タイマーを正確に保つ

動作中の Pomodoro タイマーは、一度きりのリマインダーより難しい信頼性の問題を抱えている。フォアグラウンドサービスと壁時計ベースの終了時刻で、Mintly が Doze・プロセス終了・画面オフ中のずれをどう乗り越えたかを解説する。

MFKAPPS 1 分で読めます

一度きりのリマインダーは、だいたい正しいタイミングで一度発火すればよい。集中タイマーはもっと難しい問題だ — 画面がオフの間も、Android がセッションの途中でプロセスを殺して再起動させたとしても、25分間ずっと目に見える形で正確でなければならない。ここを間違えると、失敗の形は通知の欠落ではない。「残り12:41」と表示されたまま丸1分固まったタイマーや、スマホをちらっと見ただけで25:00にリセットされるタイマーになる。

Mintly を作る過程でその両方にぶつかった。それを解決したのがこのアーキテクチャだ — フォアグラウンドサービス、減っていくカウンタではなく壁時計ベースの終了時刻、そしてサービスに再描画を任せるのではなく自分自身でカウントダウンを描く通知。

教訓をくれたバグ

最初のバージョンはありがちなものだった。毎秒ティックし、remainingSeconds フィールドを減らし、ティックごとに更新された通知を出す、CoroutineWorker に近いループだ。エミュレータでは完璧に動いたが、実機では1日で崩壊した。失敗モードは2つあった。

  • まとめられたティック。 Doze モードはウェイクアップをまとめてしまう。画面がオフになった後の「毎秒1ティック」ループは実際には毎秒実行されない — システムがそれを遅延させ、その後まとめて複数のティックを配信する。目に見えるカウントダウンがカクついたり飛んだりする。
  • 静かなプロセス終了。 システムがメモリを必要としてアプリのプロセスを殺すと、メモリ上の remainingSeconds フィールドもそれと一緒に消える。再起動時には再開できる材料が何もなく、タイマーは消えるか元の長さから再スタートする — ユーザーの視点では、5分間の集中がいきなり消えたように見える。

修正は「もっと確実にティックする」ことではなかった。残り時間を「生き残る必要がある状態」として扱うのをやめ、「いつでも再計算できるもの」として扱うことだった。

カウントダウンではなく終了時刻を保存する

核心的な変更はこれだ。セッションが始まった瞬間に単一の sessionEndAtMillis(壁時計時間、System.currentTimeMillis() ベース)を永続化し、残りはすべて必要なときにそこから導出する。

data class FocusSession(
    val id: Long,
    val ritual: RitualType,
    val startedAtMillis: Long,
    val endAtMillis: Long,
    val state: SessionState, // RUNNING, PAUSED, COMPLETED
)

fun FocusSession.remainingMillis(nowMillis: Long = System.currentTimeMillis()): Long =
    (endAtMillis - nowMillis).coerceAtLeast(0L)

この1つの変更で、システム全体が自己修復するようになる。何個のティックが失われようと、その間にプロセスが殺されて再起動しようと関係ない — remainingMillis() は呼ばれた瞬間に正しく、ループから積み上げるのではなく2つのタイムスタンプから計算される。セッションは開始した瞬間に Room に永続化されるので、まっさらなプロセスでもそれを再読み込みすれば、自分が正確にどこにいるかを即座に把握できる。

ほとんどティックしないフォアグラウンドサービス

サービスの仕事は縮小して、完了に気づけるだけプロセスを生かしておくことと、通知をそれなりに新鮮に保つことになる。真実の情報源を持っているのはサービスではない。

class FocusTimerService : Service() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val session = repository.activeSessionOrNull() ?: run { stopSelf(); return START_NOT_STICKY }

        startForeground(NOTIFICATION_ID, buildChronometerNotification(session))

        scope.launch {
            while (isActive) {
                val remaining = session.remainingMillis()
                if (remaining <= 0L) {
                    repository.markCompleted(session.id)
                    postCompletionNotification(session)
                    stopSelf()
                    break
                }
                delay(15_000) // refresh cadence, not the source of truth
            }
        }
        return START_REDELIVER_INTENT
    }
}

このほとんどの仕事を2つの細部が担っている。

  • 通知の setUsesChronometer(true) chronometerCountDown = true を指定し、endAtMillis から計算した基準時刻で初期化する。すると Android 自身の通知システムが、自分のループではなく自身の時計を使ってカウントダウンを毎秒再描画してくれる。私のサービスが15秒ごとにしかリフレッシュしなくても、表示される数字が決してカクつかない。
  • START_STICKY ではなく START_REDELIVER_INTENT システムがサービスを殺した場合、元の インテントで再起動され、onStartCommand はメモリ上に保持されていた何かを信頼するのではなく Room から activeSessionOrNull() を読み直す。殺されて再起動したサービスは、一度も死ななかったサービスとまったく同じ通知を作り出す。

確実アラームによる安全網

フォアグラウンドサービスはバックグラウンドサービスよりはるかに殺されにくいが、「はるかに殺されにくい」は保証ではない — 積極的な OEM のバッテリーマネージャーがそれでも落とすことはある。そこで完了通知には、サービスの生存にまったく依存しない第二の経路がある。セッション開始時点で endAtMillis にスケジュールされ、Hydrame のリマインダー について書いたのと同じ setExactAndAllowWhileIdle パターンを使う確実アラームだ。アラームが発火したときにサービスが生きていれば、それは無害な冗長チェックにすぎない。サービスが殺されていたら、実際に時間通りに「セッション完了」を投稿するのはこのアラームだ。ベルトとサスペンダーのようなものだが、サスペンダーの代償は AlarmManager の呼び出し1回だけだ。

アプリを再開したときに最初に確認すること

アプリがフォアグラウンドに戻るとき — 新規起動から、通知から、あるいはマルチタスク画面から — 集中画面が何よりも先にすることは、何も描画する前に activeSessionOrNull() を読み、remainingMillis() を呼ぶことだ。「離れる前にタイマーは動いていたか?」という古びる可能性のあるフラグは存在しない。残り時間のあるアクティブなセッションが Room にあれば UI はまさにそれを表示し、なければリチュアル選択画面を表示する。答えを計算できる場所が1つしかないので、タイマーが自分自身と矛盾することはあり得ない。

一般化できる教訓

このパターンは集中タイマーを超えて一般化できる。生きたカウントダウンを可変のメモリ内状態として保持したくなったときはいつでも、それを固定の終了タイムスタンプと「今」の純粋関数に置き換えればいい。ティックは真実の情報源ではなくリフレッシュの頻度になる。つまり、失われたティック、殺されたプロセス、Doze モードのまとめ処理は正しさのバグではなくなる — それらは単に誰も質問をしなかった瞬間にすぎず、次に誰かが質問したときには答えは依然として正しい。