Усъвършенстване на мрежата на Android с Coroutines на Kotlin

Ако сте като мен, обичате да абстрахирате всичко. Наличието на структури за многократна употреба, които намаляват количеството шаблони, е това, което ви прави добър разработчик.

Една такава досадна задача, от която винаги съм искал да се абстрахирам, е работата в мрежа. Огромни обратни извиквания, грозна обработка на грешки, анализиране на стойностите и картографиране на анализирани стойности в модели на бизнес логика е шаблон, от който исках да се отърва.

И всъщност успях да го опаковам в хубаво решение, подобно на API.

По-добър начин за обработка на данни

Така че всяка заявка има един и същ набор от стъпки:

  1. Изискваме данните и декларираме типа, който очакваме (някакъв модел)
  2. JSON/XML пристига със своите стойности и ние се опитваме да анализираме отговора
  3. Проверяваме модела и ако всички необходими стойности са там, го показваме, ако не, показваме съответната грешка

Тъй като е еднакъв за всички заявки, той е чудесен кандидат за генерично решение (да, обичам генеричните)!

Друго нещо, което искаме да постигнем, е една структура да представлява както успешна заявка, така и случай на грешка. Чудесно нещо, което бихме могли да използваме, е Запечатан клас. В Java обикновено се опитвате да намерите библиотека, която прави този тип неща. В Kotlin обаче обичам да се опитвам да измисля мое решение. Ще се опитаме да направим това тук, като създадем Резултат.

Резултат

Преминаваме към реално изпълнение! Ще използваме запечатан клас, за да заключим два типа отговор: успех и неуспех. Те ще имат съответно стойност или грешка като свойства.

sealed class Result<out T : Any>

data class Success<out T : Any>(val data: T) : Result<T>()

data class Failure(val error: Throwable?) : Result<Nothing>()

Само три реда код??!! Да, използвайки Kotlin, можем да накараме това да следва максимално принципа на KISS. В случай на успех получаваме данните (от тип T), а в случай на неуспех получаваме грешка (или няма грешка, ако е резервен вариант, направен от потребителя).

Сега можем да проверим какво сме получили от заявка:

fun onResponse(result: Result<User>) = when (result) {
}

Кратка бележка, когато използваме запечатан клас, компилаторът автоматично ни предлага да изчерпим всички случаи. Това означава, че ако получим Резултат, той може да бъде само Успех или Провал, нищо друго.

Добавянето на „останалите клонове“ и някои имплементации ни дава:

fun onResponse(result: Result<User>) = when (result) {
    is Success -> displayData(result.data)
    is Failure -> showError(result.error)
}

Това изглежда доста просто. Ако получим грешка, проверяваме типа и кода, в зависимост от Throwable type, и в щастливия път показваме данните.

Липсва още една стъпка — картографирането. Често ще имате модел, деклариран за бекенд:

data class UserResponse(val id: String?,
                        val firstName: String?,
                        val lastName: String?,
                        val profileImage: String?)

Забележете как всички полета са nullable. Това е за смекчаване на неуспешен анализ. Аз лично не бих искал да се занимавам с нулеви стойности в целия си ViewModel, тъй като в края на краищата ние не пишем Java, за да трябва да правим всички тези нулеви проверки.

Ето защо използваме различен модел само за бизнес логика:

data class User(val id: String = "",
                val firstName: String = "",
                val lastName: String = "",
                val profileImage: String = "")

Всички стойности тук не са нулеви, въпреки че са празни по подразбиране. Вероятно питате: „Не бихте позволили съзнателно на потребител да премине през приложението с празен идентификатор, нали?

не Ние не бихме. Ето защо е необходима конструкция за съпоставяне между процеса на получаване на анализирания потребител и бизнес логиката. Друга страхотна ООП идея идва на ум тук, картографируем интерфейс!

Картографируем‹T›

interface Mappable<out T : Any> {
    fun mapToResult(): Result<T>
}

Всеки модел на отговор трябва да прилага това. Това е начинът да кажа, добре, ако съм UserResponse, моето бизнес име ще бъде User, за да опростя нещата и да разгранича необработен JSON отговор от действително използваем POKO.

И все пак как да разберем, че UserResponse наистина е валиден?

Като декларирате функция/разширение на всеки модел, за да изясните кога е добре да го използвате!

data class UserResponse(val id: String?,
                        val firstName: String?,
                        val lastName: String?,
                        val profileImage: String?) : Mappable<User> {

    override fun mapToResult(): Result<User> = when {
        isValid() -> Success(User(id ?: "",
                firstName ?: "",
                lastName ?: "",
                profileImage ?: ""))

        else -> Failure(Exception("User body is invalid"))
    }

    private fun isValid() = !id.isNullOrBlank() && !firstName.isNullOrBlank()
}

