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

в 11:24, , рубрики: android, coroutines, elegion, kotlin, Блог компании e-Legion, Программирование, разработка мобильных приложений, Разработка под android

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

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

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

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

Coroutines :: опыт практического применения - 2
Рис. 2.2

Смена потока

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

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

Coroutines :: опыт практического применения - 3
Рис.2.3

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

Coroutines :: опыт практического применения - 4
Рис. 2.4.

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

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

Suspend-функция

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

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

Coroutines :: опыт практического применения - 5
Рис. 2.5

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

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

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

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

Coroutines :: опыт практического применения - 6
Рис. 2.6

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

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

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

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

Coroutines :: опыт практического применения - 7
Рис. 2.7

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

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

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

Coroutines :: опыт практического применения - 8
Рис. 2.8

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

Coroutines :: опыт практического применения - 9
Рис. 2.9

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

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

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

Coroutines :: опыт практического применения - 10
Рис. 2.10

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

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

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

Coroutines :: опыт практического применения - 11
Рис. 2.11

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

Coroutines :: опыт практического применения - 12
Рис. 2.12

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

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

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

Coroutines :: опыт практического применения - 13
Рис. 2.13

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

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

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

Coroutines :: опыт практического применения - 14
Рис. 2.14

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

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

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

Coroutines :: опыт практического применения - 15
Рис. 2.15

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

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

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

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

Coroutines :: опыт практического применения - 16
Рис. 2.16

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

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

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

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

Coroutines :: опыт практического применения - 17
Рис. 2.17

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

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

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

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

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

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

Coroutines :: опыт практического применения - 18
Рис. 2.18

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

Coroutines :: опыт практического применения - 19
Рис. 2.19

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

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

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

Coroutines :: опыт практического применения - 20
Рис. 2.20

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

Coroutines :: опыт практического применения - 21
Рис. 2.21

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

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

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

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

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

Coroutines :: опыт практического применения - 22
Рис. 2.22

Примеры

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

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

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

Coroutines :: опыт практического применения - 23
Рис. 2.23

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

Coroutines :: опыт практического применения - 24
Рис. 2.24

Выводы

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

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

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

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

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

Автор: mixslo

Источник

* - обязательные к заполнению поля