Предположим, что вы уже какое-то время работаете с 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» под руководством экспертов-практиков.
ссылка на оригинал статьи https://habr.com/ru/articles/854090/
Добавить комментарий