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