Хакер - Android: колбэки, корутины и бенчмарк отладочной сборки

Хакер - Android: колбэки, корутины и бенчмарк отладочной сборки

hacker_frei

https://t.me/hacker_frei

Евгений Зобнин

Се­год­ня в выпус­ке: раз­бира­емся с проб­лемами, воз­ника­ющи­ми при исполь­зовании корутин в Kotlin, прев­раща­ем кол­бэки в suspend-фун­кции, пишем при­ложе­ние с исполь­зовани­ем Kotlin Flow, а так­же раз­бира­емся, почему нель­зя про­водить тес­ты про­изво­дитель­нос­ти на отла­доч­ной сбор­ке, и учим­ся уста­нав­ливать на смар­тфон сра­зу отла­доч­ную и про­дак­шен‑вер­сии при­ложе­ния. Ну и как обыч­но, под­борка све­жих биб­лиотек.

РАЗРАБОТЧИКУ

Проблемы использования корутин в Kotlin

7 Gotchas When Explore Kotlin Coroutine — статья о проб­лемах, с которы­ми мож­но стол­кнуть­ся при исполь­зовании корутин в Kotlin. Вот наибо­лее инте­рес­ные тезисы.

1. runBlocking может под­весить при­ложе­ние

Рас­смот­рим сле­дующий код:

override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

setContentView(R.layout.activity_main)

runBlocking(Dispatchers.Main) {

Log.d("Track", "${Thread.currentThread()}")

Log.d("Track", "$coroutineContext")

}

}

Выг­лядит безобид­но, но он при­ведет к фри­зу при­ложе­ния. Почему так про­исхо­дит, под­робно опи­сано в статье How runBlocking May Surprise You. Если крат­ко, то проб­лема воз­ника­ет из‑за самого прин­ципа работы runBlocking. Он запус­кает новую корути­ну, а затем бло­киру­ет текущий поток. Но если запус­тить runBlocking с дис­петче­ром Main из основно­го потока, то порож­денная корути­на ока­жет­ся в том же потоке и в ито­ге не смо­жет получить управле­ние, так как текущий поток будет заб­локиро­ван.

По‑хороше­му эта проб­лема реша­ется отка­зом от исполь­зования runBlocking. Это инс­тру­мент для юнит‑тес­тов, а не для про­дак­шена. Но если очень хочет­ся, мож­но убрать имя дис­петче­ра из вызова фун­кции:

runBlocking {

Log.d("Track", "${Thread.currentThread()}")

Log.d("Track", "$coroutineContext")

}

2. Корути­ну нель­зя завер­шить в любой момент

Не­опыт­ные раз­работ­чики счи­тают, что если выз­вать метод cancel() корути­ны, то она будет завер­шена сра­зу. На самом деле это не так и в ряде слу­чаев корути­на может успеть пол­ностью отра­ботать, перед тем как обра­бота­ет сиг­нал завер­шения.

Про­исхо­дит так потому, что корути­ны реали­зуют модель коопе­ратив­ной мно­гоза­дач­ности. Ког­да одна корути­на посыла­ет сиг­нал завер­шения дру­гой корути­не, пос­ледняя может либо обра­ботать этот сиг­нал, либо про­игно­риро­вать его. Хорошая новость сос­тоит в том, что все стан­дар­тные suspend-фун­кции (yield()delay()withContext() и дру­гие) уме­ют самос­тоятель­но обра­баты­вать этот сиг­нал и завер­шать корути­ну. Пло­хая новость — бла­года­ря такой невиди­мой авто­мати­зации раз­работ­чики быва­ют удив­лены, что одни корути­ны в их коде завер­шают­ся поч­ти мгно­вен­но в ответ на cancel(), а дру­гие про­дол­жают работать.

Проб­лему реша­ем так: про­веря­ем зна­чение свой­ства isActive меж­ду вычис­лени­ями и завер­шаем корути­ну, если получи­ли зна­чение false.

3. Отме­нен­ный coroutine scope нель­зя исполь­зовать пов­торно

Взгля­ни на сле­дующий код:

@Test

fun testingLaunch() {

val scope = MainScope()

runBlocking {

scope.cancel()

scope.launch {

try {

println("Start Launch 2")

delay(200)

println("End Launch 2")

} catch (e: CancellationException) {

println("Cancellation Exception")

}

}.join()

println("Finished")

}

}

Дан­ный код не будет работать. Если выз­вать cancel() на coroutine scope, он ста­новит­ся неп­ригод­ным для даль­нейше­го исполь­зования. Выхода из этой ситу­ации два: соз­дать новый scope на мес­те ста­рого и завер­шать не сам scope, а все при­над­лежащие ему корути­ны:

resultsScope.coroutineContext.cancelChildren()

Используем колбэки в последовательном коде

Suspending over Views — хорошая статья о том, как прев­ратить кол­бэки в suspend-фун­кции с помощью suspendCancellableCoroutine().

В Android кол­бэки пов­сюду, и UI фрей­мворк не исклю­чение. Кол­бэки исполь­зуют­ся для все­го под­ряд:

  • AnimatorListener — что­бы запус­тить код по окон­чании ани­мации;
  • RecyclerView.OnScrollListener — что­бы выпол­нить код сра­зу пос­ле про­мот­ки RecyclerView;
  • View.OnLayoutChangeListener — что­бы узнать, ког­да View был перери­сован пос­ле изме­нения.

В целом кол­беки не очень удоб­ны и могут прев­ратить код в труд­нопере­вари­ваемую кашу (callback hell). Фун­кции‑рас­ширения из биб­лиоте­ки android-ktx час­тично реша­ют эту проб­лему: пре­обра­зуют кол­бэки на осно­ве клас­сов в лям­бды (doOnLayout() вмес­то OnLayoutChangeListener()), но всем нам хотелось бы писать код в пос­ледова­тель­ном сти­ле, как и пред­лага­ет язык Kotlin и suspend-фун­кции. Но мож­но ли это­го дос­тичь, имея дело с фрей­мвор­ком Android, написан­ным с помощью кол­бэков?

Мож­но, для это­го дос­таточ­но исполь­зовать бил­дер корутин suspendCancellableCoroutine(). Раз­берем его на при­мере сле­дующей фун­кции‑рас­ширения, при­оста­нав­лива­ющей исполне­ние корути­ны до сле­дующе­го обновле­ния View:

suspend fun View.awaitNextLayout() = suspendCancellableCoroutine<Unit> { cont ->

// Объявим стандартный листенер

val listener = object : View.OnLayoutChangeListener {

override fun onLayoutChange(...) {

// При срабатывании листенера удаляем его

view.removeOnLayoutChangeListener(this)

// И выводим корутину из спячки

cont.resume(Unit)

}

}

// Если корутина внезапно завершилась — удалим листенер

cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) }

// Подключаем листенер к View

addOnLayoutChangeListener(listener)

}

Эта фун­кция при­оста­нав­лива­ет исполне­ние корути­ны до тех пор, пока View не будет перери­сован пос­ле изме­нения. Исполь­зовать ее мож­но так:

viewLifecycleOwner.lifecycleScope.launch {

// Изменяем View и прячем

titleView.isInvisible = true

titleView.text = "Hi everyone!"

// Ждем, пока View будет перерисован

titleView.awaitNextLayout()

// View перерисован! Можно сделать его видимым и включить анимацию

titleView.isVisible = true

titleView.translationY = -titleView.height.toFloat()

titleView.animate().translationY(0f)

}

Ес­ли мы соз­дадим еще нес­коль­ко подоб­ных фун­кций (исходни­ки есть в ори­гиналь­ной статье), мы смо­жем писать пос­ледова­тель­ный код типа такого:

