Всем привет, это Полина Широбокова, 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 миллисекунд.

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