Android: советы и лайфхаки
Сегодня в выпуске: правильный способ сбора Flow из UI-компонентов, советы, как сделать код на Kotlin чище, десять полезных лайфхаков и сниппетов кода на все случаи жизни. А также объяснение сути PendingIntent и подборка библиотек для разработчиков.
РАЗРАБОТЧИКУ
Правильный способ сбора Flow из UI-компонентов
A safer way to collect flows from Android UIs — статья о том, как написать с использованием Kotlin Flow асинхронный код, который не будет страдать от проблем перерасхода ресурсов.
Современный подход к написанию приложений для Android выглядит примерно так: слой бизнес‑логики выставляет наружу suspend-функции и продюсеры Flow, а UI-компоненты вызывают suspend-функции или подписываются на Flow и обновляют UI в соответствии с пришедшими данными.
Выглядеть это все может примерно так. Функция — продюсер обновлений местоположения:
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
try { offer(result.lastLocation) } catch(e: Exception) {}
}
}
requestLocationUpdates(createLocationRequest(), callback, Looper.getMainLooper())
.addOnFailureListener { e ->
close(e) // In case of exception, close the Flow
}
awaitClose {
removeLocationUpdates(callback)
}
}
Часть UI-компонента, подписывающаяся на Flow:
lifecycleScope.launchWhenStarted {
locationProvider.locationFlow().collect {
// Новое местоположение — обновляем UI
}
}
На первый взгляд — все хорошо. Благодаря использованию lifecycleScope
мы научили код реагировать на изменение жизненного цикла приложения — при уходе приложения в фон обработка значений местоположения будет приостановлена. Но! Продюсер Flow продолжит отправлять данные об обновлении местоположения.
Чтобы избежать такой проблемы, можно либо самостоятельно запускать и останавливать корутину — обработчик Flow при изменении жизненного цикла приложения, либо использовать lifecycleOwner.addRepeatingJob
из библиотеки lifecycle-runtime-ktx версии 2.4.0-alpha01.
lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// Новое местоположение — обновляем UI
}
}
Выглядит почти так же, как предыдущий пример. Однако в данном случае корутина будет полностью остановлена при переходе приложения в любое состояние, отличное от Lifecycle.State.STARTED
, и запущена снова при переходе в это состояние. Вместе с ней будет остановлен и продюсер данных о местоположении.
Того же эффекта можно добиться, используя suspend-функцию repeatOnLifecycle
:
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
locationProvider.locationFlow().collect {
// Новое местоположение — обновляем UI
}
}
}
Она удобна в тех случаях, когда перед сбором данных необходимо выполнить определенную работу внутри suspend-функции.
Внутри эта функция использует оператор Flow.flowWithLifecycle
, который можно применять напрямую:
locationProvider.locationFlow()
.flowWithLifecycle(this, Lifecycle.State.STARTED)
.onEach {
// Новое местоположение — обновляем UI
}
.launchIn(lifecycleScope)
По сути, все эти API — это более современная и гибкая замена LiveData.
Как сделать код чище
Noisy Code With Kotlin Scopes — хорошая статья о том, как сделать код на Kotlin чище, понятнее и однозначнее.
Проблема номер 1: let. Многие программисты привыкли использовать let
в качестве простой и удобной альтернативы if (x == null)
:
fun deleteImage(){
var imageFile : File ? = ...
imageFile?.let {
if(it.exists()) it.delete()
}
}
Так делать не стоит. Использование it
— дурной тон, потому что множественные it
могут смешаться, если в коде появится еще одна подобная лямбда. Ты можешь попробовать исправить это с помощью конструкции imageFile?.let { image ->
, но в итоге сделаешь еще хуже, потому что в той же области видимости появится еще одна переменная, которая ссылается на то же значение, но имеет другое имя. И это имя надо будет придумать!
На самом деле в большинстве случаев исправить эту проблему можно простым отказом от let
:
fun deleteImage(){
val imageFile : File ? = ...
if(imageFile != null) {
if(imageFile.exists()) imageFile.delete()
}
}
С этим кодом все в порядке. Умное приведение типов сделает свою работу, и ты сможешь ссылаться на imageFile
после проверки как на не nullable-переменную.
Но! Такой прием не сработает, если речь идет не о локальной переменной, а о полях класса. Из‑за того что поля класса могут изменяться несколькими методами, работающими в разных потоках, компилятор не сможет использовать смарткастинг.
Как раз здесь и можно использовать let
.
Но есть и более необычные способы. Например, выходить из функции, если значение поля null:
fun deleteImage() {
val imageFile = getImage() ?: return
...
}
Или использовать метод takeIf
:
fun deleteImage() {
getImage()?.takeIf { it.exists }?.let {it.delete()}
}
Можно даже скомбинировать оба подхода:
fun deleteImage() {
val image = getImage()?.takeIf { it.exists } ?: return
image.delete()
}
Проблема номер 2: also и apply. Синтаксис Kotlin поощряет использование лямбд везде, где только возможно. Иногда это приводит к созданию весьма монструозных конструкций:
Intent(context, MyActivity::class.java).apply {
putExtra("data", 123)
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}).also { intent ->
startActivity(this@FooActivity, intent)
}
Смысл такого кода в том, чтобы ограничить создание интента и его использование двумя различными областями видимости, что в теории должно благотворно повлиять на его модульность.
На самом деле такие конструкции только захламляют код. Гораздо красивее выглядит его более прямолинейная версия:
val intent = Intent(context, MyActivity::class.java).apply {
putExtra("data", 123)
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
})
startActivity(this@FooActivity,intent)
А еще лучше вынести код конфигурирования объекта в отдельную функцию:
fun startSomeActivity() {
startActivity(getSomeIntent())
}
fun getSomeIntent() = Intent(context, SomeActivity::class.java).apply {
// ...
}
Проблема номер 3: run. Распространенный прием — использовать функцию run
для обрамления блоков кода:
val userName: String
get() {
preferenceManager.getInstance()?.run {
getName()
} ?: run {
getString(R.string.stranger)
}
}
Это абсолютно бессмысленное захламление кода. Оно затрудняет чтение простого по своей сути кода:
val userName: String
get() = preferenceManager.getInstance()?.getName() ?: getString(R.string.stranger)
Если же кода в блоке «если null» больше одной строчки, то можно использовать такую конструкцию:
preferenceManager.getInstance()?.getName().orDefault {
Log.w(“Name not found, returning default”)
getString(R.string.stranger)
}
Проблема номер 4: with. В данном случае проблема не в самой функции with
, а в ее игнорировании. Разработчики просто не используют эту функцию, несмотря на всю ее красоту:
val binding = MainLayoutBinding.inflate(layoutInflater)
with(binding) {
textName.text = "ch8n"
textTwitter.text = "twitter@ch8n2"
})
Причин не использовать ее обычно две:
- ее нельзя использовать в цепочках вызовов функций;
- она плохо дружит с nullable-переменными.
Что такое PendingIntent
All About PendingIntents — статья, объясняющая, что такое PendingIntent и зачем он нужен. Будет полезна в связи с изменениями в обработке PendingIntent в Android 12.
Коротко говоря, PendingIntent — это объект — обертка для Intent, который позволяет передать Intent другому приложению с целью его выполнения в будущем от имени создавшего Intent приложения. PendingIntent, в частности, используется, чтобы указать системе, что нужно сделать при нажатии на уведомление. В этом случае система запускает интент, указанный в PendingIntent, так, как будто бы его запустило создавшее уведомление приложение.
Простейший пример:
val intent = Intent(applicationContext, MainActivity::class.java).apply {
action = NOTIFICATION_ACTION
data = deepLink
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
NOTIFICATION_REQUEST_CODE,
intent,
PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(
applicationContext,
NOTIFICATION_CHANNEL
).apply {
// ...
setContentIntent(pendingIntent)
// ...
}.build()
notificationManager.notify(
NOTIFICATION_TAG,
NOTIFICATION_ID,
notification
)
Сначала мы создаем Intent, а затем заворачиваем его в PendingIntent. С помощью флага PendingIntent.FLAG_IMMUTABLE
мы указываем, что не хотим, чтобы система или кто‑либо еще, получивший доступ к этому PendingIntent, мог его изменить. Такой флаг (или флаг PendingIntent.FLAG_MUTABLE
) обязательно указывать в Android 12.
При обновлении уведомления мы должны будем указать дополнительный флаг, чтобы заменить старый PendingIntent новым:
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
Зачем же может понадобиться изменение интента внутри PendingIntent? Например, для создания системы плагинов, когда плагин передает приложению‑хосту PendingIntent и тот вносит в него изменения в соответствии с вводом пользователя.
Для изменения PendingIntent используется достаточно странный способ, когда методу send
передается еще один интент, атрибуты которого становятся частью атрибутов оригинального интента:
val intentWithExtrasToFill = Intent().apply {
putExtra(EXTRA_CUSTOMER_MESSAGE, customerMessage)
}
mutablePendingIntent.send(
applicationContext,
PENDING_INTENT_CODE,
intentWithExtrasToFill
)
Десять полезных лайфхаков
Ten #AndroidLifeHacks You Can Use Today — сборник маленьких полезных функций для Android-разработчиков. Наиболее интересные:
- Функция fadeTo. Вместо
View.setVisibility()
лучше применять функцию, которая не просто мгновенно скроет view с экрана, а сделает это с анимацией. Функция fadeTo позволяет сделать это идемпотентно (можно вызывать сколько угодно раз подряд, не сбрасывая анимацию), с указанием продолжительности и конечной прозрачности. - Функция mapDistinct. Простейшая функция — расширение Flow, которая сначала вызывает
map
, а затемdistinctUntilChanged()
(пропускать одинаковые значения). Код функции представляет собой одну строчку: fun <T, V> Flow<T>.mapDistinct(mapper: suspend (T) -> V): Flow<V> = map(mapper).distinctUntilChanged()
- Упрощенный Delegates.observable. Функция‑делегат
observable
может быть очень удобной, когда необходимо следить за изменениями значения переменной. Но чаще требуется улавливать не все изменения, а только новые, изменившиеся. В этом случае подойдет функция uniqueObservable. - Перезапускаемая Job. При разработке приложений на Kotlin часто требуется завершить предыдущую корутину перед запуском следующей (например, при выполнении поиска или обновлении экрана). Упростить эту процедуру можно с помощью ConflatedJob, которая при запуске завершает предыдущую корутину.
- Более удобный Timber. Многие разработчики используют Timber для логирования. Однако делать это напрямую не совсем удобно, приходится писать длинные строки вроде
Timber.tag(TAG).i(…)
. Чтобы облегчить себе жизнь, можно применить такой делегат для более удобной работы с Timber: class MyClass {
// This will automatically have the TAG "MyClass"
private val log by timber()
fun logSomething() {
log.i("Hello")
log.w(Exception(), "World")
}
}
- Number.dp. При разработке часто требуется преобразовать единицы DP (Density-independent Pixels) в пиксели. Для этого можно использовать такую функцию‑расширение:
val Number.dp get() = toFloat() * (Resources.getSystem().displayMetrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)