Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.
Вопросы и ответы
10 подробных ответов
01Из чего состоит стек Retrofit + OkHttp и за что отвечает каждый слой?
junior
Короткий ответ: Retrofit — верхний, декларативный слой: превращает Kotlin-интерфейс с аннотациями в HTTP-запросы и парсит ответ через Converter. OkHttp под ним — сам HTTP-клиент: сокеты, пул соединений, кэш, интерцепторы, таймауты, повторы. Retrofit без OkHttp не работает — это его транспорт.
Подробно:
┌─────────────────────────────────────────┐
│ ApiService (interface + @GET/@POST) │ ← ваш код
├─────────────────────────────────────────┤
│ Retrofit: аннотации → Request, │
│ Converter (JSON ↔ data class) │
├─────────────────────────────────────────┤
│ OkHttp: interceptors, connection pool, │
│ cache, timeouts, TLS, retry │
├─────────────────────────────────────────┤
│ Socket / сеть │
└─────────────────────────────────────────┘
- Retrofit — знает про
suspendи возвращает готовый data class; сам JSON не парсит. - Converter — kotlinx.serialization / Moshi / Gson; именно он делает JSON ↔ объект.
- OkHttp — вся сетевая механика; один
OkHttpClientна приложение, чтобы пул и кэш переиспользовались. - CallAdapter — поддержка
suspend-функций из коробки.
⚠️ Частая ошибка: создавать новый OkHttpClient/Retrofit на каждый запрос. Тогда теряются пул соединений и кэш; клиент должен быть синглтоном на всё приложение.
03Из каких частей состоит Room и почему DAO-методы делают suspend или возвращают Flow?
junior
Короткий ответ: Три кита: @Entity (таблица), @Dao (интерфейс запросов), @Database (держит связки и версию). suspend-метод уводит запрос с главного потока (main-safety), а возврат Flow превращает запрос в реактивный стрим — Room сам переэмитит данные при изменении таблицы.
Подробно:
@Entity(tableName = "user")
data class User(@PrimaryKey val id: Long, val name: String)
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE id = :id")
suspend fun getById(id: Long): User? // разовое чтение, вне main
@Query("SELECT * FROM user")
fun observeAll(): Flow<List<User>> // реактивный стрим
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(user: User)
}
- Room генерит реализацию DAO (KSP/kapt) — сам пишет биндинг параметров и маппинг курсора в объект.
- suspend DAO — Room выполняет запрос на своём Executor, а не на main; отдельный
withContext(IO)не нужен. - Flow-запрос — Room следит за таблицами через
InvalidationTrackerи заново эмитит при любом изменении. - Compile-time проверка —
@Queryвалидируется при сборке: опечатка в SQL = ошибка компиляции, а не краш в рантайме.
⚠️ Частая ошибка: сделать не-suspend DAO-метод, возвращающий List, и вызвать его на главном потоке. Room бросит IllegalStateException — «Cannot access database on the main thread».
04Application- и network-интерцепторы OkHttp: в чём разница и как правильно обновлять токен?
middle
Короткий ответ: Application-интерцептор вызывается один раз на логический вызов (над кэшем и редиректами), видит исходный Request и финальный Response — сюда логи и добавление заголовков. Network-интерцептор вызывается на каждый реальный сетевой хоп (редирект/повтор), видит «сырой» ответ — сюда кэш-манипуляции. Токен обновляет не интерцептор, а Authenticator: он реагирует на 401 и повторяет запрос с новым токеном.
Подробно:
App ─► [application interceptors] ─► cache ─► [network interceptors] ─► server
видит финальный ответ, вызывается на КАЖДЫЙ
1 раз на вызов редирект / retry
val client = OkHttpClient.Builder()
.addInterceptor(authHeaderInterceptor) // application: кладёт токен
.addNetworkInterceptor(loggingInterceptor)
.authenticator { _, response -> // срабатывает на 401
val fresh = tokenStore.refreshBlocking() ?: return@authenticator null
response.request.newBuilder()
.header("Authorization", "Bearer $fresh")
.build()
}
.build()
- Application — не видит редиректы/повторы, зато гарантированно вызывается даже при ответе из кэша; идеален для auth-заголовка и логов.
- Network — вызывается только при реальном выходе в сеть; видит
Content-Encoding, редиректы; для кэша и низкоуровневых правок. - Authenticator — реактивный: OkHttp сам зовёт его на 401 и повторяет запрос; вернуть
null= сдаться. Синхронизируйте refresh (mutex), иначе десять параллельных 401 запустят десять refresh.
⚠️ Частая ошибка: обновлять токен в интерцепторе, вручную проверяя if (code == 401). Это дублирует то, что Authenticator делает штатно (с ограничением попыток), и легко зацикливается на повторных 401.
05kotlinx.serialization, Moshi или Gson — что выбрать и где ловушки с Kotlin?
middle
Короткий ответ: kotlinx.serialization — Kotlin-native, codegen на compile-time (без рефлексии), знает про nullability и default-значения; дефолт для нового кода. Moshi — codegen-адаптеры (KSP), тоже уважает non-null; хорош для Java/Kotlin-микса. Gson — рефлексия и легаси: игнорирует Kotlin-типы, может засунуть null в non-null поле и обойти конструктор.
Подробно:
| kotlinx.serialization | Moshi (codegen) | Gson | |
|---|---|---|---|
| Механизм | codegen (compiler plugin) | codegen (KSP) | рефлексия |
| Kotlin null / defaults | уважает | уважает | игнорирует |
| Статус | активный, дефолт | активный | фактически заморожен |
- Проблема Gson — через рефлексию он создаёт объект в обход конструктора (
Unsafe), поэтому default-значения не применяются, а отсутствующее в JSON non-null поле остаётсяnull→ NPE позже, вдалеке от места парсинга. - kotlinx.serialization —
@Serializable+ плагин;@SerialNameдля маппинга имён;explicitNulls,coerceInputValuesнастраиваются. - Moshi —
@JsonClass(generateAdapter = true); рефлексивный режим тоже есть, но codegen быстрее и безопаснее.
⚠️ Частая ошибка: держать Gson и полагаться на дефолты в data class. Отсутствующее в JSON поле не запустит инициализатор — val page: Int = 1 окажется 0, а non-null String станет null. kotlinx/Moshi подставят дефолт или честно упадут.
06Как обрабатывать ошибки сетевых вызовов: sealed-результат, ретраи и backoff?
middle
Короткий ответ: Оборачивайте вызов в sealed-класс Result (Success/Error), а не разбрасывайте try/catch по UI. Различайте типы ошибок (нет сети / HTTP-код / парсинг), ретраьте только временные (IOException, 5xx, 429) с экспоненциальным backoff и джиттером, а 4xx (кроме 429) не ретраьте — повтором они не чинятся.
Подробно:
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Error(val cause: Throwable, val code: Int? = null) : ApiResult<Nothing>
}
suspend fun <T> safeCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Success(block())
} catch (e: CancellationException) {
throw e // отмену НЕ глотаем
} catch (e: HttpException) {
ApiResult.Error(e, e.code())
} catch (e: IOException) {
ApiResult.Error(e) // нет сети / таймаут
}
suspend fun <T> retrying(times: Int = 3, block: suspend () -> T): T {
var delayMs = 500L
repeat(times - 1) {
try { return block() } catch (e: IOException) { /* транзиент */ }
delay(delayMs + Random.nextLong(200)) // backoff + jitter
delayMs *= 2
}
return block()
}
- Классифицируйте — no-network (IOException), HTTP (4xx/5xx), сериализация. UI реагирует по-разному: кнопка «повторить» vs «проверьте ввод».
- Ретраить — 5xx, 429, таймауты; НЕ ретраить 400/401/404. Джиттер спасает от синхронного шторма клиентов, бьющих сервер в такт.
- CancellationException — всегда перевыбрасывать первым
catch, иначеcatch (e: Exception)сломает отмену корутины.
⚠️ Частая ошибка: catch (e: Exception) вокруг всего вызова — глотает и CancellationException (ломая structured concurrency), и баги в коде, маскируя их под «ошибку сети».
07Миграции Room, @Transaction и индексы: что важно не сломать?
middle
Короткий ответ: При изменении схемы поднимаете version и даёте Migration с ALTER TABLE — иначе Room упадёт с IllegalStateException. fallbackToDestructiveMigration — аварийный выход: дропает и пересоздаёт таблицы, стирая данные пользователя. @Transaction нужен там, где несколько запросов должны быть атомарны — в том числе для @Relation-выборок.
Подробно:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE user ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
Room.databaseBuilder(ctx, AppDb::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
// .fallbackToDestructiveMigration(dropAllTables = true) // ⚠️ стирает данные
.build()
@Transaction
@Query("SELECT * FROM user")
fun usersWithPosts(): Flow<List<UserWithPosts>> // @Relation → 2 запроса, атомарно
- Тестируйте миграции — Room экспортирует JSON-схему (
exportSchema = true), аMigrationTestHelperпрогоняет реальную миграцию на старой БД. - fallbackToDestructiveMigration — только для dev / кэш-БД. В Room 2.6+ параметр
dropAllTablesтеперь явный — чтобы вы осознанно соглашались на потерю данных. - @Transaction —
@Relationделает несколько SELECT; без транзакции между ними данные могут измениться → рассинхрон.@Transactionдаёт консистентный снимок. - @Index — на колонки в WHERE/JOIN/foreign key; ускоряет чтение ценой чуть более медленной записи.
⚠️ Частая ошибка: положиться на fallbackToDestructiveMigration в проде «чтобы не писать миграции». При первом же изменении схемы пользователи молча теряют все локальные данные.
08Как устроен offline-first и принцип единого источника правды (single source of truth)?
middle
Короткий ответ: UI читает только из локальной БД (Room) — она единственный источник правды. Сеть не рисует экран напрямую, а лишь пишет свежие данные в БД; репозиторий отдаёт Flow из Room, а синхронизация (по требованию или через WorkManager) подтягивает данные и кладёт их в ту же БД. Экран работает всегда, даже без сети.
Подробно:
┌───────── network fetch ─────────┐
▼ │
[ Remote API ] ──► write ──► [ Room ] ──► Flow ──► UI
▲
единый источник правды
fun observeUser(id: Long): Flow<Resource<User>> = flow {
emit(Resource.Loading)
try {
val remote = api.fetchUser(id)
dao.upsert(remote.toEntity()) // пишем в БД — Flow сам переэмитит
} catch (e: IOException) { /* остаёмся на кэше */ }
emitAll(dao.observe(id).map { Resource.Success(it) })
}
- Single source of truth — экран подписан на Room; любое изменение (из сети, с другого экрана) приходит одним путём, через БД. Двух конкурирующих состояний не бывает.
- Запись в БД — единственный способ «показать» новые данные; UI не знает, откуда они пришли.
- Синхронизация — pull по требованию или периодически через WorkManager (с constraints на сеть); конфликты решает стратегия (last-write-wins, версии).
- Paging 3 + RemoteMediator — тот же принцип для постраничных списков: сеть наполняет БД,
PagingSourceчитает из БД.
⚠️ Частая ошибка: показывать ответ сети прямо в UI, а в БД писать «заодно». После поворота или перезахода экран прочитает БД — и данные мигнут или разойдутся: источник правды раздвоился.
09HTTP-кэш OkHttp (ETag, Cache-Control) против кэша в БД — когда что?
senior
Короткий ответ: HTTP-кэш OkHttp работает на уровне ответов и управляется сервером через Cache-Control/ETag: экономит трафик (304 Not Modified) и прозрачен, но не даёт запросов, связей и офлайн-логики. БД-кэш (Room) — источник правды приложения: SQL-запросы, @Relation, реактивность, полноценный офлайн. Для offline-first нужен именно БД-кэш; HTTP-кэш — оптимизация поверх.
Подробно:
GET /user/42
▼
OkHttp cache? ──hit, свежий──► вернуть из кэша (0 сети)
│ stale + есть ETag
▼
GET ... If-None-Match: "abc" ──► 304 Not Modified ──► отдать кэш, продлить срок
└─► 200 + тело ──────────► записать в кэш
| HTTP-кэш (OkHttp) | БД-кэш (Room) | |
|---|---|---|
| Уровень | сырой HTTP-ответ | доменные сущности |
| Управление | сервер (Cache-Control) |
приложение |
| Запросы / связи | нет | да (SQL, @Relation) |
| Offline-first | ограничен | да |
- OkHttp cache — включается через
Cache(dir, size)в клиенте; работает только для GET и только если сервер разрешил заголовками.ETag→ условный запрос → 304 без тела. - Cache-Control —
max-age(сколько свеж),no-cache(перепроверяй через ETag),no-store(не кэшируй). Можно переопределить сетевым интерцептором. - БД-кэш — когда нужны фильтры, сортировки, связи и реактивные апдейты UI; HTTP-кэш этого не умеет в принципе.
⚠️ Частая ошибка: строить offline-first на одном HTTP-кэше OkHttp. Он вытесняется по размеру непредсказуемо, не даёт запросов и реактивных обновлений — это transport-оптимизация, а не хранилище приложения.
10Certificate/SSL pinning и шифрование данных на устройстве: как и какие риски?
senior
Короткий ответ: Pinning привязывает соединение к известному публичному ключу (а не к любому валидному сертификату из системного стора) — защита от подменного CA и MITM. Данные at-rest шифруют ключом из Android Keystore: ключ не покидает secure hardware. Важно в 2026: EncryptedSharedPreferences и вся security-crypto объявлены deprecated (апрель 2025) — новый код идёт на DataStore + Tink.
Подробно:
// SSL pinning через OkHttp (пин — SHA-256 публичного ключа)
val pinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup
.build()
OkHttpClient.Builder().certificatePinner(pinner).build()
- Как пинят —
CertificatePinner(OkHttp) или Network Security Config (XML). Пин — хэш публичного ключа, а не сам сертификат. - Риск ротации — если сервер сменит ключ, а в приложении зашит один пин, все клиенты потеряют связь до обновления приложения. Держите backup-пин, срок годности и по возможности отдавайте пины конфигом, а не хардкодьте намертво.
- At-rest — ключ в Android Keystore (AES, при наличии — StrongBox), сами данные в DataStore/файле; Keystore-ключ не экспортируется из железа.
- EncryptedSharedPreferences — deprecated с 2025 (
security-crypto 1.1.0-alpha, известные keyset-corruption краши на части OEM); мигрируйте на DataStore + Tink или поддерживаемый форк.
⚠️ Частая ошибка: захардкодить единственный пин без backup и плана ротации — это kill switch для всех пользователей в день смены сертификата. И: pinning не заменяет шифрование at-rest — это разные угрозы (канал против устройства).
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.