Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения
Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.
Самая быстрая версия любого действия в приложении — та, что вообще не открывает приложение. Основной цикл Hydrame — залогировать стакан воды — происходит десятки раз в день, и просить кого-то разблокировать телефон, найти иконку, дождаться холодного старта, а затем нажать кнопку — это на три шага больше, чем нужно для действия, которое должно быть одним. Android даёт два способа свернуть это до одного тапа: ярлыки приложений на главном экране и плитку быстрых настроек. Они решают одну и ту же задачу с разных точек входа, и реализовать их обе проще, чем кажется на первый взгляд. Вот как каждая работает на самом деле.
Две точки входа, две разные задачи
Ярлыки приложений живут под иконкой лаунчера — долгое нажатие открывает меню с действиями, которые вы определили. Они предназначены для небольшого набора часто используемых действий, привязанных к конкретно этому приложению, и их видит любой, у кого иконка уже перед глазами.
Плитка быстрых настроек живёт в шторке уведомлений, рядом с Wi-Fi и Bluetooth. Она нужна для одного действия, которое должно быть доступно откуда угодно, без поиска иконки вообще — это ближе к аппаратной кнопке, чем к функции приложения.
Hydrame поставляется с обоими вариантами: ярлык для «Записать 250мл» и «Записать 500мл» под иконкой лаунчера, и плитка быстрых настроек, которая записывает стандартный объём одним тапом прямо из шторки на экране блокировки. Это разные API, разные пути регистрации, и их стоит реализовывать раздельно, а не пытаться объединить.
Динамические ярлыки: ShortcutManagerCompat
Ярлыки бывают двух видов — статические (объявлены в XML, фиксированы на этапе сборки) и динамические (отправляются во время выполнения, могут меняться в зависимости от состояния приложения). Фиксированное «Записать 250мл» не нуждается в изменениях, так что статический вариант тоже подошёл бы, но динамические ярлыки позволяют объёмам отражать то, что человек действительно пьёт — два размера, которые он использует чаще всего, а не два, которые угадало приложение.
val shortcut = ShortcutInfoCompat.Builder(context, "log_250ml")
.setShortLabel("250ml")
.setLongLabel("Log 250ml of water")
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_glass))
.setIntent(
Intent(context, LogIntakeReceiverActivity::class.java).apply {
action = ACTION_LOG_INTAKE
putExtra(EXTRA_AMOUNT_ML, 250)
}
)
.build()
ShortcutManagerCompat.setDynamicShortcuts(context, listOf(shortcut250, shortcut500))
Intent обязан указывать на Activity, а не на BroadcastReceiver — это единственное жёсткое ограничение, которое навязывает API ярлыков, в отличие от паттерна с действиями уведомлений, где правильным выбором как раз является receiver. Собственная рекомендация Google — сделать эту Activity максимально невидимой: без layout, с немедленным finish() сразу после записи.
class LogIntakeReceiverActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val amount = intent.getIntExtra(EXTRA_AMOUNT_ML, 0)
lifecycleScope.launch {
intakeDao.logIntake(amount, timestamp = System.currentTimeMillis())
Toast.makeText(this@LogIntakeReceiverActivity, "Logged ${amount}ml", Toast.LENGTH_SHORT).show()
finish()
}
}
}
Задайте ей прозрачную тему (Theme.Material3.DayNight.NoActionBar с переопределённым windowIsTranslucent) — и переход будет восприниматься как системное подтверждение, а не запуск приложения; всё целиком укладывается меньше чем в полсекунды на телефоне среднего уровня.
Ранжирование и лимит: ярлыки — это не меню
ShortcutManagerCompat.setDynamicShortcuts() заменяет весь набор целиком при каждом вызове, а система ограничивает количество — getMaxShortcutCountPerActivity() — обычно около четырёх-пяти, в зависимости от лаунчера. Отправьте больше — и лишние будут молча отброшены, а не поставлены в очередь. Порядок тоже важен: ярлыки отображаются в том порядке, в каком вы их передали, так что объём, который человек логирует чаще всего, должен идти первым, а не последним.
Ещё одна ловушка — слишком частые вызовы setDynamicShortcuts(). Каждый вызов рассчитан на реальные изменения состояния — привычки пользователя меняются за недели, а не при каждом запуске приложения. Перезапись одних и тех же двух ярлыков при каждом холодном старте ничего не даёт, кроме лишнего системного вызова; проверяйте, действительно ли набор изменился, прежде чем отправлять его.
Плитка быстрых настроек: TileService
Плитка — это service, а не Activity — жизненным циклом управляет Android, и у вас есть ровно одна точка входа, которая имеет значение: onClick().
class LogWaterTileService : TileService() {
override fun onStartListening() {
super.onStartListening()
qsTile?.apply {
label = "Log water"
icon = Icon.createWithResource(this@LogWaterTileService, R.drawable.ic_tile_glass)
state = Tile.STATE_ACTIVE
updateTile()
}
}
override fun onClick() {
super.onClick()
val pendingResult = goAsync()
lifecycleScope.launch {
try {
intakeDao.logIntake(defaultAmountMl(), timestamp = System.currentTimeMillis())
} finally {
pendingResult.finish()
}
}
}
}
goAsync() появляется здесь по той же причине, что и в BroadcastReceiver: ожидается, что onClick() вернётся быстро, а запись в Room на главном потоке дёрнет анимацию сворачивания шторки. Плитка должна быть объявлена в манифесте с разрешением BIND_QUICK_SETTINGS_TILE, и — вот что застаёт врасплох — она не появится нигде автоматически. Пользователь должен сам перетащить её в активные плитки на экране редактирования шторки. TileService.requestListeningState() может подсказать Android 13+ предложить добавление, но принудительно разместить плитку невозможно, и нет оснований рассчитывать, что большинство пользователей вообще станут этим заниматься. Относитесь к плитке как к функции для продвинутых пользователей, а не как к основному пути взаимодействия.
Поддержание честного состояния плитки
Плитка, которая всегда показывает одну и ту же иконку независимо от состояния приложения, будет выглядеть сломанной в первый же раз, когда это окажется не так. Если дневная цель Hydrame уже достигнута, onStartListening() — подходящее место, чтобы это отразить:
override fun onStartListening() {
super.onStartListening()
lifecycleScope.launch {
val goalMet = intakeDao.todayTotal() >= goalDao.currentGoal()
qsTile?.apply {
state = if (goalMet) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE
subtitle = if (goalMet) "Goal reached" else null
updateTile()
}
}
}
onStartListening() срабатывает каждый раз, когда шторка с вашей плиткой становится видимой, а не по таймеру, так что это чтение дешёвое и актуальное по самой своей конструкции — без опроса, без фоновой задачи для синхронизации.
Ради чего стоит это строить
Ни один из этих API не сложен, если знать два правила, которые неочевидны из документации: ярлыкам нужна Activity даже для фоновой записи, а плитке нужен goAsync() по той же причине, что и приёмнику действия уведомления. То, что они дают взамен, несоразмерно объёму кода — долгое нажатие или тап по шторке заменяют полноценный запуск приложения для той горстки действий, которые человек повторяет каждый день. Это тот вид трения, отсутствие которого пользователи сознательно не замечают — только его возвращение, если убрать. Для любого приложения с одним-двумя доминирующими действиями — что-то залогировать, что-то запустить, что-то переключить — это стоит потраченного вечера.
// По теме
Ещё из журнала
In-App Review API в Android: как просить оценку, не раздражая пользователя
Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.
Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции
Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.
StateFlow против SharedFlow в Jetpack Compose: моделируем UI-состояние без бага повторного воспроизведения
Когда использовать StateFlow, а когда SharedFlow в Compose ViewModel — и баг одноразового события, который возникает, если их перепутать.