
Всем привет, это Полина Широбокова, 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") } }
Получаем следующий результат:

Внешне на 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() — пишите в соцсети нашей компании или прямо здесь в комментариях. Предложение рабочего решения — пропуск технического собеседования в компанию :)
