CoroutineExceptionHandler - зло

 

Часто вижу статьи с заголовком наподобие “Exception handling with coroutines made easy”. Дейстрительно easy, только нездорово, бестолково.

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
GlobalScope.launch(handler) { 
    throw AssertionError()
}

Тут корутина выбрасывает uncaught исключение и все хорошо, программы не упала, инфу в лог написала. Магия? Нет, все просто, она под капотом оборачивает всё в один всеобъемлющий try-catch

Uncaught Exceptions

Здорово, правда? Нихрена. Это означает, что разработчик не придусмотрел какой-то случай после которого приложение уже ведет себя не так как запланировано. Мы игнорируем ошибку в одном месте, после чего все остальное рушится как карточный домик. Последствия этой ошибки могут проявиться позже в самом неочевидном месте, а мы будем сидеть с дебаггером, следить за цепочкой вызовов, чтобы в конце обнаружить, что в какой-то момент id пользователя не загрузилось. Вывод: Приложение должно было упасть! PS: Конечно, есть исключения, не принимаем все буквально. Так мы быстро сможем найти и устранить проблему. Как считаете, что легче заметить, краш приложения, или то, что на каком-то экране вместо реальных данных вдруг появилась заглушка?

Clean Architecture

Что насчет чистого кода? У нас есть 3 слоя, Presentation, Domain и Data. Обычно в слое представления у нас есть какой-то scope, привязанный к Android lifecycle. В нем Presenter/ViewModel запускает корутину, которая обращается к какому-то интерактору или usecase, может даже к репозиторию(Тут будет ссылка на статью, где я объясняю почему он не нужен). А тот в свою очередь обращается к Model. Так какого черта, в слое представления мы обрабатываем ошибки из слоя о котором мы вообще не должны ничего знать? Мало того, что мы должны обрабатывать все виды ошибок в классе, ответственном за отображение информации во View, так еще и изменения в Data затрагивают логику Presentation, слоя с которым он вообще никак не контактирует.

Обработка ошибок здорового человека.

Either

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

// Представляет значение одного из двух возможных типов. Левая часть обычно хранит ошибку, а правая успешный результат. 
sealed class Either<out L, out R> {
    class Left<out L>(val a: L) : Either<L, Nothing>()  
    class Right<out R>(val b: R) : Either<Nothing, R>()
    
    val isRight get() = this is Right<R>
    val isLeft get() = this is Left<L>
    
    // Сюда передаем ссылки на функции, которые будут вызваны в зависимости от значения Left | Right
    fun fold(fnL: (L) -> Any, fnR: (R) -> Any): Any =
    when (this) {
        is Left -> fnL(a)
        is Right -> fnR(b)
    }
}
 

Тут я привел минимально возможный пример, советую посмотреть Either из библиотеки Arrow, так вы сможете взять лучшее от ФП и ООП, и применить в своем проекте.

Failure

Нужен базовый класс-обертка для всех возможных ошибок. Который позже будет обработан в Presentation.

sealed class Failure {
    object ServerFailure: Failure()
    object InternalFailure: Failure()
    object UnknownFailure: Failure()
    abstract class FeatureFailure: Failure()
}

По порядку:

  • ServerFailure возвращаем по каким-то левым телодвижениям от сервера, типа внутренних ошибок, либо отсутствия интернет соединения.
  • InternalFailure может быть чем угодно, начиная от ошибок в базке данных, до ArithmeticException в бизнесс логике
  • FeatureFailure служит для создания своих специфических ошибок, конкретной бизнес логики. Например, ошибка от сервера Wrong Password, обрабатывает только на определенном экране.
  • UnknownFailure непредвиденная ошибка из-за которой все же падать не стоит.

    UseCase

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

class AuthUseCase(private val authApi: AuthApi) {

// Ошибка, для ситуации с неверным паролем, нормальный сервер должен давать эту информацию. 
class BadPasswordFailure : Failure.FeatureFailure()

suspend fun run(phoneNumber: String, password: String): Either<Failure, UserCredentials> {
    // Body запроса 
    val params = PhoneAuthorizeRequestParams(phoneNumber, password)

    return try {
        // Представим, что authApi, уже возвращает нам Either c сериализованным UserCredentials либо FailureResponse(status: Int)
        val response = authApi.singUp(params)
        if (response.isRight()) {
            Either.Right(response)
        } else if (response.isLeft()){
            if (response.code == Codes.WrongPassword) { Either.Left(BadPasswordFailure()) }
            else { Either.Left(ServerFailure) }
        } else { Either.Left(UnknownFailure) } 
    } catch (e: Exception) {
        Either.Left(UnknownFailure)
    }
}

Presentation

Теперь нужно вызвать UseCase из Presentation слоя, пусть для примера, будет MVP

class AuthPresenter {

    fun load() {
        uthUseCase.run(view.getPhone(), view.getPassword()).fold(::handleFailure, ::handleCredentials)
    }
    private fun handleCredentials(credentials: UserCredentials) {
        view.showUserInfo(credentials) // Отображаем информацию о пользователе
    }
    private fun handleFailure(failure: Failure) {
        when (failure) {
            is ServerFailure -> view.showServerFailure() // Говорим пользователю проверить интернет соединение и перезагрузить страницу
            is BadPasswordFailure -> view.showWrongPasswordFailure() // Просим ввести пароль заново
            else -> view.showServerFailure() // Говорим пользователю перезагрузить страницу
        }
    }
}

Можно создать базовую функцию для обработки стандартных ошибок, и переопередлять ее только по необходимости, если возможны какие-то FetureFailure.

Итог

  • Изменения в слое Data никак не влияют на слой представления
  • Легко протестировать
  • Код выглядит пиздато, все по полочкам