Разработка Mintly: как удержать точность таймера фокуса, когда Android хочет убить процесс
У работающего таймера Pomodoro проблема с надёжностью сложнее, чем у одноразового напоминания. Вот как Mintly переживает режим Doze, гибель процесса и дрейф при выключенном экране благодаря foreground-сервису и времени окончания по системным часам.
Одноразовому напоминанию нужно сработать всего один раз, примерно в нужное время. Таймер фокуса — задача сложнее: он должен оставаться видимо и непрерывно точным все 25 минут, даже при выключенном экране, даже если Android решит убить процесс посреди сессии и перезапустить его. Если тут ошибиться, провал выглядит не как пропущенное уведомление — это таймер, который целую минуту показывает «осталось 12:41», или который сбрасывается на 25:00 после того, как вы просто взглянули на телефон.
Я столкнулся с обоими случаями, разрабатывая Mintly. Вот архитектура, которая это исправила: foreground-сервис, время окончания по системным часам вместо убывающего счётчика, и уведомление, которое само рисует свой обратный отсчёт вместо того, чтобы полагаться на перерисовку сервисом.
Баг, который преподал мне урок
Моя первая версия была очевидной: цикл, похожий на CoroutineWorker, который тикал каждую секунду, уменьшал поле remainingSeconds и публиковал обновлённое уведомление на каждом тике. В эмуляторе всё работало идеально, а на реальном телефоне развалилось за день. Было два режима отказа:
- Слипшиеся тики. Режим Doze группирует пробуждения. Цикл «один тик в секунду» на самом деле не выполняется каждую секунду после выключения экрана — система откладывает его, а затем доставляет сразу несколько тиков пачкой. Видимый обратный отсчёт заикается или скачет.
- Тихая гибель процесса. Если системе требовалась память и она убивала процесс приложения, поле
remainingSecondsв памяти исчезало вместе с ним. При перезапуске возобновлять было нечего, поэтому таймер либо пропадал, либо запускался заново с исходной длительностью — с точки зрения пользователя пять минут фокуса просто исчезали.
Решением было не «тикать надёжнее». Решением было перестать относиться к оставшемуся времени как к состоянию, которое должно выжить, и начать относиться к нему как к тому, что всегда можно пересчитать.
Храните время окончания, а не обратный отсчёт
Ключевое изменение: сохранять единственное значение 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)
Это единственное изменение делает всю систему самовосстанавливающейся. Неважно, сколько тиков было потеряно и убивался ли процесс между ними с перезапуском — remainingMillis() верна в тот самый момент, когда её вызывают, потому что она вычисляется из двух временных меток, а не накапливается в цикле. Сессия сохраняется в Room в момент старта, поэтому свежий процесс может перезагрузить её и мгновенно узнать, в какой именно точке она находится.
Foreground-сервис, который почти не тикает
Задача сервиса сжимается до: держать процесс живым достаточно долго, чтобы заметить завершение, и поддерживать уведомление достаточно свежим. Источником истины он не является.
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
}
}
Основную работу выполняют две детали:
setUsesChronometer(true)на уведомлении, инициализированное сchronometerCountDown = trueи базой, вычисленной изendAtMillis. Собственная система уведомлений Android затем перерисовывает обратный отсчёт каждую секунду, используя свои собственные часы — а не мой цикл. Отображаемое число никогда не заикается, даже если мой сервис обновляется лишь раз в 15 секунд.START_REDELIVER_INTENT, а неSTART_STICKY. Если система убивает сервис, он перезапускается с исходным intent, иonStartCommandзаново читаетactiveSessionOrNull()из Room, а не полагается на что-либо, хранившееся в памяти. Убитый и перезапущенный сервис выдаёт уведомление, идентичное тому, что выдал бы сервис, который никогда не умирал.
Страховка в виде точного будильника
У foreground-сервиса шанс быть убитым гораздо ниже, чем у фонового, но «гораздо ниже» — не гарантия: агрессивный менеджер батареи от производителя всё равно может его свалить. Поэтому у завершения сессии есть второй путь, вообще не зависящий от того, выживет ли сервис: точный будильник, запланированный на endAtMillis в момент старта сессии, по той же схеме setExactAndAllowWhileIdle, что я описывал для напоминаний Hydrame. Если сервис жив в момент срабатывания будильника, это безобидная избыточная проверка. Если сервис был убит, именно этот будильник реально публикует «Сессия завершена» вовремя. Ремень и подтяжки одновременно, но подтяжки обходятся всего в один вызов AlarmManager.
Что приложение проверяет первым делом при возобновлении
Когда приложение возвращается на передний план — после свежего запуска, из уведомления или из переключателя многозадачности — первое, что делает экран фокуса, прежде чем что-либо отрисовать, — читает activeSessionOrNull() и вызывает remainingMillis(). Нет никакого флага «работал ли таймер до того, как я ушёл», который мог бы устареть. Либо в Room есть активная сессия с оставшимся временем, и интерфейс показывает именно это, либо её нет, и показывается выбор ритуала. Таймер не может противоречить сам себе, потому что вычислить ответ разрешено только в одном-единственном месте.
Общий урок
Этот паттерн обобщается далеко за пределы таймеров фокуса: всякий раз, когда возникает соблазн хранить живой обратный отсчёт как изменяемое состояние в памяти, замените его фиксированной временной меткой окончания и чистой функцией от «сейчас». Тики становятся просто частотой обновления, а не источником истины, а значит, потерянные тики, убитые процессы и группировка режима Doze перестают быть ошибками корректности — это просто моменты, когда никто не задал вопрос, а ответ всё равно останется верным, когда его зададут в следующий раз.
// По теме
Ещё из журнала
Как устроен Subly: календарная математика за датами продления подписок
Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.
Правильные действия в уведомлениях Android: отметить приём дозы без открытия приложения
Практическое руководство по кнопкам действий в уведомлениях Android — PendingIntent, ограничение activity-трамплина, goAsync() и безопасное обновление состояния Room.
Надёжные напоминания на Android в 2026 году: WorkManager, точные будильники и новые правила батареи
Как в 2026 году выпускать напоминания на Android, которые действительно срабатывают — WorkManager против AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS и причуды производителей, которые всё ещё кусаются.