Перейти к содержимому
Все записи

Разработка Mintly: как удержать точность таймера фокуса, когда Android хочет убить процесс

У работающего таймера Pomodoro проблема с надёжностью сложнее, чем у одноразового напоминания. Вот как Mintly переживает режим Doze, гибель процесса и дрейф при выключенном экране благодаря foreground-сервису и времени окончания по системным часам.

MFKAPPS 4 мин чтения

Одноразовому напоминанию нужно сработать всего один раз, примерно в нужное время. Таймер фокуса — задача сложнее: он должен оставаться видимо и непрерывно точным все 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 перестают быть ошибками корректности — это просто моменты, когда никто не задал вопрос, а ответ всё равно останется верным, когда его зададут в следующий раз.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Как устроен Subly: календарная математика за датами продления подписок

Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.

#android #engineering #subscriptions
MFKAPPS 4 мин чтения

Правильные действия в уведомлениях Android: отметить приём дозы без открытия приложения

Практическое руководство по кнопкам действий в уведомлениях Android — PendingIntent, ограничение activity-трамплина, goAsync() и безопасное обновление состояния Room.

#android #engineering #notifications
MFKAPPS 7 мин чтения

Надёжные напоминания на Android в 2026 году: WorkManager, точные будильники и новые правила батареи

Как в 2026 году выпускать напоминания на Android, которые действительно срабатывают — WorkManager против AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS и причуды производителей, которые всё ещё кусаются.

#android #engineering #mobile