Coroutines :: опыт практического применения

    В этой статье расскажу о том, как работают корутины и как их создать. Рассмотрим применение при последовательном, параллельном выполнении. Поговорим об обработке ошибок, отладке и способах тестирования корутин. В конце я подведу итог и расскажу о впечатлениях, которые остались после применения данного подхода.

    Статья подготовлена по материалам моего доклада на MBLT DEV 2018, в конце поста — линк на видеозапись.

    Последовательный стиль



    Рис. 2.1

    Какую цель преследовали разработчики корутин? Они хотели, чтобы асинхронное программирование было как можно проще. Нет ничего проще, чем исполнение кода «строка за строкой» с применением синтаксических конструкций языка: try-catch-finally, циклов, условных операторов и так далее.

    Рассмотрим две функции. Каждая выполняется на своем потоке (рис.2.1). Первая выполняется на потоке B и возвращает некий результат dataB, затем нам нужно передать этот результат во вторую функцию, которая принимает dataB в качестве аргумента и уже выполняется на потоке А. С помощью корутин мы можем написать наш код так, как показано на рис. 2.1. Рассмотрим, как можно этого достичь.

    Функции longOpOnB, longOpOnA — так называемые suspend-функции, перед выполнением которых поток освобождается, а после завершения их работы снова становится занят.

    Чтобы эти две функции действительно выполнялись в другом потоке относительно вызываемого, и при этом сохранялся «последовательный» стиль написания кода, мы должны погрузить их в контекст корутины.

    Это делается путём создания корутины с помощью так называемого Coroutine Builder. На рисунке это launch, но существуют и другие, например, async, runBlocking. О них расскажу позже.

    В качестве последнего аргумента передаётся блок исполняемого в контексте корутины кода: вызов suspend-функций, а значит, и всё вышеописанное поведение, возможно только в контексте корутины или же в другой suspend-функции.

    В методе Coroutine Builder есть и другие параметры, например, тип запуска, поток, в котором будет выполняться блок и другие.

    Управление жизненным циклом


    Coroutine Builder в качестве возвращаемого значения отдаёт нам джобу — подкласс класса Job (Рис.2.2). С её помощью мы можем управлять жизненным циклом корутины.

    Стартовать методом start(), отменять методом cancel(), ждать завершения джобы с помощью метода join(), подписываться на событие завершения джобы и другое.


    Рис. 2.2

    Смена потока


    Поменять поток выполнения корутины можно с помощью изменения элемента контекста корутины, отвечающего за диспетчеризацию. (Рис. 2.3)

    Например корутина 1, выполнится в UI-потоке, в то время как корутина 2 в потоке, взятом из пула Dispatchers.IO.


    Рис.2.3

    Библиотека корутин также предоставляет suspend-функцию withContext(CoroutineContext), с помощью которой можно переключаться между потоками в контексте корутины. Таким образом, прыгать между потоками можно довольно просто:


    Рис. 2.4.

    Запускаем нашу корутину на UI-потоке 1 → показываем индикатор загрузки → переключаемся на рабочий поток 2, освобождая при этом главный → выполняем там долгую операцию, которую нельзя выполнять в UI-потоке → возвращаем результат обратно в UI-поток 3 → и уже там работаем с ним, отрисовывая полученные данные и скрывая индикатор загрузки.

    Пока что выглядит довольно удобно, идём дальше.

    Suspend-функция


    Рассмотрим работу корутин на примере самого частого случая — работы с сетевыми запросами с использованием библиотеки Retrofit 2.

    Первое, что нам нужно сделать — преобразовать callback-вызов в suspend-функцию, чтобы воспользоваться возможностью корутин:


    Рис. 2.5

    Для управления состоянием корутины билиотека даёт функции вида suspendXXXXCoroutine, которые предоставляют аргумент, реализующий интерфейс Continuation, с помощью методов resumeWithException и resume которого мы можем возобновлять корутину в случае ошибки и успеха соответственно.

    Далее мы разберёмся, что происходит в случае вызова метода resumeWithException, а для начала позаботимся о том, что нам нужно как-то отменять вызов сетевого запроса.

    Suspend-функция. Отмена вызова


    Для отмены вызова и других действий, касающихся освобождения неиспользуемых ресурсов, при реализации suspend-функции можно использовать идущий из коробки метод suspendCancellableCoroutine (рис. 2.6). Здесь аргумент блока уже реализует интерфейс CancellableContinuation, один из дополнительных методов которого — invokeOnCancellation — позволяет подписаться как на ошибочное, так и успешное событие отмены корутины. Следовательно, здесь и нужно отменять вызов метода.


    Рис. 2.6

    Отобразим изменения в UI


    Теперь, когда suspend-функция для сетевых запросов подготовлена, можно использовать её вызов в UI-потоке корутины как последовательный, при этом во время выполнения запроса поток будет свободен, а для работы запроса будет задействован поток ретрофита.

    Таким образом мы реализуем асинхронное относительно UI-потока поведение, но пишем его в последовательном стиле (Рис. 2.6).

    Если после получения ответа нужно выполнить тяжелую работу, например, записать полученные данные в базу, то эту функцию, как было уже показано, можно легко выполнить с помощью withContext на пуле бэкгрануд-потоков и продолжить выполнение на UI без единой строчки кода.


    Рис. 2.7

    К сожалению, это не всё, что нам нужно для разработки приложений. Рассмотрим обработку ошибок.

    Обработка ошибок: try-catch-finally. Отмена корутины: CancellationException


    Исключение, которое не было поймано внутри корутины, считается необработанным и может вести к падению приложения. Помимо обычных ситуаций, к выбросу исключения приводит возобновление корутины с использованием метода resumeWithException на соответствующей строке вызова suspend-функции. При этом исключение, переданное в качестве аргумента, выбрасывается в неизмененном виде. (Рис. 2.8)


    Рис. 2.8

    Для обработки исключений становится доступна стандартная конструкция языка try catch finally. Теперь код, который умеет отображать ошибку в UI принимает следующий вид:


    Рис. 2.9

    В случае отмены корутины, которой можно добиться путём вызова метода Job#cancel, кидается исключение CancellationException. Это исключение по умолчанию обрабатывается и не приводит к крашам или другим негативным последствиям.

    Однако, при использовании конструкции try/catch оно будет поймано в блоке catch, и с ним нужно считаться в случаях, если вы хотите обрабатывать только действительно «ошибочные» ситуации. Например, обработку ошибки в UI, когда есть возможность «отмены» запросов или предусмотрено логирование ошибок. В первом случае ошибка будет отображена пользователю, хотя её на самом деле и нет, а во втором — будет логироваться бесполезное исключение и захламлять отчёты.

    Чтобы игнорировать ситуацию отмены корутины, необходимо немного модифицировать код:


    Рис. 2.10

    Логирование ошибок


    Рассмотрим ситуацию со стэктрейсом исключений.

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


    Рис. 2.11

    Однако исключения, которые передаются в метод resumeWithException suspend-функций, как правило, не содержат информации о корутине, в которой оно произошло. Например (Рис. 2.12), если из реализованной ранее suspend-функции, возобновить корутину с тем же исключением, что и в предыдущем примере, то стэктрейс не даст информации о том, где конкретно искать ошибку.


    Рис. 2.12

    Чтобы понять, какая корутина возобновилась с исключением, можно воспользоваться элементом контекста CoroutineName. (Рис. 2.13)

    Элемент CoroutineName используется для отладки, передав в него имя корутины, можно его извлечь в suspend-функции и, например, дополнить сообщение исключения. То есть как минимум будет понятно, где искать ошибку.

    Этот подход будет работать только в случае с исключением из данной suspend-функции:


    Рис. 2.13

    Логирование ошибок. ExceptionHandler


    Для изменения логирования исключений для конкретной корутины можно установить свой ExceptionHandler, который является одним из элементов контекста корутины. (Рис. 2.14)

    Обработчик должен реализовывать интерфейс CoroutineExceptionHandler. С помощью переопределённого оператора + для контекста корутин можно подменить стандартный обработчик исключений на собственный. Необработанное исключение попадёт в метод handleException, где с ним можно сделать всё, что нужно. Например, полностью проигнорировать. Это произойдёт, если оставить обработчик пустым или дополнить собственной информацией:


    Рис. 2.14

    Посмотрим, как может выглядеть логирование нашего исключения:

    1. Нужно помнить про CancellationException, который хотим проигнорировать.
    2. Добавить собственные логи.
    3. Помнить про дефолтное поведение, в которое входит логирование и завершение приложения, иначе исключение просто «исчезнет» и будет не понятно, что произошло.

    Теперь для случая выкидывания исключения будет приходить распечатка стэктрейса в логкат с дополнённой информацией:


    Рис. 2.15

    Параллельное выполнение. async


    Рассмотрим параллельную работу suspend-функций.

    Для организации параллельного получения результатов от нескольких функций лучше всего подходит async. Async, как и launch — Coroutine Builder. Его удобство состоит в том, что он, используя метод await(), возвращает данные в случае успеха или бросает исключение, возникшее в процессе выполнения корутины. Метод await будет дожидаться завершения выполнения корутины, если она ещё не завершена, в противном случае сразу отдаст результат работы. Обратите внимание, что await является suspend-функцией, поэтому не может выполняться вне контекста корутины или другой suspend-функции.

    Используя async, параллельное получение данных из двух функций будет выглядеть примерно так:


    Рис. 2.16

    Представим, что перед нами стоит задача параллельного получения данных из двух функций. Затем, нужно их объединять и отображать. В случае возникновения ошибки необходимо отрисовывать UI, отменяя при этом все текущие запросы. Такой кейс часто встречается на практике.

    В этом случае обрабатывать ошибку нужно следующим образом:

    1. Заносим обработку ошибок внутрь каждой из async-корутин.
    2. В случае ошибки отменяем все корутины. К счастью, для этого существует возможность указать родительскую джобу, при отмене которой отменяются и все её дочерние.
    3. Придумываем дополнительную реализацию чтобы понять, все ли данные успешно загрузились. Например, будем считать, что если await вернул null, то при получении данных произошла ошибка.

    С учётом всего этого, реализация родительской корутины становится несколько сложнее. Также усложняется реализация async-корутин:


    Рис. 2.17

    Данный подход не является единственно возможным. Например, вы можете реализовать параллельное выполнение с обработкой ошибок, используя ExceptionHandler или SupervisorJob.

    Вложенные корутины


    Посмотрим на работу вложенных корутин.

    По умолчанию вложенная корутина создаётся с использованием скоупа внешней и наследует её контекст. Как следствие, вложенная корутина становится дочерней, а внешняя — родительской.

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

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


    Рис. 2.18

    Сделать из глобальной вложенной корутины дочернюю можно, заменив элемент контекста с ключом Job на родительскую джобу, либо полностью использовать контекст родительской корутины. Но в этом случае стоит помнить, перенимаются все элементы родительской корутины: пул потоков, exception handler и так далее:


    Рис. 2.19

    Теперь ясно, что в случае использования корутин извне нужно предоставлять им возможность установки либо экземляра джобы, либо контекста родителя. А разработчикам библиотек нужно учитывать возможность установки её как дочерней, что вызывает неудобства.

    Точки останова


    Корутины влияют на просмотр значений объектов в режиме отладки. Если поставить точку останова внутри следующей корутины на функции logData, то при её срабатывании увидим, что здесь всё хорошо и значения отображаются корректно:


    Рис. 2.20

    Теперь получим dataA с помощью вложенной корутины, оставив точку останова на logData:


    Рис. 2.21

    Попытка раскрыть блок this, чтобы попытаться найти нужные значения, оборачивается неудачей. Таким образом, отладка при наличии suspend-функций становится затруднительной.

    Unit-тестирование


    Unit-тестирование реализовать довольно просто. Для этого можно использовать Coroutine Builder runBlocking. runBlocking блокирует поток до тех пор, пока не завершаться все его вложенные корутины, а это именно то, что нужно для тестирования.

    Например, если известно, что где-то внутри метода для его реализации используется корутина, то для тестирования метода нужно лишь обернуть его в runBlocking.

    runBlocking можно использовать для тестирования suspend-функции:


    Рис. 2.22

    Примеры


    Напоследок хотелось бы показать несколько примеров использования корутин.

    Представим, что нам нужно выполнить параллельно три запроса A, B и C, показать их завершение и отразить момент завершения запросов A и B.

    Для этого можно просто обернуть корутины запросов A и B в одну общую и работать с ней, как с единым целым:


    Рис. 2.23

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


    Рис. 2.24

    Выводы


    Из минусов отмечу, что корутины — относительно молодой инструмент, поэтому если хотите использовать их на проде, то делать это стоит с осторожностью. Есть сложности отладки, небольшой бойлерплэйт в реализации очевидных вещей.

    В целом, корутины довольно просты в использовании, особенно для реализации не сложных асинхронных задач. В частности из-за того, что можно применять стандартные конструкции языка. Корутины легко поддаются unit-тестированию и всё это идет из коробки от той же компании, что разрабатывает язык.

    Видеозапись доклада


    Получилось много букв. Для тех, кому больше нравиться слушать — видео с моего доклада на MBLT DEV 2018:


    Полезные материалы по теме:


    • +27
    • 10.8k
    • 3
    e-Legion
    90.91
    Лидер мобильной разработки в России
    Share post

    Comments 3

      0

      Не надо использовать job, возвращенный из launch внутри этого самого launch — это ошибка. Вам никто не гарантирует, что он уже будет заполнен. Безотносительно исходной задачи надо делать coroutineContext[Job]!!. А если я саму задачу правильно понял, то лучше вообще вместо GlobalScope и ручной отмены использовать просто coroutineScope {}.

        +1
        Начинаю читать статью и понимаю, что на первой же картинке читателя вводят в заблуждение.
        Методы suspend работают совсем не так. Они не выполняются в отдельном потоке!
          0

          Судя по названию метода, он внутри использует withContext(B). В любом случае без исходного кода функции нельзя точно сделать вывод о потоках на которых она выполняется

        Only users with full accounts can post comments. Log in, please.