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

Комментарии (15)


  1. LeonidBelyakov
    24.11.2021 18:36
    +1

    Отличный ресерч!

    Единственное, немного смущает формулировка: «Однако на самом деле с IO запрос выполнился быстрее в 10.92 раз!»

    Ведь время выполнения самого сетевого запроса не сильно изменилось. Изменилось лишь время от запуска корутины до следующего действия на ui (hideKeyboard())


    1. pshirobokova Автор
      25.11.2021 17:35

      Спасибо!) Да, эта формулировка не совсем корректная, в статье идет речь про время до начала сетевого запроса. Поправлю


  1. alaershov
    25.11.2021 08:56

    А вы случайно не измеряете время создания экземпляра Retrofit и вашего ApiService? Функция provideApiService() уж очень подозрительно выглядит, и конечно, если на каждый запрос создавать новый инстанс ApiService, будет медленно.

    Другими словами, удалось ли понять, за счёт чего именно вы добились ускорения? Шаги "заметить проблему" и "решить проблему" были, а промежуточного "понять, где тормоза" я не заметил :)


    1. pshirobokova Автор
      25.11.2021 17:35

      Конечно, на реальных проектах я не создаю новый объект ApiService на каждый запрос.

      Здесь для демо-проекта добавила такую реализацию, т.к. задача была в том числе показать, что есть проблемы со скоростью первого запроса.

      Описанное поведение с задержкой было замечено именно на боевом проекте в первую очередь


      1. alaershov
        26.11.2021 11:50

        Тогда расскажите, что именно было медленным, какая именно часть кода внутри вызова provideApiService().getSomethingFromApi() исполнялась на главном потоке?


        1. pshirobokova Автор
          26.11.2021 15:42

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


          1. alaershov
            26.11.2021 15:49

            Точно, я подумал что это с другого проекта результаты, невнимательно прочитал) Спасибо!


  1. gmk57
    25.11.2021 09:36
    +3

    Спасибо за любопытную и немного провокационную статью. :)

    Время в 500+ миллисекунд меня сильно удивило, да честно говоря и 50 мс после исправления - это неприлично много.

    Очень здорово, что вы выложили тестовый проект. Без него вряд ли удалось бы воспроизвести результаты. Погонял его на стареньком (6 лет, Snapdragon 410) аппарате, и вот что получилось (время в формате "первый запуск / последующие запуски"):

    getMainThreadResult 620 / 18 мс
    getRetrofitFixResult 57 / 8 мс
    getWithContextResult 12 / 2 мс

    Основную часть времени занимает создание reflection-based JSON-адаптера:

    Замена его на kotlin-codegen дает ускорение в разы:
    getMainThreadResult 92 / 11 мс
    getRetrofitFixResult 63 / 8 мс (тут нет ускорения, разрыв сильно сокращается)

    Но в принципе вызывать Retrofit.Builder()....create(ApiService::class.java) каждый раз по нажатию кнопки - сомнительная идея, обычно это делается единожды при старте приложения.

    Такой рефакторинг ускоряет getMainThreadResult еще в разы: 25 / 3 мс

    Из этих 25 мс основная часть уходит на создание CatFactResponseJsonAdapter (один раз при первом вызове любого метода, возвращающего CatFactResponse).

    3 мс - это главное время, от которого имеет смысл отталкиваться. getRetrofitFixResult и getWithContextResult по-прежнему чуть быстрее (1-3 мс), но стоит ли это мучений с обертками над обертками? ;) Простые архитектурные изменения (см. выше) дают гораздо больший эффект.

    Цифры выше можно рассматривать как worst-case scenario: старый аппарат, debug build без AOT-компиляции и прочих рекомендуемых хитростей.

    Мораль: профайлер - наше всё. ;)


    1. pshirobokova Автор
      25.11.2021 17:37

      Большое спасибо за такой анализ!)

      Замечания справедливые, но к сожалению не у всех есть кодген на проектах. И даже знаю пару разрабов, которые кодогенерацию не любят) Насчет вытеснения инстанса ApiService: это не решает проблему замедления первого запроса


      1. gmk57
        25.11.2021 18:32

        Сочувствую. Reflection - это боль и по производительности, и в сочетании с R8, а kotlin-reflect еще и здоровенная либа в рантайме.

        Вы пробовали закинуть issue в Retrofit? Сходу не нашел. Логично было бы создавать адаптеры в Dispatchers.IO, но возможно у них были причины так не делать.


        1. pshirobokova Автор
          26.11.2021 15:37

          Пока что не создали issue


  1. konstantinivanov29
    25.11.2021 11:25

    Применил решение описанное в статье в рабочем коде и в сложной цепочке flatMapMerge()... .toList() получаю бесконечное ожидание, меняю решение из статьи на обычный заворот в корутин диспатчер, все ок.


    1. pshirobokova Автор
      25.11.2021 17:38

      Спасибо за замечание, видимо это еще один кейс в копилку нерабочих вместе с async/await, описанных в статье


  1. MaksimNovikov
    30.11.2021 12:00

    Не рассматривали вариант просто вернуться к адаптору от Вортана?


    1. pshirobokova Автор
      01.12.2021 10:02

      Мы исправили наш фикс в том числе и для работы с async{}/await(). Скоро опубликуем его. Пока взяли паузу и ждем решений от желающих миновать тех. собеседование, как описано в конце статьи)