viewLifecycleOwner.lifecycleScope.launch {

imageView.animate().run {

alpha(0f)

start()

awaitEnd()

}

recyclerView.run {

smoothScrollToPosition(10)

awaitScrollEnd()

}

ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {

start()

awaitEnd()

}

}

Этот код запус­кает ани­мацию исчезно­вения изоб­ражения, дожида­ется ее окон­чания, затем про­маты­вает RecyclerView до позиции 10, дожида­ется окон­чания этой опе­рации и запус­кает дру­гую ани­мацию.

Почему нельзя использовать отладочную сборку для бенчмарков

Don’t Run Benchmarks on a Debuggable Android App (Like I Did for Coroutines) — неболь­шая замет­ка о том, почему отла­доч­ные сбор­ки всег­да нам­ного мед­леннее релиз­ных.

Не­кото­рое вре­мя назад автор написал статью, пос­вящен­ную корути­нам Kotlin, и дал совет не исполь­зовать корути­ны при ини­циали­зации при­ложе­ния, потому что на запуск пер­вой корути­ны ухо­дит при­мер­но 110 мил­лисекунд. Поз­же в ком­мента­рии приш­ли дру­гие раз­работ­чики и посове­това­ли запус­тить тест про­изво­дитель­нос­ти на релиз­ной сбор­ке вмес­то отла­доч­ной. В резуль­тате тест показал 9 мил­лисекунд при вклю­чен­ной минифи­кации (ProGuard) и 14 при отклю­чен­ной.

Так про­исхо­дит потому, что сре­да раз­работ­ки вклю­чает для отла­доч­ных сбо­рок флаг debuggable, который поз­воля­ет под­клю­чать к при­ложе­нию отладчик. Это, в свою оче­редь, вле­чет за собой отклю­чение опре­делен­ных опти­миза­ций кода, вклю­чение пос­тоян­ной сбор­ки метадан­ных и мно­гие дру­гие вещи. Обыч­но это при­водит к замед­лению при­ложе­ния при­мер­но на 0–80%, но в слу­чае с корути­нами замед­ление может дос­тигать 650%.

Вы­вод: ни­ког­да не изме­ряй про­изво­дитель­ность, исполь­зуя отла­доч­ную сбор­ку при­ложе­ния.

Как установить отладочную и обычную версии приложения одновременно

Android — Keeping Release and Debug Installed All the Time — статья о том, как упростить себе жизнь, уста­новив сра­зу две вер­сии при­ложе­ния на одно устрой­ство.

Для начала нуж­но изме­нить имя пакета при­ложе­ния. В инс­трук­циях сбор­ки имя пакета зада­ется с помощью дирек­тивы applicationId:

defaultConfig {

applicationId "com.example.app"

minSdkVersion 26

targetSdkVersion 30

versionCode 10203

versionName "1.2.3"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

}

В то же вре­мя имя пакета мож­но перепи­сать для раз­ных вари­антов сбор­ки с помощью дирек­тивы applicationIdSuffix:

buildTypes {

release {

minifyEnabled false

proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'

}

debug {

applicationIdSuffix ".debug"

}

}

В ито­ге отла­доч­ная сбор­ка при­ложе­ния получит имя com.example.app.debug. Это поможет уста­новить сра­зу две сбор­ки при­ложе­ния на устрой­ство, но при­ведет к двум проб­лемам:

  1. Сер­висы типа Firebase перес­танут рас­позна­вать при­ложе­ние.
  2. Оба при­ложе­ния будут иметь оди­нако­вое имя и икон­ку.

Обе проб­лемы реша­ются с помощью аль­тер­натив­ных ресур­сов для отла­доч­ной сбор­ки при­ложе­ния. Нап­ример, что­бы поменять имя при­ложе­ния, дос­таточ­но соз­дать в катало­ге app/src про­екта (рядом с катало­гом main) каталог debug/res/values/, а в нем файл strings.xml с таким содер­жимым:

<string name="app_name" translatable="false">Имя дебажной сборки</string>

