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

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

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

MFKAPPS 4 мин чтения

Если напоминание заставляет открывать приложение, чтобы его закрыть, вы добавили шаг, который никто не просил. Весь смысл уведомления в том, что оно само и есть интерфейс — пользователь реагирует на него и идёт дальше. OldSchool, напоминание о приёме лекарств, полностью на этом держится: кнопки на экране блокировки Принято, Отложить и Пропустить должны срабатывать мгновенно, обновлять нужные данные и никогда не запускать Activity, которую пользователь не просил показывать. Вот как это устроено — и два-три места, где Android усложняет задачу больше, чем нужно.

Проблема «просто открой приложение»

Наивная версия уведомления-напоминания — это одно касание, открывающее приложение на экране, где нужно нажать кнопку. Это два лишних действия для того, что должно быть одним. Это также означает холодный запуск Activity каждый раз — на телефоне среднего класса с десятком других приложений в памяти это заметная задержка между «я увидел напоминание» и «оно отмечено выполненным».

Решение — NotificationCompat.Action: кнопки, прикреплённые прямо к уведомлению, которые срабатывают, ни разу не выводя Activity на передний план.

val takenIntent = Intent(context, DoseActionReceiver::class.java).apply {
    action = ACTION_MARK_TAKEN
    putExtra(EXTRA_DOSE_ID, doseId)
}
val takenPendingIntent = PendingIntent.getBroadcast(
    context,
    doseId.hashCode(),
    takenIntent,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

NotificationCompat.Builder(context, CHANNEL_ID)
    .setContentTitle("Metformin — 500mg")
    .addAction(R.drawable.ic_check, "Taken", takenPendingIntent)
    .addAction(R.drawable.ic_snooze, "Snooze", snoozePendingIntent)
    .addAction(R.drawable.ic_skip, "Skip", skipPendingIntent)
    .build()

Три кнопки, три PendingIntent, каждый указывает на BroadcastReceiver, а не на Activity. Это различие и есть весь фокус.

Почему receiver, а не Activity

Соблазнительно направить действие через Activity — она у вас уже есть, она уже общается с базой данных. Не делайте этого. Начиная с Android 12, запуск Activity напрямую из действия уведомления (а не из основной области касания самого уведомления) ограничен как «activity-трамплин», и даже там, где это ещё работает, это означает нежелательную вспышку интерфейса, запись в переключателе задач и более медленный отклик. BroadcastReceiver ничего этого не делает — он выполняется, делает свою работу, и ничего видимого не происходит, кроме обновления уведомления.

FLAG_IMMUTABLE тоже важен: начиная с Android 12, изменяемые PendingIntent по умолчанию блокируются для всего, что явно не требует FLAG_MUTABLE (например, RemoteInput). Обычной кнопке действия изменяемость не нужна — отметьте её как immutable и двигайтесь дальше.

Реальная работа в receiver: goAsync()

Проблема с BroadcastReceiver.onReceive() в том, что от него ожидается возврат почти мгновенно — несколько миллисекунд, а не время, которое занимает запись в Room в фоновом потоке. Если вы вернётесь до завершения записи, система может убить процесс прямо посреди записи на некоторых оболочках производителей.

goAsync() корректно покупает это дополнительное время:

class DoseActionReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val doseId = intent.getLongExtra(EXTRA_DOSE_ID, -1)
        val pendingResult = goAsync()

        CoroutineScope(Dispatchers.IO).launch {
            try {
                doseDao.markTaken(doseId, timestamp = System.currentTimeMillis())
                NotificationManagerCompat.from(context).cancel(doseId.toInt())
            } finally {
                pendingResult.finish()
            }
        }
    }
}

goAsync() даёт вам примерно 10 секунд продлённого времени жизни — достаточно для записи в Room, но недостаточно, чтобы оправдать foreground-сервис. Всегда вызывайте pendingResult.finish() в блоке finally; забыть об этом на пути исключения означает, что система будет держать wakelock, который не может освободить, и в итоге пометит ваш receiver как ведущий себя некорректно.

Держать уведомление и базу данных в согласии

Отметить дозу как «принятую» — это не просто строка в базе данных: само уведомление должно исчезнуть или измениться, иначе пользователь нажмёт на него снова и удивится, почему ничего не происходит. Вызов NotificationManagerCompat.cancel() после записи — это минимум; для Отложить я перепланирую через тот же путь AlarmManager/WorkManager, который использовало исходное напоминание (см. статью о WorkManager и точных будильниках для планировочной половины этого), а не изобретаю второй конвейер уведомлений. Одно напоминание, один путь кода — независимо от того, сработало оно в первый раз или в третий.

Другая распространённая ошибка — обновлять состояние в памяти, за которым никто не наблюдает. Если представление дня опирается на Flow из Room, записи receiver достаточно — интерфейс перекомпонуется в следующий раз, когда станет видимым, без единой строчки явного кода синхронизации. Если нет, вы получите устаревшие данные в тот момент, когда пользователь вернётся в приложение, и в итоге будете отлаживать состояние гонки, которое на самом деле — просто отсутствующий наблюдатель.

Что это даёт

Измеримая разница на бумаге не выглядит впечатляюще — на один экран меньше, на одно касание меньше — но она меняет то, как ощущается напоминание. Уведомление, которое разрешается само за меньше чем секунду, прямо там, где появилось, воспринимается как уважение к тем двум секундам, которые вы ему дали. То, что требует холодного запуска приложения, воспринимается как трение — а трение превращает напоминание в то, что смахивают по привычке, вместо того, чем пользуются.

Если вы строите любое уведомление типа «отреагируй прямо сейчас» — напоминание, подталкивание к задаче, подтверждение доставки — паттерн работает независимо от предметной области: направляйте действия через BroadcastReceiver, продлевайте окно с помощью goAsync(), пишите через существующий слой данных и давайте реактивному интерфейсу подхватить изменение вместо того, чтобы проталкивать его вручную. Это немного «сантехники» ради функции, которую пользователи замечают только тогда, когда её нет.

// По теме

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

MFKAPPS 7 мин чтения

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

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

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

Как выстроить режим приёма лекарств, которого вы действительно будете придерживаться

Практическое руководство по соблюдению режима приёма лекарств — почему люди пропускают дозы, как на самом деле работает время напоминаний и как выстроить режим, который переживёт плохую неделю.

#reminders #ux #notifications
MFKAPPS 4 мин чтения

Привычка пить воду, которая не держится на силе воли

Большинство советов про питьевой режим сводятся к «пей больше воды» в надежде, что дальше сработает сила воли. Вот привычка, построенная на подсказках и мягких напоминаниях — вместо этого.

#mobile #ux #reminders