May 6, 2021

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)