Часть 1, Часть 2

Мы, разработчики, обычно тратим много времени на полировку наилучшего сценария работы нашего приложения. Однако не менее важно обеспечить надлежащий пользовательский опыт, когда все идет не так, как ожидалось. С одной стороны, наблюдать сбой приложения — это плохой опыт для пользователя; с другой, необходимо показать ему правильное сообщение, когда действие не удалось.

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

Если вы предпочитаете видео, посмотрите это обсуждение KotlinConf’19 от Флорины Мунтенеску и меня (англ):

⚠️ Для того, чтобы понимать остальную часть статьи без каких-либо проблем, необходимо прочитать и понять часть 1 серии.

Корутина внезапно завершилась! И что теперь? ?

Когда корутина терпит неудачу с исключением, она распространит его до своего родителя! Затем родитель 1) отменит остальные свои дочерние элементы, 2) отменит себя и 3) распространит исключение до своего родителя.

Исключение достигнет корня иерархии, и все сопрограммы, которые запустил CoroutineScope, также будут отменены.

Исключение в корутине будет распространяться по всей иерархии

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

А что, если вы не хотите такого поведения? Кроме того, возможно использовать другую реализацию Job, а именно SupervisorJob, в CoroutineContext CoroutineScope который и создает эти корутины.

SupervisorJob на помощь

С SupervisorJob неудача одного дочернего элемента не влияет на других. SupervisorJob не отменит ни саму себя, ни остальных своих “детей”. Кроме того, SupervisorJob также не будет распространять исключение и позволит дочерней корутине обрабатывать его.

Вы можете создать CoroutineScope, подобный этому val uiScope = CoroutineScope(SupervisorJob()), чтобы не распространять отмену при сбое корутины, как показано на этом изображении:

SupervisorJob не отменит ни саму себя, ни остальных своих детей

Если исключение не обрабатывается и у CoroutineContext нет обработчика CoroutineExceptionHandler (как мы увидим позже), то оно достигнет ExceptionHandler потока по умолчанию. В JVM исключение будет регистрироваться в консоли, а в Android оно приведет к сбою вашего приложения независимо от Диспетчера, на котором это происходит.

?Неотловленные исключения всегда будут отбрасываться независимо от того, какой тип Job вы используете

То же самое поведение применяется к конструкторам областей CoroutineScopeи supervisorScope. Они создадут подпространство (с Job или SupervisorJob соответственно в качестве родителя), с помощью которого вы можете логически сгруппировать корутины (например, если вам надо выполнять параллельные вычисления или же вы хотите, чтобы они были или не были затронуты друг другом).

Предупреждение: SupervisorJob работает только так, как описано, являясь при этом частью области видимости: то есть создано с помощью supervisorScope, либо CoroutineScope(SupervisorJob()).

Job или SupervisorJob? ?

Когда вы должны использовать Jobили SupervisorJob? Используйте SupervisorJob или supervisorScope, если вы не хотите, чтобы сбой отменял родителя и его сиблингов.

Примеры:

// Область обработки корутин для конкретного слоя моего приложения

val scope = CoroutineScope(SupervisorJob())scope.launch {
    // Потомок 1
}scope.launch {
    // Потомок 2
}

В этом случае, если child#1 не сработает, ни область действия, ни child#2 не будут отменены. Еще пример:

// Область обработки корутин для конкретного слоя моего приложения
val scope = CoroutineScope(Job())scope.launch {
    supervisorScope {
        launch {
            // Child 1
        }
        launch {
            // Child 2
        }
    }
}

В этом случае, поскольку supervisorScope создает подпространство с заданием SupervisorJob, если child#1 завершится неудачей,child#2 не будет отменен. Если вместо этого вы используете CoroutineScope, сбой будет распространяться дальше и в конечном итоге приведет к отмене области.

Итак, викторина! А кто же мой родитель? ?

Учитывая следующий фрагмент кода, можете ли вы определить, какой тип Job child#1 имеет в качестве родителя?

val scope = CoroutineScope(Job())scope.launch(SupervisorJob()) {
    // новая корутина -> возможно приостановить
   launch {
        // Child 1
    }
    launch {
        // Child 2
    }
}

parentJob child#1 — это работа типа Job! Надеюсь, все понятно! Даже если на первый взгляд можно подумать, что это может быть SupervisorJob, это не потому, что новой корутине всегда назначается новое Job(), которое в данном случае переопределяет SupervisorJob. SupervisorJob является родителем корутины, созданной с помощью scope.launch, так что SupervisorJob ничего не делает в этом коде!

Родитель child#1 and child#2 имеет тип Job, не SupervisorJob

Таким образом, если child#1 или child#2 завершится неудачно, то ошибка достигнет области действия и вся работа, начатая ей, будет отменена.