Просто изглежда логично отговорът да знае дали може или не може да картографира. Малко е грозно, че трябва изрично да използваме оператор Elvis за стойности, но това е незначителен проблем.

Така че сега, когато получим отговора, можем да предадем правилния резултат на бизнес слоя. Браво! :]

Покрихме почти всичко от списъка с кофи с грозни неща в мрежите на Android. Остана още една задача — обратни повиквания.

Async е труден за разбиране

Първо, нека направим преглед на асинхронното програмиране. Моето общо мнение е, че асинхронното програмиране не е нещо, което хората трябва да разбират. Извиквате функция и тя се изпълнява, но някъде в бъдещето нейният резултат се връща. Освен това резултатът се връща чрез обратно извикване, нещо още по-магическо.

Това е една от най-трудните концепции, които трябва да обясним на нашите стажанти тук COBE Team.

Първият път, когато срещнах асинхронно програмиране, използвах AsyncTask, което има още по-малко смисъл. Вие просто внедрявате неговите три основни метода и данните се предават зад завесите.

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

Въведете Coroutines

Паралелността може да причини главоболие. Още повече, ако се опитвате да комбинирате повече от една заявка. Ето защо Kotlin въвежда концепция за съпрограмив света на JVM.

Корутините са много прости по своята същност, но отнема известно време, за да ги разберете, тъй като е трудно да се мисли за нещо, което е едновременно последователно и синхронно по някакъв начин, докато работи асинхронно. Разгледайте това:

fun showUserProfile(userId: String) {
    val user = service.getUser(userId)
    
    view.showUserName(user.name)
}

Тук искаме да покажем името на потребителя. Би било идеално, ако можем просто да поискаме потребителя от услуга и незабавно да я използваме за показване на данните в потребителския интерфейс. Стандартните асинхронни подходи не ни позволяват да го направим, имаме нужда от ламбда обратно извикване, което ще реагира на пристигането на данни.

Но чрез използване на съпрограми нашият код прехвърля доста:

fun showUserProfile(userId: String) = async {
    val user = async { service.getUser(userId) }.await()

    view.showUserName(user.name)
}

Отказ от отговорност: това е свободно изпълнение, зад кулисите не е толкова просто като това. Въпреки че след като внедрим нашия API, ще бъде още по-лесно.

Асинхронният блок тук е конструктор на сърутина. Това означава, че когато всичко вътре в него се изпълнява в сърутина, то се спира, докато не се появи резултат. Звучи ми познато? Основната разлика между този и асинхронния е, че няма изрично обратно извикване, което трябва да дефинираме.

В случай, че искаме да използваме функция за спиране или сърутина някъде, тя трябва да се използва в конструктор на съпрограма. Така че нека внедрим умен начин за изискване на данни в последователен блок код, захранван от съпрограма.

Магия зад кулисите

Нашият идеален поток има следните стъпки:

  1. Изпратете заявка, получете обратно обаждането на Retrofit‹T›
  2. Преобразувайте гореспоменатото извикване в Coroutine/Deferred стойност
  3. Използвайки интерфейса Mappable‹T›, преобразувайте данните в случай на резултат, от който се нуждаем
  4. Върнете стойността последователно, така че да е готова за използване на следващия ред

Вече сме подготвили процедурата за анализиране и картографиране, сега трябва да изградим конструкция, която ще ни позволи да върнем отложена стойност, а не отговор. И ключовата точка за продажба е: без обратни извиквания (може да излъжа за това :]).

Трябва да започнем с дефиниране на базовия подпис:

fun <T : Any> getResult(): Result<T> {
    
}

В момента това не връща нищо, трябва да го накараме да върне готова стойност, но асинхронно! Нека добавим сърутина:

suspend fun <T : Any> getResult(): Result<T> = suspendCoroutine {

}

Подписът ни се промени доста. Тъй като използваме сърутина, се нуждаем от модификатор за спиране. Но стигаме някъде, ако напишете този код, няма да има грешки, можете да върнете съпрограма като резултат‹T› и да обработвате данните от съпрограмата.

Обикновено получаваме отговор, като използваме Обаждане. Така че нека добавим повикване и малко логика за обработка на данни.

suspend fun <T : Any> getResult(call: Call<T>): Result<T> = suspendCoroutine {
    val data = call.execute()?.body()

    if (data != null) {
        it.resume(Success(data))
    } else {
        it.resume(Failure(NullPointerException()))
    }
}

Това не е най-добрият подход, най-вече защото извикваме execute() вместо enque(), принуждавайки заявката. Можем ли да направим това асинхронно?

Първо, съжалявам, че излъгах за частта „без обратни повиквания“. Тук ще се използва едно обратно извикване:

suspend fun <T : Any> getResult(call: Call<T>): Result<T> = suspendCoroutine {
    call.enqueue(object : Callback<T> {

        override fun onFailure(call: Call<T>?, error: Throwable?) = it.resume(Failure(error))

        override fun onResponse(call: Call<T>?, response: Response<T>?) {
            response?.body()?.run { it.resume(Success(this)) }
            response?.errorBody()?.run { it.resume(Failure(HttpException(response))) }
        }
    })
}

