Irbisa · Шпаргалка

Сеть в Android

Retrofit, OkHttp, интерцепторы, kotlinx.serialization, обработка ошибок

Middle-разработчик · 25 вопросов

  1. 01

    Что такое Retrofit и OkHttp и как они связаны?

    OkHttp представляет собой HTTP-клиент, который реально передаёт байты; Retrofit работает поверх него и превращает аннотированный Kotlin-интерфейс в типизированные вызовы, которые выполняет OkHttp.

    @Serializabledata class UserDto(val id: Long, val name: String) interface UserApi {  @GET("users/{id}")  suspend fun user(@Path("id") id: Long): UserDto…
  2. 02

    Что делают @Path, @Query, @Body, @Field, @Header и @Url в интерфейсе Retrofit?

    Они говорят Retrofit, куда в HTTP-запросе попадает каждый аргумент: в путь URL, в query-строку, в тело, в поле формы или в заголовок, а @Url подменяет URL целиком.

    interface ShopApi {  @GET("products/{id}")  suspend fun product(@Path("id") id: Long): ProductDto   @GET("products")  suspend fun search(…
  3. 03

    execute(), enqueue() или suspend-функция в Retrofit: на каком потоке работает каждый вариант и нужен ли suspend-вызову withContext(Dispatchers.IO)?

    execute() блокирует вызывающий поток, enqueue() выполняет вызов на пуле потоков OkHttp и зовёт колбэк, а suspend-функция внутри использует enqueue(), поэтому она main-safe, и withContext(Dispatchers.IO) ей не нужен.

    interface UserApi {  @GET("users/{id}")  suspend fun user(@Path("id") id: Long): UserDto            // suspends, never blocks   @GET("users/{id}")  fun userCall(@Path("id") id: Long): Call<UserDto>          // you choose execute or enqueue…
  4. 04

    Приложение падает или не работает на первом же сетевом вызове: какие обычные причины?

    Четыре причины покрывают почти все случаи: вызов идёт на главном потоке, нет разрешения INTERNET, URL на обычном http при заблокированном cleartext-трафике или localhost указывает на сам эмулятор.

    <!-- AndroidManifest.xml --><uses-permission android:name="android.permission.INTERNET" /> <!-- src/debug/res/xml/network_security_config.xml: debug builds only --><network-security-config>  <domain-config cleartextTrafficPermitted="true">…
  5. 05

    Как приложение узнаёт, что оно офлайн, и почему инстинкт проверять сеть перед каждым запросом неверен?

    Состояние сети наблюдают через network callback у ConnectivityManager, но запрос никогда им не ограничивают: делайте вызов и считайте IOException сигналом офлайна.

    fun ConnectivityManager.isOnlineFlow(): Flow<Boolean> = callbackFlow {  val callback = object : ConnectivityManager.NetworkCallback() {    override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {      trySend(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))    }    override fun onLost(network: Network) { trySend(false) }…
  6. 06

    Что меняется для Android-клиента между HTTP/1.1, HTTP/2 и HTTP/3 и какие из них поддерживает OkHttp?

    HTTP/1.1 передаёт по одному запросу за раз на соединение, HTTP/2 мультиплексирует много стримов в одном TLS-соединении, а HTTP/3 делает то же поверх QUIC на UDP; OkHttp поддерживает HTTP/1.1 и HTTP/2, но не HTTP/3.

  7. 07

    Что возвращает retrofit.create(), если вы не писали реализацию, и что делают конвертеры и call adapter-ы?

    Он возвращает java.lang.reflect.Proxy, реализующий ваш интерфейс: каждый вызов метода попадает в один обработчик, который один раз разбирает аннотации метода, собирает запрос OkHttp и использует конвертер для тела и call adapter для возвращаемого типа.

    val retrofit = Retrofit.Builder()  .baseUrl("https://api.example.com/")  .addConverterFactory(ScalarsConverterFactory.create())   // first: plain String bodies  .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))  .validateEagerly(BuildConfig.DEBUG)                      // parse every method at create()  .build()…
  8. 08

    Найдите баг: каждый запрос из этого репозитория медленный, даже когда сам сервер отвечает за 20 мс.

    class UserRepository(private val context: Context, private val tokens: TokenStore) {  suspend fun user(id: Long): UserDto {    val client = OkHttpClient.Builder()      .cache(Cache(File(context.cacheDir, "http"), 10L * 1024 * 1024))      .addInterceptor(AuthInterceptor(tokens))      .build()    val api = Retrofit.Builder()      .baseUrl("https://api.example.com/")      .client(client)      .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))      .build()      .create<UserApi>()    return api.user(id)  }}

    Каждый вызов создаёт новые OkHttpClient и Retrofit, а вместе с ними новый пул соединений, пул потоков и кэш: соединения никогда не переиспользуются, каждый запрос платит за новое TCP- и TLS-рукопожатие, а простаивающие сокеты и потоки копятся.

  9. 09

    При такой настройке какой URL запрашивает каждый вызов и почему users() получает 404?

    val retrofit = Retrofit.Builder()  .baseUrl("https://api.example.com/v2/")  .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))  .build() interface UserApi {  @GET("/users")  suspend fun users(): List<UserDto>   @GET("users/{id}")  suspend fun user(@Path("id") id: String): UserDto}

    Retrofit разрешает путь из аннотации относительно baseUrl так же, как браузер разрешает ссылку: ведущий / означает «от корня хоста», поэтому @GET("/users") идёт на https://api.example.com/users и теряет /v2.

  10. 10

    Почему в Kotlin kotlinx.serialization предпочитают Gson и как настроить его для API, которое вы не контролируете?

    Он генерирует сериализаторы во время компиляции, поэтому уважает nullability и значения по умолчанию из Kotlin, не требует рефлексии и keep-правил R8 и работает в мультиплатформенном коде; для чужого API экземпляр Json делают терпимым к лишним, отсутствующим и неожиданным значениям.

    val json = Json {  ignoreUnknownKeys = true      // the backend will add fields  coerceInputValues = true      // null or an unknown enum value -> the default} @Serializable…
  11. 11

    Чем отличаются application- и network-интерсепторы в OkHttp и куда класть логирование и общие заголовки?

    Оба реализуют Interceptor, но application-интерсептор выполняется один раз на вызов и видит логический запрос, а network-интерсептор выполняется на каждый реальный round trip, включая каждый редирект и повтор, и видит запрос в том виде, в каком он уходит в сеть.

    class HeadersInterceptor(private val tokens: TokenStore) : Interceptor {  override fun intercept(chain: Interceptor.Chain): Response {    val request = chain.request().newBuilder()      .header("Accept-Language", Locale.getDefault().toLanguageTag())      .apply { tokens.accessToken?.let { header("Authorization", "Bearer $it") } }      .build()…
  12. 12

    После добавления этого интерсептора каждый вызов падает с IllegalStateException: closed. Почему и как логировать тело правильно?

    class BodyLogger : Interceptor {  override fun intercept(chain: Interceptor.Chain): Response {    val response = chain.proceed(chain.request())    Log.d("api", response.body?.string().orEmpty())    return response  }} val client = OkHttpClient.Builder()  .addInterceptor(BodyLogger())  .build()

    ResponseBody представляет собой одноразовый поток поверх открытого соединения: string() читает его до конца и закрывает, поэтому когда конвертер Retrofit потом читает то же тело, он находит закрытый поток.

  13. 13

    Найдите баг: сервер отвечает 401 на каждый вызов, хотя этот интерсептор добавляет access-токен.

    class AuthInterceptor(private val tokens: TokenStore) : Interceptor {  override fun intercept(chain: Interceptor.Chain): Response {    val request = chain.request()    val token = tokens.accessToken()    if (token != null) {      request.newBuilder()        .header("Authorization", "Bearer $token")        .build()    }    return chain.proceed(request)  }} val client = OkHttpClient.Builder()  .addInterceptor(AuthInterceptor(tokens))  .build()

    Request неизменяем: newBuilder()…build() возвращает новый запрос с заголовком, но код его выбрасывает и передаёт дальше исходный, поэтому ни один вызов не несёт Authorization, и сервер отвечает 401.

  14. 14

    Как в Retrofit-репозитории отличить сетевые сбои, HTTP-ошибки и ошибки парсинга?

    Бросающий suspend-вызов падает тремя разными способами: IOException (нет соединения, таймаут), HttpException (сервер ответил не 2xx) и SerializationException (тело не совпало с моделью), и репозиторий один раз превращает каждый в доменную ошибку.

    sealed interface AppError {  data object Offline : AppError  data object Unauthorized : AppError  data class Server(val code: Int, val body: String?) : AppError  data class Contract(val cause: Throwable) : AppError}…
  15. 15

    Пользователь уходит с экрана, пока suspend-вызов Retrofit в полёте: что происходит с HTTP-вызовом и откатывается ли отменённый POST?

    fun load(id: Long) {  viewModelScope.launch {    try {      val user = repo.user(id)            // a Retrofit suspend call inside      _state.value = UiState.Loaded(user)    } catch (e: Exception) {      _state.value = UiState.Failed(e.message)      analytics.logError("user_load_failed", e)    }  }}

    viewModelScope отменяется, Retrofit вызывает call.cancel(), OkHttp закрывает сокет (HTTP/1.1) или сбрасывает стрим (HTTP/2), а корутина получает CancellationException, но сервер мог уже обработать запрос.

  16. 16

    Какие таймауты есть в OkHttp, что он повторяет сам и как добавить свой retry?

    В OkHttp есть таймауты connect, read, write и call; сам он повторяет только неудачную попытку соединения (другой маршрут или новое соединение) и следует редиректам, а повторять запрос, который сервер мог уже обработать, решаете вы.

    val client = OkHttpClient.Builder()  .connectTimeout(10, TimeUnit.SECONDS)  .readTimeout(15, TimeUnit.SECONDS)      // inactivity between bytes  .callTimeout(30, TimeUnit.SECONDS)      // the only bound on the whole call  .build()…
  17. 17

    Как обновить истёкший access-токен и повторить упавший вызов: через Authenticator или интерсептор?

    Используйте Authenticator из OkHttp: он вызывается только когда сервер ответил 401, возвращает запрос для повтора с новым токеном (или null, чтобы сдаться), и OkHttp сам его повторяет; обновление, спрятанное в интерсепторе, легко зацикливается или устраивает гонку.

    class TokenAuthenticator(private val session: Session) : Authenticator {  override fun authenticate(route: Route?, response: Response): Request? {    if (response.priorResponse?.code == 401) return null      // the retry failed too: stop    val sent = response.request.header("Authorization")?.removePrefix("Bearer ")    val fresh = runBlocking { session.refreshIfStale(sent) } ?: return null    return response.request.newBuilder().header("Authorization", "Bearer $fresh").build()…
  18. 18

    Как работает HTTP-кэширование в OkHttp и может ли оно отдавать данные, когда устройство офлайн?

    В OkHttp есть дисковый кэш, который включают явно (Cache(dir, maxSize) на клиенте); он следует стандартному HTTP-кэшированию (Cache-Control, ревалидация через ETag) и отдаёт устаревшие ответы офлайн только для запросов с only-if-cached.

    val client = OkHttpClient.Builder()  .cache(Cache(File(context.cacheDir, "http"), 50L * 1024 * 1024))   // 50 MB  .addNetworkInterceptor { chain ->    // the server sends no Cache-Control: allow 5 minutes of reuse    chain.proceed(chain.request()).newBuilder()      .header("Cache-Control", "public, max-age=300")…
  19. 19

    Как загрузить файл через Retrofit и как показать прогресс загрузки?

    Пометьте метод @Multipart и передайте файл как MultipartBody.Part, собранный из потокового RequestBody; прогресс получают, оборачивая это тело и считая байты по мере того, как OkHttp их пишет.

    interface UploadApi {  @Multipart  @POST("avatar")  suspend fun upload(@Part("caption") caption: RequestBody, @Part file: MultipartBody.Part)}…
  20. 20

    Список картинок из сети тормозит при прокрутке, а память растёт: что за вас делает загрузчик вроде Coil и что остаётся на вас?

    Загрузчик изображений качает их не на главном потоке, декодирует bitmap под отображаемый размер, а не под исходный, держит кэши в памяти и на диске и отменяет запрос, когда элемент уходит с экрана.

    // Built on the same OkHttp stack: auth, connection poolval imageLoader = ImageLoader.Builder(context)  .components { add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) }  .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.25).build() }  .diskCache {    DiskCache.Builder().directory(context.cacheDir.resolve("images").toOkioPath()).build()…
  21. 21

    Пагинация по offset или по курсору: что выбрать и как Paging 3 с RemoteMediator загружает страницы?

    Пагинация по курсору считается безопасным выбором по умолчанию для меняющейся ленты, потому что offset-страницы сдвигаются при вставке строк; Paging 3 загружает страницы через PagingSource, а RemoteMediator записывает страницы из сети в Room, чтобы список работал и офлайн.

    class PostsPagingSource(private val api: PostsApi) : PagingSource<String, Post>() {  override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> = try {    val page = api.posts(after = params.key, limit = params.loadSize)    LoadResult.Page(data = page.items, prevKey = null, nextKey = page.nextCursor)  } catch (e: IOException) {    LoadResult.Error(e)…
  22. 22

    Как тестировать сетевой слой без настоящего сервера?

    Запускайте Retrofit против MockWebServer (настоящего HTTP-сервера на localhost), чтобы реально отработали OkHttp, ваши интерсепторы, конвертер и маппинг ошибок, и проверяйте и запрос, который отправил клиент, и то, как он обработал заготовленный ответ.

    class UserApiTest {  private val server = MockWebServer()  private lateinit var api: UserApi   @Before fun setUp() {    server.start()…
  23. 23

    REST, GraphQL или gRPC для мобильного клиента: в чём компромиссы?

    REST служит простым и дружелюбным к кэшу вариантом по умолчанию; GraphQL позволяет экрану получить ровно нужные поля за один round trip; gRPC даёт строгие контракты на Protobuf, компактные бинарные сообщения и стриминг ценой инструментов и потери HTTP-кэширования.

  24. 24

    WebSocket, Server-Sent Events, long polling или push: как выбрать транспорт реального времени для Android-приложения?

    WebSocket подходит для двустороннего обмена вроде чата, SSE нужен для одностороннего потока с сервера, long polling годится только как запасной вариант, а FCM берут для всего, что должно дойти до устройства, пока приложение не запущено.

    // Socket as a Flow: opened on collect, closed when the last collector goes awayfun OkHttpClient.messages(url: String): Flow<String> = callbackFlow {  val socket = newWebSocket(Request.Builder().url(url).build(), object : WebSocketListener() {    override fun onMessage(webSocket: WebSocket, text: String) { trySend(text) }    override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {      webSocket.close(code, null)   // answer the server's close, or onClosed never fires…
  25. 25

    Загрузка файла на 400 МБ на части телефонов падает по памяти и начинается с нуля при каждом обрыве соединения: как писать её потоком на диск и докачивать?

    @Streaming не даёт Retrofit буферизовать всё тело в памяти, а запрос с Range, начинающийся с размера частичного файла на диске, позволяет продолжить загрузку после обрыва соединения.

    interface FilesApi {  @Streaming                                   // live stream, no buffering  @GET("files/{name}")  suspend fun download(    @Path("name") name: String,    @Header("Range") range: String?,           // null headers are omitted…