Помните, что SupervisorJob работает только так, как он описан, являясь при этом частью области: то есть создан с помощью supervisorScope, либо CoroutineScope(SupervisorJob()). Передача SupervisorJob в качестве параметра конструктора корутины не будет иметь желаемого эффекта, который можно ожидать от отмены.

Что касается исключений, то если какой-либо дочерний элемент создает исключение, то SupervisorJob не будет распространять его вверх по иерархии и позволит корутине обработать его.

Под капотом

Если вам интересно, как работает Job под капотом, ознакомьтесь с реализацией функций childCancelled и notifyCancelling в файлеJobSupport.kt.

В реализации SupervisorJob метод childCancelled просто возвращает false, что означает, что он не распространяет отмену, но и не обрабатывает исключение.

Работа с исключениями ? ?

Корутины используют обычный синтаксис Kotlin для обработки исключений: try/catch либо встроенные вспомогательные функции, такие как runCatching( который использует try/catch внутренне).

Ранее упоминалось, что всегда будут появляться неучтенные исключения. Однако разные сборщики корутин трактуют исключения по-разному.

Запуск

При запуске исключения будут появляться сразу же, как только они произойдут. Таким образом, вы можете обернуть код, который может создавать исключения внутри try/catch, как в этом примере:

scope.launch {
    try {
        codeThatCanThrowExceptions()
    } catch(e: Exception) {
        // Обработать исключение
    }
}

Async

Когда async используется в качестве корневой корутины (являющейся прямым потомком экземпляра CoroutineScope или supervisorScope), исключения создаются не автоматически, а при вызове .await().

Чтобы обрабатывать исключения, создаваемые async всякий раз корневой корутиной, можно обернуть ее .await() вызова внутри try/catch:

supervisorScope {
    val deferred = async {
        codeThatCanThrowExceptions()
    }    try {
        deferred.await()
    } catch(e: Exception) {
        // Обработка исключения, появившегося в async
    }
}

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

Также обратите внимание, что мы используем supervisorScope для вызова async и await. Как мы уже говорили, SupervisorJob позволяет корутине обрабатывать исключение, в отличие от Job, которое автоматически распространит его вверх по иерархии, поэтому блок catch не будет вызван:

coroutineScope {
    try {
        val deferred = async {
            codeThatCanThrowExceptions()
        }
        deferred.await()
    } catch(e: Exception) {
        // Исключение, вызванное в async, не будет отловлено здесь и   // распространится до области действия
    }
}

Кроме того, исключения, которые происходят в корутинах, созданных другими, всегда будут распространяться независимо от их конструктора. Например:

val scope = CoroutineScope(Job())scope.launch {
    async {
        // Если исключение выбрасывает async, launch вызывается без вызова .await()
    }
}

В этом случае, если async создает исключение, оно будет вызвано сразу же, потому что launch — это корутина, являющаяся прямым дочерним элементом области. Причина заключается в том, что asyncJob в его CoroutineContext) автоматически распространит исключение до его родителя (launch), который его и вызовет.

⚠️Исключения, вызванные в конструкторе CoroutineScope или в корутинах, созданных другими, не будут пойманы в try/catch!

В разделе SupervisorJob мы упоминаем о существовании CoroutineExceptionHandler. Давайте погрузимся в него!

CoroutineExceptionHandler

CoroutineExceptionHandler— это необязательный элемент CoroutineContext, позволяющий обрабатывать неучтенные исключения.

Вот как вы можете определить CoroutineExceptionHandler: всякий раз, когда ловится исключение, у вас есть информация о CoroutineContext, где оно произошло, а также непосредственно само исключение:

val handler = CoroutineExceptionHandler {
    context, exception -> println("Caught $exception")
}

Исключения будут пойманы, если требования будут выполнены:

  • Когда⏰: исключение создается корутиной, которая автоматически создает другие исключения (работает с launch, а не с async).
  • Где ?: если он находится в CoroutineContext CoroutineScope или корневой корутине (прямой потомок CoroutineScope или supervisorScope).

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

val scope = CoroutineScope(Job())
scope.launch(handler) {
    launch {
        throw Exception("Failed coroutine")
    }
}

В другом случае, когда обработчик установлен во внутренней корутине, он не будет пойман:

val scope = CoroutineScope(Job())
scope.launch {
    launch(handler) {
        throw Exception("Failed coroutine")
    }
}

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

Изящно справляясь с исключениями в вашем приложении, вы сможете предоставлять хороший пользовательский опыт, даже если все идет не так, как ожидалось.

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

Неучтенные исключения будут увеличиваться в числе. Отлавливайте их, чтобы обеспечить отличный UX!

Читайте также:


Перевод статьи Manuel Vivo: Exceptions in coroutines

Предыдущая статьяДвоичное дерево поиска: вставка значения с использованием JavaScript
Следующая статьяИскусство упрощения для программистов