При­мер­но таким же обра­зом мож­но поменять икон­ку и под­клю­чить при­ложе­ние к дру­гому про­екту Firebase.

Современный подход к разработке с использованием Kotlin

How to Show Download Progress, Kotlin-Style — неболь­шая статья с хорошей иллюс­тра­цией сов­ремен­ных под­ходов к раз­работ­ке на при­мере диало­га заг­рузки фай­ла.

Ког­да‑то, на заре ста­нов­ления Android как самой популяр­ной плат­формы, по сети гуляло мно­жес­тво при­меров реали­зации подоб­ной фун­кци­ональ­нос­ти. Обыч­но все сво­дилось к исполь­зованию AsyncTask и runOnUiThread(), а сам код выг­лядел как весь­ма далекая от пат­терна MVC мешани­на клас­сов и методов.

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

Итак, для начала соз­дадим sealed-класс для управле­ния сос­тоянием заг­рузки:

sealed class DownloadStatus {

object Success : DownloadStatus()

data class Error(val message: String) : DownloadStatus()

data class Progress(val progress: Int): DownloadStatus()

}

Sealed-клас­сы отли­чают­ся тем, что могут иметь толь­ко огра­ничен­ный и заранее опре­делен­ный набор потом­ков. Нам нуж­но все­го три сос­тояния: заг­рузка завер­шена, ошиб­ка заг­рузки, текущий прог­ресс заг­рузки.

Те­перь реали­зуем саму фун­кцию заг­рузки:

suspend fun HttpClient.downloadFile(file: File, url: String): Flow<DownloadStatus> {

return flow {

val response = call {

url(url)

method = HttpMethod.Get

}.response

val byteArray = ByteArray(response.contentLength()!!.toInt())

var offset = 0

do {

val currentRead = response.content.readAvailable(byteArray, offset, byteArray.size)

offset += currentRead

val progress = (offset * 100f / byteArray.size).roundToInt()

emit(DownloadStatus.Progress(progress))

} while (currentRead > 0)

response.close()

if (response.status.isSuccess()) {

file.writeBytes(byteArray)

emit(DownloadStatus.Success)

} else {

emit(DownloadStatus.Error("File not downloaded"))

}

}

}

Это фун­кция‑рас­ширение для клас­са HttpClient Kotlin-биб­лиоте­ки Ktor. Как рас­ширение, она может исполь­зовать методы клас­са HttpClient нап­рямую, к тому же нам нет необ­ходимос­ти соз­давать спе­циаль­ный ути­лит­ный класс для нее, в осталь­ном коде она будет выг­лядеть как метод клас­са HttpClient.

Кро­ме того что это фун­кция‑рас­ширение, это еще и suspend-фун­кция, которая воз­вра­щает Flow (поток дан­ных), содер­жащий текущее сос­тояние заг­рузки. Это зна­чит, что код в теле фун­кции будет выпол­нен не в момент вызова фун­кции, а в момент получе­ния дан­ных из самого Flow (с помощью метода collect()). Это поз­волит сде­лать код более струк­туриро­ван­ным и понят­ным.

На­конец, напишем фун­кцию, которая будет запус­кать заг­рузку в фоновом потоке и динами­чес­ки обновлять интерфейс при­ложе­ния:

private fun downloadWithFlow() {

CoroutineScope(Dispatchers.IO).launch {

ktor.downloadFile(file, url).collect {

withContext(Dispatchers.Main) {

when (it) {

is DownloadStatus.Success -> {

// Обновляем UI

}

is DownloadStatus.Error -> {

// Обновляем UI

}

is DownloadStatus.Progress -> {

// Обновляем UI

}

}

}

}

}

}

Вся логика в одной неболь­шой фун­кции. Сна­чала запус­каем фоновую корути­ну (CoroutineScope(Dispatchers.IO).launch), затем запус­каем заг­рузку фай­ла и обновля­ем UI в соот­ветс­твии с получен­ным из Flow сос­тоянием.

Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei

Report Page