Предположим, что вы уже какое-то время работаете с Kotlin-корутинами и знакомы с базовыми концепциями, такими как приостанавливаемые (suspend
) функции и билдер launch
. Однако по мере усложнения проектов вы всё чаще сталкиваетесь с необходимостью искать решения для более продвинутых задач и обращаетесь к поисковым системам или искусственному интеллекту за помощью.
Эта шпаргалка создана как удобный справочник для сложных сценариев работы с корутинами и содержит ключевые идеи, накопленные мной за всё время работы.
Словарь корутин
Контекст корутины (из документации Kotlin): «Набор различных элементов. Основными элементами являются Job корутины и её диспетчер».
Job (из документации Kotlin): «Отменяемая сущность с жизненным циклом, который завершается по завершении корутины. Каждая корутина создаёт свой собственный Job (это единственный элемент контекста корутины, который не наследуется от родительской корутины).
Dispatcher: Позволяет выбрать, на каком потоке (или пуле потоков) должна выполняться корутина (как на старте, так и при возобновлении).
Coroutine scope (область видимости корутины): Определяет время жизни и контекст корутины. Она отвечает за управление жизненным циклом корутин, включая их отмену и обработку ошибок.
Coroutine builder (билдеры корутин): это функции-расширения для CoroutineScope
, которые позволяют запускать асинхронные корутины (например, launch
, async
и другие).
Основные правила работы с корутинами
Для запуска корутины необходим
CoroutineScope
(например, с помощьюlaunch
илиasync
). Чаще всего в Android используетсяviewModelScope
, но можно создать и свою область видимости.Дочерняя корутина (корутина, запущенная из другой корутины) наследует контекст корутины-родителя (кроме
Job
).Job корутины-родителя используется в качестве родительского
Job
для новой корутины. Это создаёт иерархию задач.Корутина-родитель приостанавливается до тех пор, пока все её дочерние корутины не завершатся.
Если корутина-родитель отменяется, то отменяются и все её дочерние корутины.
Если дочерняя корутина завершилась с необработанным исключением, это исключение приведёт к отмене корутины-родителя (если не используется
SupervisorJob
, см. ниже).Не следует использовать
GlobalScope
, так как это может привести к утечкам памяти и оставит корутину «живой», даже если активность или фрагмент, из которых она была запущена, уже завершили своё выполнение.Не передавайте область видимости корутины в качестве аргумента — вместо этого используйте функцию
coroutineScope
(пример ниже).
Функции для работы с областью видимости корутин:
coroutineScope
: приостанавливаемая функция, которая запускает область видимости и возвращает значение, произведённое переданной функцией.supervisorScope
: похожа наcoroutineScope
, но переопределяет Job контекста, поэтому функция не будет отменена, если дочерняя корутина выбросит исключение.withContext
: похожа наcoroutineScope
, но позволяет вносить некоторые изменения в область видимости (обычно используется для того, чтобы задатьDispatcher
).withTimeout
: похожа наcoroutineScope
, но устанавливает ограничение по времени на выполнение тела, и если оно превышено, функция будет отменена. ВыбрасываетTimeoutCancellationException
.withTimeoutOrNull
: аналогичнаwithTimeout
, но вернёт null вместо выброса исключения при превышении времени.
Диспетчеры (Dispatchers)
Запуск корутины в конкретном диспетчере требует ресурсов. При вызове withContext
корутина приостанавливается и может ожидать в очереди перед возобновлением (см. ниже, как избежать ненужных повторных запусков).
Dispatchers.Default
Используется по умолчанию, если диспетчер не задан.
Предназначен для выполнения операций, требующих высокой нагрузки на процессор (CPU).
Размер пула потоков соответствует количеству ядер на устройстве.
Можно ограничить количество потоков, доступных операции, с помощью
Dispatchers.Default.limitedParallelism(3)
.
Dispatchers.Main
Для Android запускает корутины в основном потоке (UI thread).
Важно избегать блокировки этого потока.
Не существует в юнит-тестах (при необходимости можно создать собственный Main-диспетчер).
Dispatchers.IO
Предназначен для выполнения блокирующих операций (ввода-вывода, чтение/запись файлов, доступ к Shared Preferences и т.д.).
Размер пула потоков составляет 64 (или соответствует числу ядер, если их больше 64).
Использует тот же пул потоков, что и
Dispatchers.Default
, но с независимыми ограничениями.Применяется для функций, выполняющих блокирующие операции.
Для запуска корутины с этим диспетчером:
withContext(Dispatchers.IO) { // блокирующая функция }
.Можно ограничить количество потоков для операции с помощью
Dispatchers.Default.limitedParallelism(3)
.Использование
limitedParallelism
сDispatchers.IO
имеет особенность: создаётся новый диспетчер с независимым пулом потоков (лимит может превышать 64).
Dispatchers.Unconfined
Корутина выполняется в том же потоке, в котором была запущена, без смены потоков.
Полезен для юнит-тестов.
По производительности это самый «лёгкий» диспетчер, так как не происходит переключения потоков.
Опасен для использования в продакшене, так как случайное выполнение блокирующего вызова в основном потоке может вызвать проблемы.
Наблюдения по производительности
Во время приостановки количество используемых потоков не имеет значительного значения.
При блокировке выполнения, чем больше потоков задействовано, тем быстрее завершатся все корутины.
При выполнении задач, требующих высокой нагрузки на процессор (CPU), оптимальным будет использование
Dispatchers.Default
.Для задач, требующих большой памяти, увеличение числа потоков может незначительно улучшить производительность.
Чтобы лучше понять, как работают конкурентность, параллелизм и порядок отмены, посмотрите отличное видео Дейва Лидса:
Запуск вызовов параллельно
Когда необходимо выполнить два действия одновременно и дождаться результата обоих перед тем, как вернуть итог:
Если у вас есть доступ к области (например, из ViewModel)
suspend fun getConfigFromAPI(): UserConfig {
// do API call here or any suspend functions
}
suspend fun getSongsFromAPI(): List<Song> {
// do API call here or any suspend functions
}
fun getConfigAndSongs() {
// scope can be any scope you'd want a typical case would be viewModelScope
scope.launch {
val userConfig = async { getConfigFromAPI() }
val songs = async { getSongsFromAPI()}
return Pair(userConfig.await(), songs.await())
}
}
Предположим, у вас есть API с пагинацией, и вы хотите скачать все страницы до того, как показать их пользователю. При этом вы хотите загрузить все страницы параллельно:
suspend fun getSongsFromAPI(page: Int): List<Song> {
// do API call
}
const val totalNumberOfPages = 10
fun getAllSongs() {
// scope can be any scope you'd want a typical case would be viewModelScope
scope.launch {
val allNews = (0 until totalNumberOfPages)
.map { page -> async { getSongsFromAPI(page) } }
.awaitAll()
}
}
Примечание по async
/ await
. Корутина начнёт выполнение сразу после вызова. async
возвращает объект типа Deferred<T>
(в нашем случае Deferred<List<Song>>
). Deferred
имеет приостанавливаемую функцию await
, которая возвращает значение, когда оно готово.
Если у вас нет доступа к области (например, из репозитория)
В вашем репозитории или случае использования (use case) необходимо определить корутину, которая запустит два (или более) вызова параллельно. Проблема в том, что для использования async
вам нужна область, но вы находитесь вне ViewModel или Presenter и не имеете здесь доступа к области (не забывайте о том, что передавать область в качестве аргумента — не лучшее решение).
Пример, основанный на нашем сценарии выше:
suspend fun getConfigAndSongs(): Pair<UserConfig, List<Song> = coroutineScope {
val userConfig = async { getConfigFromAPI() }
val songs = async { getSongsFromAPI()}
Pair(userConfig.await(), songs.await())
}
Очистка при отмене корутины
Если корутина отменяется, она сначала переходит в состояние cancelling, прежде чем станет cancelled. В состоянии cancelling у нас есть время для выполнения необходимых действий по очистке, если это требуется (например, очистка локальной базы данных, поскольку операция не завершилась успешно, или выполнение API-запроса, чтобы уведомить сервер о неудаче операции).
Для этого можно использовать блок finally
.
viewModelScope.launch {
try {
// call some suspend function here
} finally {
// execute clean up operation here
}
}
Однако при очистке нельзя использовать функции приостановки. Если вам нужно выполнить функцию приостановки, следует сделать следующее
viewModelScope.launch {
try {
// call some suspend function here
} finally {
withContext(NonCancellable) {
// execute clean up suspend function here
}
}
}
Примечание: Отмена произойдёт в первую точку приостановки. Следовательно, отмена не произойдет, если в функции нет точек приостановки.
Очистка корутины при её завершении
Аналогично очистке при отмене корутины вы можете захотеть выполнить операцию, когда корутина достигает конечного состояния (completed
или cancelled
).
suspend fun myFunction() = coroutineScope {
val job = launch { /* suspend call here */ }
job.invokeOnCompletion { exception: Throwable ->
// do something here
}
}
Как избежать отмены корутины, если один из её дочерних элементов завершился с ошибкой
Вы можете использовать SupervisorJob
, и он будет игнорировать все исключения в своих дочерних процессах.
Создание собственной области видимости корутины
val scope = CoroutineScope(SupervisorJob())
// if one throw an error the other coroutine will not be cancelled
scope.launch { myFirstCoroutine() }
scope.launch { mySecondCoroutine() }
Использование функции области видимости
suspend fun myFunction() = supervisorScope {
// if one throw an error the other coroutine will not be cancelled
launch { myFirstCoroutine() }
launch { mySecondCoroutine() }
}
Обработка исключений
suspend fun myFunction() {
try {
coroutineScope {
launch { myFirstCoroutine() }
}
} catch (e: Exception) {
// handle error here
}
try {
coroutineScope {
launch { mySecondCoroutine() }
}
} catch (e: Exception) {
// handle error here
}
}
CancellationException
не распространяется на своего родителя, отменяется только текущая корутина. Можно расширить CancellationException
, чтобы создать свой собственный тип исключения, который не будет распространяться на родителя.
Определите поведение по умолчанию в случае возникновения исключения
Используйте CoroutineExceptionHandler
Может быть использован, чтобы автоматически выйти из системы, когда сервер отвечает, например, с кодом 401
.
val handler = CoroutineExceptionHandler { context, exception ->
// define default behaviour like showing a dialog or error message
}
val scope = CoroutineScope(SupervisorJob() + handler)
scope. launch { /* suspend call here */ }
scope. launch { /* suspend call here */ }
Запуск вспомогательной операции
Если вы хотите запустить функцию suspend, которая не должна влиять на другие (например, если она выбросит ошибку, то только она не отменит родительскую корутину, а другие отменят).
Пример: вызов аналитики
val nonEssentialOperationScope = CoroutineScope(SupervisorJob())
suspend fun getConfigAndSongs(): Pair<UserConfig, List<Song> = coroutineScope {
val userConfig = async { getConfigFromAPI() }
val songs = async { getSongsFromAPI()}
nonEssentialOperationScope.launch { /* non essential op here */ }
Pair(userConfig.await(), songs.await())
}
Идеально — внедрить nonEssentialOperationScope
в класс (упрощает тестирование).
Запуск операции в одном потоке, чтобы избежать проблем с синхронизацией
suspend fun myFunction() = withContext(Dispatchers.Default.limitedParallelism(1)) {
// suspend call here
}
// Can also use Dispatchers.IO
Для предотвращения проблем синхронизации при многопоточности есть другие подходы:
Вы можете использовать AtomicReference
(из Java).
private val myList = AtomicReference(listOf(/* add objects here */))
suspend fun fetchNewElement() {
val myNewElement = // fetch new element here
myList.getAndSet { it + myNewElement }
}
Или Mutex
val mutex = Mutex()
private var myList = listOf(/* add objects here */)
suspend fun fetchNewElement() {
mutex.withLock {
val myNewElement = // fetch new element here
myList = myList += myNewElement
}
}
Избегайте повторного перенаправления корутины на тот же диспетчер
Избегайте ненужных затрат на переключение диспетчера, если мы уже используем Main диспетчер:
// this will only dispatch if it is needed
suspend fun myFunction() = withContext(Dispatcher.Main.immediate) {
// suspend call here
}
На данный момент только Dispatchers.Main
поддерживает немедленное (immediate
) выполнение.
Спасибо за прочтение, делитесь своими идеями и профессиональными советами о корутинах. Для глубокого изучения корутин рекомендую прочитать книгу Марцина Москаи (Marcin Moskała).
Научиться разрабатывать тесты для всех платформ, где используется Kotlin, можно на онлайн‑курсе «Kotlin QA Engineer» под руководством экспертов-практиков.