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

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

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

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


ke8ztulkufchujj6k5pxutqtigw.png
Рис. 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 (), подписываться на событие завершения джобы и другое.

osbf9jvwmvqtlidv_ugwrfbk-yc.png
Рис. 2.2

Смена потока


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

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

oe9eo1ynqwdskvi-laxuunj-xkk.png
Рис. 2.3

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

gacaimxsxh_bjcxjweywrc8kstw.png
Рис. 2.4.

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

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

Suspend-функция


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

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

hskkuekq2frbocs_hd-oxsz8k7o.png
Рис. 2.5

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

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

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


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

59uthwhyrpuk2-hzm6c2rax0vzk.png
Рис. 2.6

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


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

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

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

ksqkg48x7gjja00x6hii4cpc1uo.png
Рис. 2.7

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

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


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

hmsvf0yzwobbhf3mlv_ce8xy2l8.png
Рис. 2.8

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

ce53hpazfknfd3962fn0wvfv8x8.png
Рис. 2.9

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

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

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

cw9pyfzj3sbzqrpz49b0ochntjq.png
Рис. 2.10

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


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

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

701qh72uei7qbt8jpf9kegbnavy.png
Рис. 2.11

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

wd41xr4kb6f-luf61_cvcmxetr4.png
Рис. 2.12

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

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

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

5uxsstlugngflzdlcrarrfws1d4.png
Рис. 2.13

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


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

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

c_1w3ya8lagmhplbeeq9ajvtbws.png
Рис. 2.14

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

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

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

mrjlscj8bilbyancdfmygexqlki.png
Рис. 2.15

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


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

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

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

w1-thvudknznjyexdz7_c5rb0w8.png
Рис. 2.16

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

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

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


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

wyxenyk2quyek7zmhz4ze1dq5sw.png
Рис. 2.17

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

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


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

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

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

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

fjpu_yzyp3z0ttuypjypxu2oav4.png
Рис. 2.18

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

hhwdjxobjkl0arojj0sh6ufii7m.png
Рис. 2.19

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

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


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

hpt-ockcfqvrlzej2ncx0v2cboy.png
Рис. 2.20

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

bq0jcbefwitj0ss-bhahf8_cjy8.png
Рис. 2.21

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

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


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

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

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

okvi4t5bogczdolbbiimwmqyz5g.png
Рис. 2.22

Примеры


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

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

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

xyutfowgblzyjx5l-fb0tcazbx4.png
Рис. 2.23

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

anbkzbm6ozetsl3vtwopsmtm2f0.png
Рис. 2.24

Выводы


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

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

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


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

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


© Habrahabr.ru