Добавихме страховитото обратно извикване, но то е далеч от полезрението и от ума. Ако използваме този API, никога няма да мислим за кода зад кулисите.

Някои неща за API трябва да се променят. Искам това да е разширение(вместо глобална функция) и само на T :Mappable ‹R› (за да знаем, че можем да го картографираме по-късно).

suspend fun <T : Mappable<R>, R : Any> Call<T>.getResult(): Result<T> = suspendCoroutine {
    enqueue(object : Callback<T> {
        override fun onFailure(call: Call<T>?, error: Throwable?) {
            it.resume(Failure(error))
        }

        override fun onResponse(call: Call<T>?, response: Response<T>?) {
            response?.body()?.run { it.resume(Success(this)) }
            response?.errorBody()?.run { it.resume(Failure(HttpException(response))) }
        }
    })
}

Сега е малко по-ясно какво използваме! :]

Големият финал

Ние напълно дефинирахме нашия API. Как изглежда една заявка?

suspend fun getUser(id: String): Result<User> = async {
    val result = tasksApiService.getUser(id).getResult()

    when (result) {
        is Success -> result.data.mapToResult()
        is Failure -> result
    }
}.await()

Това е слоят на взаимодействие. Имаме стил на последователен код, имаме синтактичен анализ и картографиране до точката и знаем кой резултат обработваме по-късно!

fun getUserProfile() = async {
    val result = backend.getUser("someId")

    when (result) {
        is Success -> showData(result.data)
        is Failure -> handleError(result.error)
    }
}

Ето го! Нашият ViewModel просто пита за потребителя (вътре в конструктор на сърутина) и го обработва по-късно. :]

Исках да отида малко по-далеч и да видя дали можем или не да приложим някои Rx-подобни оператори върху резултата. Така че измислих няколко спретнати функции за разширение, които може да ни помогнат в бъдеще:

inline fun <T : Any> Result<T>.onSuccess(action: (T) -> Unit): Result<T> {
    if (this is Success) action(data)

    return this
}

inline fun <T : Any> Result<T>.onError(action: (Throwable) -> Unit) {
    if (this is Failure && error != null) action(error)
}

inline fun <T : Any, R : Any> Result<T>.mapOnSuccess(map: (T) -> R) = when (this) {
    is Success -> Success(map(data))
    is Failure -> this
}

От този момент можем лесно да използваме нашия резултат по чист и плавен начин. Ако получим резултат, можем да го картографираме отново и отново към нещо по-използваемо в нашия бизнес слой.

result.mapOnSuccess { it.jobOffers }
        .mapOnSuccess { it[0] }
        .mapOnSuccess { it.id }

Така. Съпоставяне на списък с предложения за работа към идентификатора на първия елемент.

Или бихме могли просто да добавим манипулатори за успех и грешка без пот.

result.onSuccess { data -> showOffers(data.jobOffers) }
        .onError { error -> handleError(error) }

Да имаме хубав начин да изобразим какво искаме да направим с всеки случай.

Накратко

Видяхме как може да изглежда работата в мрежа на Android, когато използваме манипулаторите по подразбиране, и как може да се подобри, без да се губи функционалност или да се уврежда производителността/стабилността.

Също така успяхме да използваме функциите на езика, за да подобрим кода си, без да използваме библиотеки на трети страни. Така че като видите това, вие знаете, че всеки може да изгради хубав API за себе си, без много работа.

P.S. Приготвих това решение с помощта на моя екип за Android и с известна представа за това как други платформи се справят с мрежовия отговор. Едва след като изградихме всичко от нулата, потърсихме подобни решения, като това.

Това решение е наистина добро и се справя с повечето от нещата, за които говорихме тук. Една разлика е, че ние връщаме съпрограми, докато другото решение връща cancellableCoroutine и неговите случаи на обработка на грешки са разделени на два типа, докато ние използваме само един. Освен това имаме някои Rx-подобни оператори и функции, които използваме, за да ни помогнат да изчистим нашия код.

Ако имате някакъв съвет как да направим нашия API по-добър или как да разширим функционалността на нашия пример, моля, оставете коментар и нека го обсъдим. :]

Филип Бабич е разработчик на Android в COBE и студент по компютърни науки във FERIT, Осиек. Той е голям фен на Kotlin и от време на време организира мини семинари и срещи на Kotlin в Осиек. Той обича да научава нови неща, да играе DnD и да пише за нещата, които обича най-много. Когато не кодира, не пише за кодиране, не се учи да кодира или не учи другите как да кодират, той подхранва вътрешната си нервност, като играе и гледа фентъзи предавания.

Докато сте тук, проверете още статии от COBE: