После сборки — доработать напильником. Фиксим Retrofit для Корутин

от автора

Всем привет, это Полина Широбокова, android-разработчик в компании 65apps. При выходе Retrofit версии 2.6.0 нам озвучили официальную поддержку корутин, а значит — теоретически больше не было необходимости использовать специальный адаптер для вызова suspend-функций, у разработчиков появилась возможность обращения к API «из коробки». 

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

(Не совсем) асинхронные запросы в сеть

В changelog-е корутиновского обновления Retrofit указано, что под капотом suspend-функции обрабатываются как обычные, к примеру, fun user(…): Call<User>, с вызовом Call.enqueue()

В свою очередь, метод enqueue() выполняет асинхронный запрос в сеть, обращаясь к Dispatchers от OkHttp. Можно предположить, что отправляя запрос с использованием корутин, заботиться о том, чтобы главный поток не блокировался — больше не нужно, все уже написано за нас.

Тем не менее, при разработке одного из проектов нашей компании однажды мы заметили небольшой фриз UI сразу после отправки запроса по сети:

* дизайн экрана проекта изменен 

Как видите, при нажатии на кнопку и отправке запроса, перед тем как “прибиться” к низу экрана вслед за скрытой клавиатурой, кнопка подтормаживает на прежнем месте. Лаг исчез, когда вызов suspend-функции API обернули в withContext(Dispatchers.IO).

Возникает вопрос: каким образом заявленный асинхронный enqueue() может нагружать главный поток приложения и вызывать подвисание UI?

Ответ: сами запросы, конечно, OkHttp отправляет в другом потоке. А вот подготовка данных перед тем, как отправить их на другой поток, занимает какое-то время. И эта подготовка по тем или иным причинам не вынесена в отдельный поток и происходит на главном. 

Какой из этого следует вывод? Клиентский код всё-таки должен обеспечить изначальную асинхронность, даже делая запросы через enqueue()

Но прежде чем помянуть добрым словом «те или иные причины», что оставили подготовку в главном потоке, неплохо бы проверить на простейшем чистом примере, что вообще происходит.  Соберем одноэкранное приложение без архитектуры и всего, на что можно сослаться как на причину подвисания интерфейса. Сверстаем аналогичный UI и обратимся к какому-нибудь открытому API, не переключая диспатчер.

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

Во-первых, создадим CoroutineScope с Unconfined диспатчером. Грубо говоря, этот диспатчер продолжает выполнение в том потоке, в котором отработала корутина — в нашем случае речь про MainThread:

private val scope = CoroutineScope(Dispatchers.Unconfined)

Далее в функции makeQuery() создадим корутину на созданном ранее скоупе и обратимся к API. Заодно в логах будем ожидать ответ, полученный с сервера и время, которое проработает корутина, через measureTimeMillis(): 

private fun makeQuery() {         val time = measureTimeMillis {             scope.launch {                 val result = provideApiService().getSomethingFromApi()                 Timber.tag("RetrofitIssueLogs").d("result = $result")             }         }         Timber.tag("RetrofitIssueLogs").d("time = $time")     }

Помимо этого вызовем скрытие клавиатуры и также выведем начало работы функции в логи (для скрытия используется самописная extension-функция, ее вы легко найдете в гугле)

private fun hideKeyboard() {         Timber.tag("RetrofitIssueLogs").d("start hideKeyBoard()")         binding.root.hideKeyboard()     }

И, наконец, вызовем makeQuery() и hideKeyboard() при нажатии на кнопку, и, конечно, не забудем про лог

binding.btnTap.setOnClickListener {              Timber.tag("RetrofitIssueLogs").d("button clicked")             makeQuery()             hideKeyboard()         }

Запускаем приложение, жмем на кнопку и смотрим:

С момента нажатия на кнопку до начала скрытия клавиатуры прошло 546 миллисекунд. Такую длительность вывела в переменную time функция measureTimeMillis(). Стоит упомянуть о том, что время выполнения может меняться на разных устройствах и с поправкой на периодически возникающие системные операции под капотом. В конечном итоге информация о времени нужна нам для сравнения результатов.

А теперь обернем вызов апи в withContext(Dispatchers.IO):

scope.launch {                 withContext(Dispatchers.IO) {                     val result = provideApiService().getSomethingFromApi()                     Timber.tag("RetrofitIssueLogs").d("result = $result")                 }             }

Получаем следующий результат:

Теперь выполнение запроса заняло 50 миллисекунд.
Теперь выполнение запроса заняло 50 миллисекунд.

Внешне на UI разница обращения к API с Dispatchers.IO и без него была незаметна. Однако на самом деле, с IO переход к следующей операции на UI произошел быстрее в 10.92 раз! 

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

Главный вывод из вышеизложенного: переключать диспатчер, работая с Retrofit, всё-таки нужно.

Давайте сформулируем задачу

Практически во всех туториалах по использованию корутин и Retrofit, методы сервисного интерфейса оборачиваются в уже упомянутую withContext(Dispatchers.IO). На некоторых наших проектах мы поступаем так же.

Но после обнаружения вышеупомянутого UI-фриза мы решили переосмыслить этот подход и вынесли несколько его минусов:

  • легко ошибиться, забыв обернуть suspend-функцию. Остается полагаться на code review, но было бы здорово автоматизировать переключение диспатчеров, сняв немного ответственности с ревьюеров.

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

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

Знакомьтесь, Retrofit-fix

Если есть возможность от бойлерплейта каким-либо способом избавиться — это стоит хотя бы попробовать сделать. Так и родилась идея написания Retrofit-фикса для корутин, который переключал бы за нас диспатчер один раз, снимая головную боль от сохранения вызовов в сеть в main-safe состоянии. 

RetrofitFix — это прокси-объект, позволяющий сразу установить необходимый диспатчер при реализации интерфейса вашего API с помощью Retrofit.

private typealias Invoke = suspend (method: Method, args: Array<*>) -> Any?  object RetrofitFix {       fun <T> create(retrofit: Retrofit, context: CoroutineContext, service: Class<T>): T {         val delegate = retrofit.create(service)          val invoke: Invoke = { method: Method, args: Array<*> ->             applyDispatcher(context) {                method.invoke(delegate, *args)             }         }          val invocationHandler = suspendInvocationHandler(invoke)          return Proxy.newProxyInstance(             service.classLoader,             arrayOf<Class<*>>(service),             invocationHandler         ) as T     }      private suspend inline fun applyDispatcher(         context: CoroutineContext,         crossinline method: () -> Any?     ) {         withContext(context) {             method()         }     }      private fun suspendInvocationHandler(block: Invoke): InvocationHandler {         return InvocationHandler { _, method, args ->             val cont = args?.lastOrNull() as? Continuation<*>             if (cont == null) {                 /* TBD                 Нерабочее решение:      val methodArgs = args.orEmpty()                 runBlocking {                     block(proxy, method, methodArgs)                 } */                 throw RuntimeException("Не используйте RetrofitFix при вызове не suspend функций")             } else {                 @Suppress("UNCHECKED_CAST")                 val suspendInvoker = block as (Method, Array<*>?, Continuation<*>) -> Any?                 suspendInvoker(method, args, cont)             }         }     } }

При создании объекта в параметры метода create() необходимо передать уже созданный объект Retrofit (Retrofit.Builder() … .build()), диспатчер, на котором вы хотите вызывать сетевые запросы и, наконец, ссылку на java class самого интерфейса API

fun provideApiServiceWithRetrofitFix() = RetrofitFix.create(provideRetrofit(), Dispatchers.IO, ApiService::class.java)

Наша задача состояла в том, чтобы вызывать все методы API на предопределенном диспатчере. Для этого мы использовали java.lang.reflect.Proxy, так как прокси-объект перехватывает вызовы методов интерфейсов, которые этот объект реализует, позволяя нам добавить новый функционал во время, собственно, вызовов этих самых методов.

Вернемся к реализации фикса. Нашему будущему прокси-объекту необходим InvocationHandler для добавления новой функциональности. Можно было бы просто создать объект InvocationHandler-а и в методе invoke() обернуть приходящий в прокси Method API в нужный диспатчер. Но поскольку всё-таки планируется, что фикс будет поддерживать и обычные, и suspend функции, из-за различий в контекстах их вызовов, реализацию мы разделили под каждый из двух случаев.

В функции suspendInvocationHandler() мы проверяем аргументы, пришедшие в Invoke, на наличие объекта типа Continuation

private fun suspendInvocationHandler(block: Invoke): InvocationHandler {         return InvocationHandler { proxy, method, args ->             val cont = args?.lastOrNull() as? Continuation<*>             if (cont == null) {                // TBD             } else { // code             }         }

Это сделано как раз для разделения вызовов suspend и не-suspend-функций. Как мы знаем, в Java корутин нет. При преобразовании кода из Kotlin в Java, в конец списка аргументов корутиновских suspend-функций добавляется еще один параметр — объект Continuation. Этот объект позволяет “продолжить” выполнение корутины по возвращении из suspend-функции. На StartAndroid есть базовое описание принципов работы Continuation на русском языке. Погрузиться в детали реализации каждый может самостоятельно, нам же важно отметить, что Continuation служит своеобразным маркером suspend-функции. Поэтому, чтобы корректно обработать вызов апи, для начала определяем, какую функцию мы получили.

В хендлере мы используем переменную block — аргумент типа Invoke, который сами и создали:

private typealias Invoke = suspend (method: Method, args: Array<*>) -> Any?

Invoke — это typealias для suspend-функционального типа, который принимает Method (его мы получим в хендлере) и аргументы метода — args.

В методе create(), при создании объекта фикса, мы присваиваем объекту новоиспеченного типа Invoke, по сути, “выполнение” будущего метода (Method) у прокси-объекта, обернув вызов метода в диспатчер.

val delegate = retrofit.create(service)          val invoke: Invoke = { method: Method, args: Array<*> ->             applyDispatcher(context) {                 @Suppress("SpreadOperator")                 method.invoke(delegate, *args)             }         }

А вот и функция обертка — applyDispatcher(). Она простая, но выполняет главную задачу фикса:

 private suspend inline fun applyDispatcher(         context: CoroutineContext, // диспатчер, который мы передаем в create() при инициализации RetrofitFix          crossinline method: () -> Any?     ) {         withContext(context) { // просто оборачиваем полученную функцию в диспатчер             method()         }     }

Вернемся к Continuation и разделению вызовов функций в хендлере. Поскольку suspend-функцию нельзя вызвать из обычной функции, мы расширяем наш тип Invoke, и кастим его к не-suspend функциональному типу, передавая Continuation, как “провод”, по которому suspend-обертка сможет вернуть результат своего выполнения обратно к месту вызова API.

val suspendInvoker = block as (Method, Array<*>?, Continuation<*>) -> Any?                 suspendInvoker(method, args, cont)

Вы можете заметить, что аргументы нашего функционального типа Invoke практически идентичны аргументам, приходящим в функцию invoke() в InvocationHandler. Не хватает только proxy: Any! в начале. Более того, выше в method.invoke() мы передаем не прокси, чей метод, по идее, будет вызван, а заново создаем объект сетевого интерфейса через retrofit.create(service) и используем его. 

Дело в том, что Retrofit при создании объекта интерфейса API сам создает прокси, при этом выполняя свои дополнительные операции (можете посмотреть в исходниках библиотеки). Поэтому, если бы при обращении к API внутри invoke() мы оборачивали в диспатчер  метод нами же созданного прокси, мы бы не имели никакой связи с Retrofit. 

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

Прокси над прокси — к сожалению, в текущей версии Retrofit не предусмотрена возможность расширения функционала через прокидывание диспатчеров.

Остается дело за малым: создаем InvocationHandler, передавая ему диспатчер-обертку Invoke

val invocationHandler = suspendInvocationHandler(invoke)

и возвращаем из метода create() заветную прокси, передавая в инстанс-метод интерфейс API и ранее переопределенный InvocationHandler.

return Proxy.newProxyInstance(             service.classLoader,             arrayOf<Class<*>>(service),             invocationHandler         ) as T

Подведем итоги

Retrofit версии 2.6.0 предоставляет поддержку корутин, однако у существующего решения «из коробки» есть существенный недостаток — необходимо принудительно уходить с main-thread’а в клиентском коде, а разработчики по-прежнему вынуждены писать бойлерплейт в виде переключения корутиновских диспатчеров при запросах в сеть (например, с использованием withContext(Dispatchers.IO)). 

Retrofit-fix позволяет забыть о переключении диспатчеров, обращаясь к API.

Важное уточнение: мы хотим, чтобы фикс работал как с suspend-методами API, так и с обычными. К сожалению, пока удалось реализовать поддержку только suspend-функций и только при вызове их из launch() билдера. Если у вас будут идеи как можно исправить это и реализовать поддержку работы с async()/await()  — пишите в соцсети нашей компании или прямо здесь в комментариях. Предложение рабочего решения — пропуск технического собеседования в компанию 🙂


ссылка на оригинал статьи https://habr.com/ru/articles/590037/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *