Улучшите производительность приложения с помощью сопрограмм Kotlin

Корутины Kotlin позволяют писать чистый, упрощенный асинхронный код, который обеспечивает отзывчивость приложения при управлении длительными задачами, такими как сетевые вызовы или операции с диском.

В этой статье подробно рассматривается работа с сопрограммами в Android. Если вы не знакомы с сопрограммами, обязательно прочтите статью «Kotlin-сопрограммы в Android» перед тем, как читать эту статью.

Управление длительными задачами

Сопрограммы дополняют обычные функции двумя операциями для обработки длительных задач. В дополнение к invoke (или ` call ) и return , сопрограммы добавляют suspend и resume :

  • suspend приостанавливает выполнение текущей сопрограммы, сохраняя при этом все локальные переменные.
  • resume продолжает выполнение приостановленной сопрограммы с того места, где она была приостановлена.

Вызывать функции suspend можно только из других функций suspend или с помощью конструктора сопрограмм, например, launch , для запуска новой сопрограммы.

Следующий пример демонстрирует простую реализацию сопрограммы для гипотетической длительной задачи:

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) {
}

В этом примере метод get() по-прежнему выполняется в основном потоке, но он приостанавливает сопрограмму перед началом сетевого запроса. После завершения сетевого запроса get возобновляет приостановленную сопрограмму, вместо того чтобы использовать функцию обратного вызова для уведомления основного потока.

Kotlin использует стековый кадр для управления выполняемой функцией, а также локальными переменными. При приостановке сопрограммы текущий стековый кадр копируется и сохраняется для последующего использования. При возобновлении работы сопрограммы стековый кадр копируется обратно из места его сохранения, и функция снова начинает выполняться. Хотя код может выглядеть как обычный последовательный блокирующий запрос, сопрограмма гарантирует, что сетевой запрос не заблокирует основной поток.

Используйте сопрограммы для обеспечения безопасности основного кода.

В Kotlin корутины используют диспетчеры для определения того, какие потоки используются для выполнения корутин. Чтобы запустить код вне основного потока, вы можете указать корутинам Kotlin выполнять работу либо в диспетчере Default , либо в диспетчере IO . В Kotlin все корутины должны выполняться в диспетчере, даже если они работают в основном потоке. Корутины могут приостанавливать свою работу, и диспетчер отвечает за их возобновление.

Чтобы указать, где должны выполняться сопрограммы, Kotlin предоставляет три диспетчера, которые вы можете использовать:

  • Dispatchers.Main — Используйте этот диспетчер для запуска сопрограммы в основном потоке Android. Его следует использовать только для взаимодействия с пользовательским интерфейсом и выполнения быстрых задач. Примеры включают вызов функций suspend , выполнение операций фреймворка пользовательского интерфейса Android и обновление объектов LiveData .
  • Dispatchers.IO — этот диспетчер оптимизирован для выполнения операций ввода-вывода на диске или в сети вне основного потока. Примеры включают использование компонента Room , чтение или запись в файлы, а также выполнение любых сетевых операций.
  • Dispatchers.Default — этот диспетчер оптимизирован для выполнения ресурсоемких задач вне основного потока. Примеры использования включают сортировку списка и разбор JSON.

Продолжая предыдущий пример, вы можете использовать диспетчеры для переопределения функции get . Внутри тела функции get вызовите withContext(Dispatchers.IO) , чтобы создать блок, который выполняется в пуле потоков ввода-вывода. Любой код, помещенный внутрь этого блока, всегда выполняется через диспетчер IO . Поскольку withContext сама по себе является функцией приостановки, функция get также является функцией приостановки.

suspend fun fetchDocs() { // Dispatchers.Main
    val result = get("developer.android.com") // Dispatchers.Main
    show(result) // Dispatchers.Main
}

suspend fun get(url: String) = // Dispatchers.Main
    withContext(Dispatchers.IO) { // Dispatchers.IO (main-safety block)
        /* perform network IO here */ // Dispatchers.IO (main-safety block)
    } // Dispatchers.Main

С помощью сопрограмм вы можете управлять потоками с высокой степенью детализации. Поскольку withContext() позволяет контролировать пул потоков любой строки кода без использования коллбэков, его можно применять даже к очень небольшим функциям, таким как чтение из базы данных или выполнение сетевого запроса. Хорошей практикой является использование withContext() для обеспечения безопасности каждой функции в основном потоке , то есть для вызова функции из основного потока. Таким образом, вызывающей стороне никогда не нужно думать о том, какой поток следует использовать для выполнения функции.

В предыдущем примере функция fetchDocs() выполняется в основном потоке; однако она может безопасно вызвать функцию get , которая выполняет сетевой запрос в фоновом режиме. Поскольку сопрограммы поддерживают suspend и resume , сопрограмма в основном потоке возобновляется с результатом функции get , как только блок withContext завершится.

Производительность withContext()

withContext() не добавляет дополнительных накладных расходов по сравнению с эквивалентной реализацией на основе коллбэков. Более того, в некоторых ситуациях можно оптимизировать вызовы withContext() по сравнению с эквивалентной реализацией на основе коллбэков. Например, если функция выполняет десять вызовов к сети, вы можете указать Kotlin переключать потоки только один раз, используя внешний ` withContext() . Тогда, даже если библиотека сети использует withContext() несколько раз, она остается на одном и том же диспетчере и избегает переключения потоков. Кроме того, Kotlin оптимизирует переключение между Dispatchers.Default и Dispatchers.IO , чтобы по возможности избегать переключения потоков.

Запустить сопрограмму

Сопрограммы можно запускать двумя способами:

  • launch запускает новую сопрограмму и не возвращает результат вызывающей стороне. Любую работу, которая считается "запущенной и забытой", можно начать с помощью launch .
  • async запускает новую сопрограмму и позволяет возвращать результат с помощью функции приостановки, называемой await .

Как правило, новую сопрограмму следует launch из обычной функции, поскольку обычная функция не может вызвать await . Используйте async только внутри другой сопрограммы или внутри функции suspend и при выполнении параллельного разложения.

Параллельное разложение

Все сопрограммы, запущенные внутри функции ` suspend должны быть остановлены после возврата из этой функции, поэтому вам, вероятно, потребуется гарантировать, что эти сопрограммы завершатся до возврата. В Kotlin с помощью структурированной параллельной обработки вы можете определить ` coroutineScope , который запускает одну или несколько сопрограмм. Затем, используя await() ` (для одной сопрограммы) или awaitAll() (для нескольких сопрограмм), вы можете гарантировать, что эти сопрограммы завершатся до возврата из функции.

В качестве примера определим coroutineScope , который асинхронно получает два документа. Вызывая await() для каждой отложенной ссылки, мы гарантируем, что обе async операции завершатся до возврата значения:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Также можно использовать awaitAll() для коллекций, как показано в следующем примере:

suspend fun fetchTwoDocs() = // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf( // fetch two docs at the same time
            async { fetchDoc(1) }, // async returns a result for the first doc
            async { fetchDoc(2) } // async returns a result for the second doc
        )
        deferreds.awaitAll() // use awaitAll to wait for both network requests
    }

Несмотря на то, что функция fetchTwoDocs() запускает новые сопрограммы с помощью async , она использует awaitAll() для ожидания завершения запущенных сопрограмм перед возвратом. Однако следует отметить, что даже если бы мы не вызвали awaitAll() , построитель coroutineScope не возобновил бы сопрограмму, вызвавшую fetchTwoDocs до тех пор, пока не завершатся все новые сопрограммы.

Кроме того, coroutineScope перехватывает любые исключения, которые генерируют сопрограммы, и направляет их обратно вызывающей стороне.

Для получения дополнительной информации о параллельном разложении см. раздел «Композиция функций приостановки» .

концепции сопрограмм

CoroutineScope

CoroutineScope отслеживает все создаваемые им сопрограммы с помощью launch или async . Текущая работа (т.е. запущенные сопрограммы) может быть отменена вызовом scope.cancel() в любой момент времени. В Android некоторые библиотеки KTX предоставляют собственные CoroutineScope для определенных классов жизненного цикла. Например, ViewModel имеет viewModelScope , а LifecyclelifecycleScope . Однако, в отличие от диспетчера, CoroutineScope не запускает сопрограммы.

viewModelScope также используется в примерах, приведенных в статье «Фоновые потоки в Android с использованием корутин» . Однако, если вам необходимо создать собственный CoroutineScope для управления жизненным циклом корутин в определенном слое вашего приложения, вы можете сделать это следующим образом:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Отменённая область видимости не может создавать новые сопрограммы. Поэтому следует вызывать scope.cancel() только тогда, когда уничтожается класс, управляющий её жизненным циклом. При использовании viewModelScope класс ViewModel автоматически отменяет область видимости в методе onCleared() класса ViewModel.

Работа

Объект Job — это дескриптор сопрограммы. Каждая сопрограмма, созданная с помощью launch или async возвращает экземпляр Job , который однозначно идентифицирует сопрограмму и управляет её жизненным циклом. Вы также можете передать Job в CoroutineScope для дальнейшего управления его жизненным циклом, как показано в следующем примере:

class ExampleClass {
    // ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (condition) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

Объект CoroutineContext определяет поведение сопрограммы с помощью следующего набора элементов:

  • Job : Управляет жизненным циклом сопрограммы.
  • CoroutineDispatcher : Распределяет задачи в соответствующий поток.
  • CoroutineName : Имя сопрограммы, полезно для отладки.
  • CoroutineExceptionHandler : Обрабатывает необработанные исключения.

Для новых сопрограмм, созданных в рамках определенной области видимости, новой сопрограмме присваивается новый экземпляр объекта Job , а остальные элементы CoroutineContext наследуются от содержащей ее области видимости. Вы можете переопределить унаследованные элементы, передав новый CoroutineContext в функцию launch или async . Обратите внимание, что передача объекта Job в launch или async не имеет эффекта, поскольку новой сопрограмме всегда присваивается новый экземпляр объекта Job .

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Дополнительные ресурсы сопрограмм

Дополнительные материалы по сопрограммам можно найти по следующим ссылкам: