Закрывает блок 7 чеклиста, в банке вопросов — блок 6 (вопросы 81–97).
Что уже разобрано в других материалах и здесь не дублируется:
- проектирование системы целиком, включая пример API-контракта и разбор outbox на уровне
дизайна —
04-system-design.md; WorkManager, его гарантии, ограничения фона и Doze —10-android-sdk-deep.md, раздел 6;- корутиновые паттерны (ретрай с backoff, дедупликация запросов, дебаунс) и их тестирование —
08-coroutines-android.md, раздел 4; Flow, операторы и холодность/горячесть —Kotlin_Senior_Android_Guide.markdown, раздел 13;- где хранить состояние экрана и границы domain/data —
12-architecture-deep.md, раздел 4.
Здесь — механика слоя данных: как устроен offline-first, что именно делает Room, как не сломаться на синхронизации и конфликтах, как работает пагинация и сеть.
Вопрос 81 банка проверяет ровно одно: понимаете ли вы, что offline-first — это не «добавили кэш». Это правило о том, кто имеет право отвечать UI.
Наивная схема (и самая частая в проде):
UI → ViewModel → Repository → если есть сеть, идём в сеть
→ если нет, идём в кэш
Она плоха не тем, что не работает, а тем, что у UI два разных источника с разным поведением. Отсюда мигание при переключении, расхождение данных между экранами, невозможность показать «старое, пока грузится новое» и ад в тестировании: состояний вдвое больше, чем кажется.
Offline-first:
UI → ViewModel → Repository → Room (Flow) — единственный источник для чтения
↑
синхронизация пишет сюда
UI читает только из базы и вообще не знает про сеть. Сеть — это фоновый процесс, который
пишет в базу. Из этого автоматически следуют три приятных свойства: экран одинаково работает
в офлайне, данные согласованы между экранами по построению, а «обновление» — это просто новое
значение из Flow.
Честный ответ обязан назвать цену, иначе выглядит как пересказ статьи:
- Нужно отдельно моделировать статус синхронизации. Данные больше не «загружаются» — они всегда есть. Но пользователю нужно понимать, свежие они или нет, поэтому появляется отдельное состояние: когда синхронизировались, идёт ли синк, была ли ошибка.
- Нужно решать, что делать с записью. Чтение из базы — это просто, а вот запись требует outbox (раздел 3), иначе офлайн-действия теряются.
- Схема БД становится публичным контрактом. Её теперь нельзя менять свободно — нужны миграции.
- Не все данные этого заслуживают. Одноразовый экран оплаты или результат поиска не надо класть в базу. Offline-first — про данные, к которым возвращаются.
Факт, который стоит знать, чтобы не выглядеть отставшим: летом 2026 года вышел Room 3.0 —
и это не обычное обновление, а новая линейка с другой группой артефактов
(androidx.room3) и другими пакетами. Ключевые изменения:
- Только KSP. Поддержки KAPT и Java-процессора нет, генерируется только Kotlin.
- Только новый драйвер SQLite. Старый слой поддержки доступен лишь через отдельную обёртку — это то, обо что спотыкается миграция в больших проектах.
- Ориентация на корутины и переименование части аннотаций (например, конвертеры типов).
Практический вывод для ответа: миграция с 2.x на 3.x — не смена версии, а работа, и в реальных проектах какое-то время будут жить обе линейки. Всё, что написано ниже про механику (инвалидация, транзакции, миграции), одинаково справедливо для обеих.
Правило простое: Flow для чтения, suspend для разовых операций и записи.
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY published_at DESC")
fun observeAll(): Flow<List<Article>> // экран подписан на это
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun getById(id: String): Article? // разовое чтение, например для синка
@Upsert
suspend fun upsertAll(articles: List<Article>)
}Механика, которую стоит уметь объяснить, потому что из неё следуют все практические проблемы:
Room ведёт InvalidationTracker, который через триггеры в SQLite отслеживает изменения
на уровне таблиц, а не строк. Когда таблица изменилась, все Flow, объявленные над этой
таблицей, перевыполняют запрос и эмитят новый результат.
Из «на уровне таблиц» следуют два практических вывода:
- Лишние эмиссии — норма. Изменение любой строки таблицы
articlesперевыполнит запрос «дай статью с id = 5», даже если именно она не менялась. На горячих таблицах это заметная нагрузка. - Спасает
distinctUntilChanged(). Он отсечёт эмиссию, если результат фактически не изменился. Это дешёвая и почти всегда правильная привычка дляFlowиз Room.
Ещё одна деталь, о которой спрашивают: запрос выполняется на диспетчере, который Room берёт из
своего executor'а, поэтому Flow из Room уже не на главном потоке — дополнительный
flowOn(Dispatchers.IO) не нужен и только вводит в заблуждение.
@Transaction нужен в двух неочевидных случаях помимо «несколько записей вместе»:
- При
@Relation. Room выполняет такой запрос как несколько отдельных запросов (родитель, потом дети). Без@Transactionмежду ними может вклиниться запись, и вы получите несогласованный результат. Room об этом предупреждает, и предупреждение не стоит игнорировать. - При «прочитать и записать». Классический
read-modify-writeбез транзакции — это гонка, даже если весь код в одной корутине: писать может другой процесс или другой воркер.
Полезно помнить, что метод с @Transaction может быть suspend, и Room выполнит его в одной
транзакции целиком, включая вызовы других DAO-методов внутри.
Что будет, если забыть. При открытии базы Room сверяет хеш схемы с сохранённым в базе.
Если версия выросла, а пути миграции нет, Room бросает IllegalStateException при первом
обращении к базе. То есть это не тихая порча данных, а падение — и падение у всех
обновившихся пользователей сразу, обычно в момент старта. Это одна из самых дорогих ошибок
в мобильной разработке, потому что чинится только новым релизом.
Что важно сказать про варианты:
- Авто-миграции покрывают механические изменения (добавили колонку, добавили таблицу).
Для переименования и удаления нужны спецификации (
@RenameColumn,@DeleteColumn), потому что по одной схеме Room не может отличить «переименовали» от «удалили и добавили». - Ручные миграции нужны везде, где меняются сами данные, а не только форма: разложить одну колонку в две, конвертировать формат даты, заполнить новое поле вычисленным значением.
- Разрушающая миграция (сброс базы) допустима только для чистого кэша, который можно
перезапросить, и никогда — для пользовательских данных вроде черновиков и офлайн-действий.
Небольшая деталь, по которой видно свежесть знаний: в Room 2.7 старый вызов без параметров
(
fallbackToDestructiveMigration()) задепрекейчен в пользу версии, которая требует явно указать, сбрасывать ли все таблицы (dropAllTables). Смысл изменения в том, что «разрушить» — слишком опасная операция, чтобы включаться незаметно. В Room 3.0 у этого параметра появилось значение по умолчанию, так что вызов без аргументов снова компилируется — но осознанность выбора это не отменяет.
Тестирование миграций — обязательная часть ответа. Room умеет экспортировать схемы в JSON
(exportSchema = true, схемы коммитятся в репозиторий), и есть MigrationTestHelper, который
создаёт базу старой версии, применяет миграцию и проверяет результат. Практическое правило,
которое стоит произнести: тест должен не только проверять, что миграция прошла, но и что
данные на месте — миграция, стирающая данные, технически «успешна».
Три вещи, которых достаточно для интервью:
- Индекс нужен на колонках, по которым идёт
WHERE,JOINиORDER BY. Room предупреждает об отсутствии индекса на внешнем ключе, и это предупреждение почти всегда справедливо. - Индекс — не бесплатный: он замедляет запись и занимает место. Индексировать всё подряд — такая же ошибка, как не индексировать ничего.
- Проверять догадки надо через
EXPLAIN QUERY PLAN, а не на глаз. Если в плане видноSCAN TABLEтам, где ожидалсяSEARCH ... USING INDEX, индекс не применился — частая причина в том, что на колонке есть функция или несовпадение типов.
Потому что сеть не является надёжной, а UI должен отвечать сразу. Если сначала идти в сеть, то любое действие пользователя в офлайне либо теряется, либо требует держать его в памяти — а память не переживает смерть процесса.
Порядок такой:
- Записать намерение в базу в одной транзакции с оптимистичным изменением видимых данных.
- Разбудить воркер, который отправит.
- По ответу сервера — подтвердить или откатить.
Ключевое слово — «в одной транзакции». Если запись в outbox и изменение видимых данных не
атомарны, возможны оба плохих состояния: пользователь видит лайк, которого никто не отправит,
или отправка есть, а в UI ничего не изменилось.
Подробный разбор устройства таблицы, обработки, ретраев и порядка — в 04-system-design.md,
раздел «Образец deep dive». Здесь важно не повторять его, а уметь связать с реализацией на
Android: воркер — это WorkManager с уникальным именем и констрейнтом на сеть, потому что
только он переживает смерть процесса (механика — в 10-android-sdk-deep.md, раздел 6.3).
Гарантия нужна на трёх уровнях, и senior-ответ называет все три.
- Клиент генерирует идентификатор операции один раз, при её создании, и переиспользует при всех повторах. Генерировать UUID в момент отправки — распространённая ошибка, которая полностью обесценивает механизм: каждый ретрай получит новый ключ.
- Сеть: ключ передаётся в заголовке (
Idempotency-Key) или в теле; сервер хранит его некоторое время и на повтор возвращает тот же результат, а не ошибку. - База:
@Upsertвместо@Insert, либоINSERT ... ON CONFLICT REPLACE— чтобы повторное применение ответа не создало дубль локально.
Тонкость, которую любят как follow-up: идемпотентность нужна не только для POST. PUT идемпотентен
по определению HTTP, а вот «увеличить счётчик на 1» — нет, и это как раз тот случай, когда
операцию надо переформулировать в «установить значение X», чтобы повтор был безопасным.
Конфликт — это когда одна сущность изменена в двух местах между синхронизациями. Стратегии, от простой к дорогой:
| Стратегия | Как работает | Цена |
|---|---|---|
| Last-write-wins по времени | побеждает запись с большим timestamp | часы клиента врут; тихая потеря данных |
| Server-wins | локальные изменения отбрасываются | предсказуемо, но пользователь теряет работу молча |
| Client-wins | сервер перезаписывается | ещё хуже: один пользователь затирает других |
| Версии (optimistic concurrency) | клиент шлёт версию, сервер отвечает 409 при расхождении | нужно решать, что делать с 409 |
| Слияние по полям | конфликтуют только реально изменённые поля | нужна дельта, а не полный объект |
| CRDT / event sourcing | конфликты невозможны по построению | самая дорогая схема; оправдана для совместного редактирования |
Что важнее конкретной таблицы: выбор зависит от цены потери данных. Для настроек приложения last-write-wins нормален. Для текста заметки — нет, там нужны версии и явный UX при конфликте. Именно эту связку «цена потери → стратегия» и ждут в ответе.
Отдельно проговорите, почему нельзя доверять часам клиента: пользователь может перевести время, часовые пояса плавают, и timestamp с устройства как арбитр — источник неисправимых багов. Если время нужно, используйте серверное или логические часы (монотонно растущий счётчик версии).
Сценарий из банка полезно уметь пройти целиком, по шагам, потому что он проверяет сразу всё:
- Нажатие офлайн. В одной транзакции:
likes = likes + 1,isLikedByMe = trueв таблице постов и запись вoutboxсо статусомPENDINGи клиентским UUID. UI обновился мгновенно, потому что подписан наFlowиз базы. - Приложение убито. Ничего не потеряно: обе записи на диске. При следующем запуске
WorkManagerвосстановит запланированную работу. - Сеть вернулась. Констрейнт воркера выполнен, система его будит. Воркер берёт
PENDING, отправляет сIdempotency-Key. - Ответ получен. Запись из outbox удаляется, значение счётчика заменяется серверным (важно: не оставлять локально посчитанное — за это время лайкнули и другие).
- Ответ — ошибка 4xx. Откат оптимистичного изменения и сообщение пользователю. Откат должен быть в той же транзакции, что и удаление из outbox.
- Ответ не получен (таймаут). Не откатываем и не считаем ошибкой — ретраим с тем же ключом. Именно здесь идемпотентность спасает от двойного лайка.
Отдельный момент, который добавляет очков: что показывать пользователю в шаге 1. Если UI ничем не отличает подтверждённое действие от неподтверждённого, то при откате в шаге 5 изменение «отыграется назад» неожиданно. Поэтому у элемента обычно есть третье, промежуточное представление — например, счётчик обновлён, но с пометкой «не отправлено».
Offset (LIMIT 20 OFFSET 40) прост и позволяет прыгать на произвольную страницу. Его
фатальная проблема в ленте — page drift: если между запросом страницы 3 и страницы 4 в
начало списка добавились два элемента, то элементы на границе страниц повторятся, а если
удалились — будут пропущены. Пользователь видит дубликаты в списке, и причину этого обычно
ищут где угодно, только не в пагинации.
Cursor — это указатель на конкретную позицию в упорядоченной последовательности («дай 20 после элемента X»). Вставки и удаления выше курсора его не сдвигают, поэтому дрейфа нет.
Что стоит добавить, чтобы ответ был полным:
- Курсор должен быть непрозрачным для клиента. Как только клиент начинает его разбирать, сервер теряет право менять реализацию.
- Курсор требует стабильной сортировки. Если сортировать по неуникальному полю (например,
только по дате), элементы с одинаковым значением могут разъезжаться — поэтому в ключ сортировки
добавляют уникальное поле:
(published_at, id). - Обратная сторона: по курсору нельзя прыгнуть на страницу N и трудно показать «страница 3 из 47». Для админок и таблиц offset часто остаётся правильным выбором.
Самописная пагинация — это обычно 50 строк, и в них почти всегда нет того, что даёт библиотека:
- Дедупликация запросов. Быстрый скролл не порождает пять параллельных запросов одной страницы.
- Состояния загрузки по краям (
LoadStateдля refresh, prepend, append) с ошибкой и ретраем на каждом крае по отдельности. - Переживание пересоздания через
cachedIn(viewModelScope): при повороте список не перезагружается с нуля. - Инвалидация, согласованная с источником: при изменении данных пейджер пересобирается корректно, а не «дозагружает поверх старого».
RemoteMediator — это как раз мост к offline-first. Источником для UI остаётся Room
(PagingSource из DAO), а медиатор вызывается, когда локальных данных не хватает, идёт в сеть и
пишет в базу. UI при этом по-прежнему читает только базу. Здесь надо аккуратно рассказать
про LoadType:
REFRESH— начальная загрузка или явное обновление; обычно очищает и заполняет заново;APPEND— дошли до конца локальных данных, нужна следующая страница;PREPEND— нужна страница «до» начала; часто в лентах его не реализуют и возвращаютendOfPaginationReached = true.
Тонкость, на которой спотыкаются: для REFRESH значение endOfPaginationReached всегда
трактуется как «не конец», поэтому решать, что данные закончились, нужно по результатам
APPEND/PREPEND, а не по обновлению.
Ключи страниц надо где-то хранить, и это самая неприятная часть: обычно заводят отдельную
таблицу remote_keys, потому что курсор следующей страницы не является частью доменной модели.
Честная оговорка, которую полезно добавить: Paging 3 сложен в тестировании и в отладке, у него
крутая кривая обучения, и для короткого конечного списка (например, 50 элементов максимум) он
избыточен — там достаточно обычного Flow<List<T>>.
Классическое возражение против Paging: PagingData нельзя вынести за пределы UI-слоя. Его
нельзя скомбинировать с другим потоком, отфильтровать по состоянию экрана или положить в общий
UiState — он предназначен для того, чтобы течь напрямую в список. Из-за этого архитектура
экрана с пагинацией всегда получалась «особенной», и это была честная причина отказываться от
библиотеки.
В свежих версиях появился способ получить из потока пагинации обычный снимок списка, пригодный для комбинирования и хранения в состоянии. Цена — за подгрузку следующих страниц теперь отвечает не скролл, а явные вызовы «загрузить дальше». То есть библиотека дала выбор между двумя моделями: привычной автоматической и ручной, но совместимой с обычным UDF.
Если на интервью прозвучит «Paging плохо ложится на нашу архитектуру состояния» — это ровно тот момент, где стоит сказать, что ограничение было настоящим, но перестало быть абсолютным.
Разница в том, где интерцептор стоит относительно кэша и редиректов.
Application interceptor (addInterceptor) |
Network interceptor (addNetworkInterceptor) |
|
|---|---|---|
| Вызывается | один раз на логический запрос | на каждый реальный сетевой запрос |
| При ответе из кэша | вызывается | не вызывается |
| При редиректе/ретрае | один раз | на каждый хоп |
| Видит | запрос как его написал вызывающий | финальный запрос со всеми заголовками OkHttp |
Практические выводы, из-за которых это и спрашивают:
- Авторизацию вешают на application interceptor: она логическая, и её не нужно применять к каждому редиректу (иначе токен уедет на чужой хост при редиректе).
- Логирование того, что реально ушло в сеть (с
Content-Length,Accept-Encoding, добавленными OkHttp) возможно только на network interceptor. - Если ответ пришёл из кэша, application interceptor это увидит, а network — нет. Поэтому метрики «сколько запросов реально ушло» считают на network interceptor.
Сценарий: токен протух, пять параллельных запросов получили 401. Нужно обновить ровно один раз.
Два инструмента и разница между ними:
Authenticator— механизм OkHttp специально для этого: вызывается после 401, его ответ используется для повторного запроса, аresponse.priorResponseпозволяет ограничить число попыток. Он не решает проблему гонки сам по себе — пять потоков войдут в него параллельно.- Interceptor может обновлять токен проактивно (по времени жизни), но тогда нужно самому ловить 401.
Механика, снимающая гонку, — дедупликация обновления (single-flight): первый вошедший запускает обновление, остальные ждут его результат, а не запускают своё.
class TokenRefresher(
private val api: AuthApi,
private val storage: TokenStorage,
) {
private val mutex = Mutex()
suspend fun refreshIfNeeded(usedToken: String): String? = mutex.withLock {
val current = storage.accessToken
// Пока мы ждали мьютекс, кто-то мог уже обновить токен.
// Тогда обновлять второй раз не нужно — просто берём свежий.
if (current != null && current != usedToken) return@withLock current
val refreshed = runCatching { api.refresh(storage.refreshToken) }.getOrNull()
refreshed?.also { storage.save(it) }?.accessToken
}
}Ключевая строка здесь — сравнение current != usedToken. Без неё пять запросов последовательно
пройдут через мьютекс и сделают пять обновлений, просто не одновременно, а по очереди. Проверка
«токен уже сменился с того, с которым я упал» превращает это в одно обновление.
Что ещё обязательно упомянуть:
- Ограничение попыток. Без него протухший refresh-токен даёт бесконечный цикл 401 → refresh
→ 401. В
Authenticatorдля этого считают глубину черезpriorResponse. - Что делать при неудаче. Обновление не удалось — это разлогин, и он должен быть централизованным (одно место, которое чистит хранилище и уводит на экран входа), а не «каждый экран как-нибудь обработает».
- Блокирующий контекст.
Authenticatorв OkHttp вызывается синхронно, на сетевом потоке, поэтому внутри негоrunBlocking— вынужденная мера; важно, чтобы под ним не оказалось главного потока.
Общий паттерн дедупликации в отрыве от токенов (SingleFlight) разобран в
08-coroutines-android.md, раздел 4 — там же его тестирование.
Таймауты в OkHttp есть отдельно на соединение, чтение и запись, плюс общий callTimeout
на всю операцию. Практический совет: callTimeout — единственный, который ограничивает всё
целиком, включая редиректы и ретраи; без него запрос может суммарно висеть гораздо дольше, чем
кажется по отдельным таймаутам.
Ретраи. У OkHttp есть retryOnConnectionFailure, но он покрывает только сетевые сбои
соединения, а не коды ответа. Ретраить надо избирательно: 5xx, 408, 429 и сетевые ошибки — да;
4xx — нет (кроме перечисленных). Обязательно с экспоненциальным backoff и джиттером, иначе при
массовом сбое клиенты синхронно ударят по восстановившемуся серверу (thundering herd). И
обязательно уважать Retry-After, если сервер его прислал.
HTTP-кэш OkHttp работает по заголовкам (Cache-Control, ETag, Last-Modified) и требует
явно сконфигурированного дискового кэша. Важное ограничение: он кэширует только GET и только
по правилам HTTP. Для offline-first он не заменяет базу — HTTP-кэш обслуживает «не тянуть то
же самое повторно», а не «показать данные без сети как первоклассное состояние».
| Транспорт | Когда | На что смотреть |
|---|---|---|
| REST | запрос-ответ, кэшируемость, простота | дефолт; хорошо кэшируется по HTTP |
| GraphQL | много разных экранов на одних данных, клиент диктует форму | сложнее кэшировать, риск тяжёлых запросов, нужен контроль глубины |
| Polling | редкие обновления, простота важнее свежести | батарея; интервал должен расти в фоне |
| SSE | односторонний поток от сервера, работает поверх HTTP | проще WebSocket, но только сервер→клиент |
| WebSocket | двусторонний обмен, низкая латентность (чат, торги) | своя реконнект-логика, heartbeat, батарея |
Для двух последних обязательно рассказать про деградацию: реконнект с экспоненциальным backoff и джиттером, heartbeat/ping для обнаружения «молчащего» соединения (TCP может очень долго не замечать обрыв), догон пропущенного через обычный REST по курсору последнего полученного события, и отключение сокета при уходе приложения в фон с переходом на push.
| Хранилище | Для чего | Ключевые свойства |
|---|---|---|
| Room | структурированные данные, запросы, связи | транзакции, миграции, реактивные запросы |
| DataStore | настройки, флаги, небольшое состояние | асинхронный, транзакционный, на корутинах и Flow |
| Файлы | блобы, медиа, экспорт | вы сами отвечаете за атомарность |
| SharedPreferences | легаси | синхронное чтение, опасный API |
Почему SharedPreferences считается легаси — вопрос, который стоит уметь развернуть:
- Весь файл читается в память при первом обращении, синхронно, и первое обращение часто происходит на главном потоке в момент старта.
commit()пишет на диск синхронно и возвращает результат — на главном потоке это прямой путь к ANR.apply()пишет асинхронно, но записи ставятся в очередь, и эта очередь дожидается завершения в моментonPause/onStopкомпонентов. Поэтому пачкаapply()тоже даёт ANR, просто не там, где её сделали, — этот механизм разобран в11-concurrency-deep.md.- Нет транзакционности между несколькими значениями и нет вменяемой обработки ошибок записи.
DataStore решает всё перечисленное, но у него есть своя цена, которую полезно назвать: чтение
всегда асинхронное (нельзя «просто прочитать значение» в момент старта, что иногда неудобно),
на один файл должен быть ровно один экземпляр на процесс, и при повреждении файла нужен явный
corruptionHandler, иначе получите исключение вместо данных.
Сценарий из банка — спроектировать кэш изображений. Каркас:
Уровни.
- Память —
LruCache, размер в байтах (не в штуках!), обычно доля отActivityManager.memoryClass. Держит готовые к отрисовке объекты. - Диск — размер в мегабайтах, переживает перезапуск. Обычно это
DiskLruCache; учтите, что это не класс платформы, а реализация из библиотек (OkHttp, Glide) или скопированная в проект — на этом иногда ловят уточняющим вопросом. - Сеть — источник истины.
Промах на каждом уровне проваливается ниже, попадание — поднимается наверх.
Ключ. Не URL, а URL плюс параметры трансформации (целевой размер, обрезка). Иначе миниатюра и полноразмерная картинка перезапишут друг друга — классический баг.
Вытеснение. LRU по умолчанию; для медиа иногда лучше LFU или сегментированный кэш, чтобы одна прокрутка длинной ленты не вымыла всё полезное (это называется cache pollution и его стоит упомянуть).
Инвалидация — самая сложная часть, и ответ на неё зависит от того, меняется ли содержимое по тому же URL:
- если URL контентно-адресуемый (в нём хеш) — инвалидация не нужна вообще, это лучший вариант, и его стоит предложить как требование к бэкенду;
- если нет — TTL плюс валидация по
ETag; - ручной сброс по событию (пользователь сменил аватар) должен чистить оба уровня, и про память забывают чаще, чем про диск.
Что сказать в конце: в реальном проекте это не пишут руками — берут Coil или Glide, где всё перечисленное уже есть, включая привязку к жизненному циклу и отмену загрузки для переиспользуемых ячеек. Умение спроектировать нужно, чтобы понимать, что именно делает библиотека и где она может ошибаться.
Короткий срез, чтобы не цитировать устаревшее:
- Сериализация.
kotlinx.serialization— дефолт, и обе альтернативы это подтверждают. Gson официально в режиме поддержки: его собственная документация прямо говорит, что он не рекомендуется для Android и что стоит взятьkotlinx.serialization. Moshi формально не заброшен и репозиторий не заархивирован, но стабильных релизов нет с конца 2024 года, а мейнтейнер публично написал, что не намерен его развивать и рекомендует переходить наkotlinx.serialization. Поддержки KMP у Moshi не будет. Аккуратная формулировка для интервью: «Moshi не умер, но и не развивается, и его автор сам советует уходить». - Сеть. OkHttp 5 стабилен. Про Retrofit 3 стоит знать неожиданное: это не переписывание на Kotlin с встроенной поддержкой корутин, как часто пишут, — основное содержание релиза сводится к переходу на новую версию OkHttp, а совместимость с 2.x сохранена. Если уверенно заявить на интервью про «переписали на Kotlin», это будет ошибкой.
- Хранилище настроек. SharedPreferences формально не задепрекейчен, но официальная документация прямо советует использовать вместо него DataStore. В самом DataStore, в свою очередь, появился новый способ создания через билдер, и старую фабрику Google рекомендует постепенно оставить.
- Изображения. И Coil, и Glide активно поддерживаются; Coil 3 — мультиплатформенная линейка, что делает его естественным выбором в KMP-проектах.
Короткий набор, который закрывает 🟢-пункт чеклиста: сжатие (gzip/brotli, обычно уже включено
на уровне OkHttp), дельта-синхронизация вместо полной выгрузки (клиент присылает версию, сервер
отдаёт только изменения), батчинг мелких запросов, разные политики для Wi-Fi и мобильной сети
(NetworkType.UNMETERED в констрейнтах WorkManager для тяжёлого), пагинация вместо полной
выгрузки и предзагрузка только того, что пользователь с высокой вероятностью откроет.
- Чем offline-first отличается от «добавили кэш» и что усложняется?
- Почему у UI должен быть ровно один источник чтения?
Flowилиsuspendв DAO: правило и механика инвалидации.- Почему Room эмитит лишние значения и что с этим делать?
- Зачем
@Transactionпри@Relation? - Что произойдёт, если поднять версию базы и забыть миграцию?
- Чем авто-миграции отличаются от ручных и когда первых не хватает?
- Как тестируют миграции и что должен проверять такой тест кроме успеха?
- Почему запись идёт в базу до сети и что обязано быть в одной транзакции?
- Три уровня идемпотентности. Где чаще всего ошибаются?
- Шесть стратегий разрешения конфликтов и критерий выбора между ними.
- Почему нельзя доверять часам клиента?
- Пройдите весь путь оптимистичного лайка: офлайн, смерть процесса, возврат сети, ошибка.
- Offset против cursor: что такое page drift и почему курсор непрозрачен?
- Что Paging 3 даёт поверх самописной пагинации и в чём роль
RemoteMediator? - Три значения
LoadTypeи зачем нужна таблица ключей. - Почему
PagingDataбыло нельзя вынести из UI-слоя и что изменилось? addInterceptorпротивaddNetworkInterceptor: четыре различия и практические выводы.- Пять запросов получили 401 — как обновить токен ровно один раз?
- Какая строка в решении с мьютексом на самом деле убирает гонку?
- Что ретраить, а что нет; зачем джиттер и
Retry-After? - Room, DataStore, файлы, SharedPreferences — критерии выбора.
- Почему пачка
apply()может дать ANR и где именно? - Спроектируйте многоуровневый кэш изображений: уровни, ключ, вытеснение, инвалидация.
- REST, GraphQL, SSE, WebSocket, polling — критерии и деградация.