Хакер - Android: колбэки, корутины и бенчмарк отладочной сборки
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. Это поможет установить сразу две сборки приложения на устройство, но приведет к двум проблемам:
- Сервисы типа Firebase перестанут распознавать приложение.
- Оба приложения будут иметь одинаковое имя и иконку.
Обе проблемы решаются с помощью альтернативных ресурсов для отладочной сборки приложения. Например, чтобы поменять имя приложения, достаточно создать в каталоге 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