Skip to content

Latest commit

 

History

History
565 lines (430 loc) · 49.2 KB

File metadata and controls

565 lines (430 loc) · 49.2 KB

Данные, сеть и offline-first: теория и ответы

Закрывает блок 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, как не сломаться на синхронизации и конфликтах, как работает пагинация и сеть.


1. Offline-first как архитектура, а не как фича

1.1. Что означает «single source of truth»

Вопрос 81 банка проверяет ровно одно: понимаете ли вы, что offline-first — это не «добавили кэш». Это правило о том, кто имеет право отвечать UI.

Наивная схема (и самая частая в проде):

UI → ViewModel → Repository → если есть сеть, идём в сеть
                            → если нет, идём в кэш

Она плоха не тем, что не работает, а тем, что у UI два разных источника с разным поведением. Отсюда мигание при переключении, расхождение данных между экранами, невозможность показать «старое, пока грузится новое» и ад в тестировании: состояний вдвое больше, чем кажется.

Offline-first:

UI → ViewModel → Repository → Room (Flow) — единственный источник для чтения
                                  ↑
                            синхронизация пишет сюда

UI читает только из базы и вообще не знает про сеть. Сеть — это фоновый процесс, который пишет в базу. Из этого автоматически следуют три приятных свойства: экран одинаково работает в офлайне, данные согласованы между экранами по построению, а «обновление» — это просто новое значение из Flow.

1.2. Что при этом усложняется

Честный ответ обязан назвать цену, иначе выглядит как пересказ статьи:

  • Нужно отдельно моделировать статус синхронизации. Данные больше не «загружаются» — они всегда есть. Но пользователю нужно понимать, свежие они или нет, поэтому появляется отдельное состояние: когда синхронизировались, идёт ли синк, была ли ошибка.
  • Нужно решать, что делать с записью. Чтение из базы — это просто, а вот запись требует outbox (раздел 3), иначе офлайн-действия теряются.
  • Схема БД становится публичным контрактом. Её теперь нельзя менять свободно — нужны миграции.
  • Не все данные этого заслуживают. Одноразовый экран оплаты или результат поиска не надо класть в базу. Offline-first — про данные, к которым возвращаются.

2. Room

2.0. Что изменилось: Room 3.0

Факт, который стоит знать, чтобы не выглядеть отставшим: летом 2026 года вышел Room 3.0 — и это не обычное обновление, а новая линейка с другой группой артефактов (androidx.room3) и другими пакетами. Ключевые изменения:

  • Только KSP. Поддержки KAPT и Java-процессора нет, генерируется только Kotlin.
  • Только новый драйвер SQLite. Старый слой поддержки доступен лишь через отдельную обёртку — это то, обо что спотыкается миграция в больших проектах.
  • Ориентация на корутины и переименование части аннотаций (например, конвертеры типов).

Практический вывод для ответа: миграция с 2.x на 3.x — не смена версии, а работа, и в реальных проектах какое-то время будут жить обе линейки. Всё, что написано ниже про механику (инвалидация, транзакции, миграции), одинаково справедливо для обеих.

2.1. Flow или suspend (вопрос 90)

Правило простое: 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) не нужен и только вводит в заблуждение.

2.2. Транзакции и @Relation

@Transaction нужен в двух неочевидных случаях помимо «несколько записей вместе»:

  • При @Relation. Room выполняет такой запрос как несколько отдельных запросов (родитель, потом дети). Без @Transaction между ними может вклиниться запись, и вы получите несогласованный результат. Room об этом предупреждает, и предупреждение не стоит игнорировать.
  • При «прочитать и записать». Классический read-modify-write без транзакции — это гонка, даже если весь код в одной корутине: писать может другой процесс или другой воркер.

Полезно помнить, что метод с @Transaction может быть suspend, и Room выполнит его в одной транзакции целиком, включая вызовы других DAO-методов внутри.

2.3. Миграции (вопрос 91)

Что будет, если забыть. При открытии базы Room сверяет хеш схемы с сохранённым в базе. Если версия выросла, а пути миграции нет, Room бросает IllegalStateException при первом обращении к базе. То есть это не тихая порча данных, а падение — и падение у всех обновившихся пользователей сразу, обычно в момент старта. Это одна из самых дорогих ошибок в мобильной разработке, потому что чинится только новым релизом.

Что важно сказать про варианты:

  • Авто-миграции покрывают механические изменения (добавили колонку, добавили таблицу). Для переименования и удаления нужны спецификации (@RenameColumn, @DeleteColumn), потому что по одной схеме Room не может отличить «переименовали» от «удалили и добавили».
  • Ручные миграции нужны везде, где меняются сами данные, а не только форма: разложить одну колонку в две, конвертировать формат даты, заполнить новое поле вычисленным значением.
  • Разрушающая миграция (сброс базы) допустима только для чистого кэша, который можно перезапросить, и никогда — для пользовательских данных вроде черновиков и офлайн-действий. Небольшая деталь, по которой видно свежесть знаний: в Room 2.7 старый вызов без параметров (fallbackToDestructiveMigration()) задепрекейчен в пользу версии, которая требует явно указать, сбрасывать ли все таблицы (dropAllTables). Смысл изменения в том, что «разрушить» — слишком опасная операция, чтобы включаться незаметно. В Room 3.0 у этого параметра появилось значение по умолчанию, так что вызов без аргументов снова компилируется — но осознанность выбора это не отменяет.

Тестирование миграций — обязательная часть ответа. Room умеет экспортировать схемы в JSON (exportSchema = true, схемы коммитятся в репозиторий), и есть MigrationTestHelper, который создаёт базу старой версии, применяет миграцию и проверяет результат. Практическое правило, которое стоит произнести: тест должен не только проверять, что миграция прошла, но и что данные на месте — миграция, стирающая данные, технически «успешна».

2.4. Индексы и производительность

Три вещи, которых достаточно для интервью:

  • Индекс нужен на колонках, по которым идёт WHERE, JOIN и ORDER BY. Room предупреждает об отсутствии индекса на внешнем ключе, и это предупреждение почти всегда справедливо.
  • Индекс — не бесплатный: он замедляет запись и занимает место. Индексировать всё подряд — такая же ошибка, как не индексировать ничего.
  • Проверять догадки надо через EXPLAIN QUERY PLAN, а не на глаз. Если в плане видно SCAN TABLE там, где ожидался SEARCH ... USING INDEX, индекс не применился — частая причина в том, что на колонке есть функция или несовпадение типов.

3. Синхронизация и outbox

3.1. Почему запись идёт в базу до сети (вопрос 82)

Потому что сеть не является надёжной, а UI должен отвечать сразу. Если сначала идти в сеть, то любое действие пользователя в офлайне либо теряется, либо требует держать его в памяти — а память не переживает смерть процесса.

Порядок такой:

  1. Записать намерение в базу в одной транзакции с оптимистичным изменением видимых данных.
  2. Разбудить воркер, который отправит.
  3. По ответу сервера — подтвердить или откатить.

Ключевое слово — «в одной транзакции». Если запись в outbox и изменение видимых данных не атомарны, возможны оба плохих состояния: пользователь видит лайк, которого никто не отправит, или отправка есть, а в UI ничего не изменилось.

Подробный разбор устройства таблицы, обработки, ретраев и порядка — в 04-system-design.md, раздел «Образец deep dive». Здесь важно не повторять его, а уметь связать с реализацией на Android: воркер — это WorkManager с уникальным именем и констрейнтом на сеть, потому что только он переживает смерть процесса (механика — в 10-android-sdk-deep.md, раздел 6.3).

3.2. Идемпотентность (вопрос 87)

Гарантия нужна на трёх уровнях, и senior-ответ называет все три.

  • Клиент генерирует идентификатор операции один раз, при её создании, и переиспользует при всех повторах. Генерировать UUID в момент отправки — распространённая ошибка, которая полностью обесценивает механизм: каждый ретрай получит новый ключ.
  • Сеть: ключ передаётся в заголовке (Idempotency-Key) или в теле; сервер хранит его некоторое время и на повтор возвращает тот же результат, а не ошибку.
  • База: @Upsert вместо @Insert, либо INSERT ... ON CONFLICT REPLACE — чтобы повторное применение ответа не создало дубль локально.

Тонкость, которую любят как follow-up: идемпотентность нужна не только для POST. PUT идемпотентен по определению HTTP, а вот «увеличить счётчик на 1» — нет, и это как раз тот случай, когда операцию надо переформулировать в «установить значение X», чтобы повтор был безопасным.

3.3. Разрешение конфликтов (вопрос 86)

Конфликт — это когда одна сущность изменена в двух местах между синхронизациями. Стратегии, от простой к дорогой:

Стратегия Как работает Цена
Last-write-wins по времени побеждает запись с большим timestamp часы клиента врут; тихая потеря данных
Server-wins локальные изменения отбрасываются предсказуемо, но пользователь теряет работу молча
Client-wins сервер перезаписывается ещё хуже: один пользователь затирает других
Версии (optimistic concurrency) клиент шлёт версию, сервер отвечает 409 при расхождении нужно решать, что делать с 409
Слияние по полям конфликтуют только реально изменённые поля нужна дельта, а не полный объект
CRDT / event sourcing конфликты невозможны по построению самая дорогая схема; оправдана для совместного редактирования

Что важнее конкретной таблицы: выбор зависит от цены потери данных. Для настроек приложения last-write-wins нормален. Для текста заметки — нет, там нужны версии и явный UX при конфликте. Именно эту связку «цена потери → стратегия» и ждут в ответе.

Отдельно проговорите, почему нельзя доверять часам клиента: пользователь может перевести время, часовые пояса плавают, и timestamp с устройства как арбитр — источник неисправимых багов. Если время нужно, используйте серверное или логические часы (монотонно растущий счётчик версии).

3.4. Оптимистичные обновления (вопрос 97)

Сценарий из банка полезно уметь пройти целиком, по шагам, потому что он проверяет сразу всё:

  1. Нажатие офлайн. В одной транзакции: likes = likes + 1, isLikedByMe = true в таблице постов и запись в outbox со статусом PENDING и клиентским UUID. UI обновился мгновенно, потому что подписан на Flow из базы.
  2. Приложение убито. Ничего не потеряно: обе записи на диске. При следующем запуске WorkManager восстановит запланированную работу.
  3. Сеть вернулась. Констрейнт воркера выполнен, система его будит. Воркер берёт PENDING, отправляет с Idempotency-Key.
  4. Ответ получен. Запись из outbox удаляется, значение счётчика заменяется серверным (важно: не оставлять локально посчитанное — за это время лайкнули и другие).
  5. Ответ — ошибка 4xx. Откат оптимистичного изменения и сообщение пользователю. Откат должен быть в той же транзакции, что и удаление из outbox.
  6. Ответ не получен (таймаут). Не откатываем и не считаем ошибкой — ретраим с тем же ключом. Именно здесь идемпотентность спасает от двойного лайка.

Отдельный момент, который добавляет очков: что показывать пользователю в шаге 1. Если UI ничем не отличает подтверждённое действие от неподтверждённого, то при откате в шаге 5 изменение «отыграется назад» неожиданно. Поэтому у элемента обычно есть третье, промежуточное представление — например, счётчик обновлён, но с пометкой «не отправлено».


4. Пагинация

4.1. Offset против cursor и что такое page drift (вопрос 88)

Offset (LIMIT 20 OFFSET 40) прост и позволяет прыгать на произвольную страницу. Его фатальная проблема в ленте — page drift: если между запросом страницы 3 и страницы 4 в начало списка добавились два элемента, то элементы на границе страниц повторятся, а если удалились — будут пропущены. Пользователь видит дубликаты в списке, и причину этого обычно ищут где угодно, только не в пагинации.

Cursor — это указатель на конкретную позицию в упорядоченной последовательности («дай 20 после элемента X»). Вставки и удаления выше курсора его не сдвигают, поэтому дрейфа нет.

Что стоит добавить, чтобы ответ был полным:

  • Курсор должен быть непрозрачным для клиента. Как только клиент начинает его разбирать, сервер теряет право менять реализацию.
  • Курсор требует стабильной сортировки. Если сортировать по неуникальному полю (например, только по дате), элементы с одинаковым значением могут разъезжаться — поэтому в ключ сортировки добавляют уникальное поле: (published_at, id).
  • Обратная сторона: по курсору нельзя прыгнуть на страницу N и трудно показать «страница 3 из 47». Для админок и таблиц offset часто остаётся правильным выбором.

4.2. Что даёт Paging 3 поверх самописного (вопрос 89)

Самописная пагинация — это обычно 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>>.

4.3. Главная претензия к Paging и что с ней стало

Классическое возражение против Paging: PagingData нельзя вынести за пределы UI-слоя. Его нельзя скомбинировать с другим потоком, отфильтровать по состоянию экрана или положить в общий UiState — он предназначен для того, чтобы течь напрямую в список. Из-за этого архитектура экрана с пагинацией всегда получалась «особенной», и это была честная причина отказываться от библиотеки.

В свежих версиях появился способ получить из потока пагинации обычный снимок списка, пригодный для комбинирования и хранения в состоянии. Цена — за подгрузку следующих страниц теперь отвечает не скролл, а явные вызовы «загрузить дальше». То есть библиотека дала выбор между двумя моделями: привычной автоматической и ручной, но совместимой с обычным UDF.

Если на интервью прозвучит «Paging плохо ложится на нашу архитектуру состояния» — это ровно тот момент, где стоит сказать, что ограничение было настоящим, но перестало быть абсолютным.


5. Сеть

5.1. addInterceptor против addNetworkInterceptor (вопрос 92)

Разница в том, где интерцептор стоит относительно кэша и редиректов.

Application interceptor (addInterceptor) Network interceptor (addNetworkInterceptor)
Вызывается один раз на логический запрос на каждый реальный сетевой запрос
При ответе из кэша вызывается не вызывается
При редиректе/ретрае один раз на каждый хоп
Видит запрос как его написал вызывающий финальный запрос со всеми заголовками OkHttp

Практические выводы, из-за которых это и спрашивают:

  • Авторизацию вешают на application interceptor: она логическая, и её не нужно применять к каждому редиректу (иначе токен уедет на чужой хост при редиректе).
  • Логирование того, что реально ушло в сетьContent-Length, Accept-Encoding, добавленными OkHttp) возможно только на network interceptor.
  • Если ответ пришёл из кэша, application interceptor это увидит, а network — нет. Поэтому метрики «сколько запросов реально ушло» считают на network interceptor.

5.2. Обновление токена без гонок (вопрос 93)

Сценарий: токен протух, пять параллельных запросов получили 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 — там же его тестирование.

5.3. Таймауты, ретраи и кэш

Таймауты в 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-кэш обслуживает «не тянуть то же самое повторно», а не «показать данные без сети как первоклассное состояние».

5.4. Выбор транспорта (вопрос 96)

Транспорт Когда На что смотреть
REST запрос-ответ, кэшируемость, простота дефолт; хорошо кэшируется по HTTP
GraphQL много разных экранов на одних данных, клиент диктует форму сложнее кэшировать, риск тяжёлых запросов, нужен контроль глубины
Polling редкие обновления, простота важнее свежести батарея; интервал должен расти в фоне
SSE односторонний поток от сервера, работает поверх HTTP проще WebSocket, но только сервер→клиент
WebSocket двусторонний обмен, низкая латентность (чат, торги) своя реконнект-логика, heartbeat, батарея

Для двух последних обязательно рассказать про деградацию: реконнект с экспоненциальным backoff и джиттером, heartbeat/ping для обнаружения «молчащего» соединения (TCP может очень долго не замечать обрыв), догон пропущенного через обычный REST по курсору последнего полученного события, и отключение сокета при уходе приложения в фон с переходом на push.


6. Хранилища и кэш

6.1. Что где хранить (вопрос 94)

Хранилище Для чего Ключевые свойства
Room структурированные данные, запросы, связи транзакции, миграции, реактивные запросы
DataStore настройки, флаги, небольшое состояние асинхронный, транзакционный, на корутинах и Flow
Файлы блобы, медиа, экспорт вы сами отвечаете за атомарность
SharedPreferences легаси синхронное чтение, опасный API

Почему SharedPreferences считается легаси — вопрос, который стоит уметь развернуть:

  • Весь файл читается в память при первом обращении, синхронно, и первое обращение часто происходит на главном потоке в момент старта.
  • commit() пишет на диск синхронно и возвращает результат — на главном потоке это прямой путь к ANR.
  • apply() пишет асинхронно, но записи ставятся в очередь, и эта очередь дожидается завершения в момент onPause/onStop компонентов. Поэтому пачка apply() тоже даёт ANR, просто не там, где её сделали, — этот механизм разобран в 11-concurrency-deep.md.
  • Нет транзакционности между несколькими значениями и нет вменяемой обработки ошибок записи.

DataStore решает всё перечисленное, но у него есть своя цена, которую полезно назвать: чтение всегда асинхронное (нельзя «просто прочитать значение» в момент старта, что иногда неудобно), на один файл должен быть ровно один экземпляр на процесс, и при повреждении файла нужен явный corruptionHandler, иначе получите исключение вместо данных.

6.2. Многоуровневый кэш (вопрос 95)

Сценарий из банка — спроектировать кэш изображений. Каркас:

Уровни.

  1. ПамятьLruCache, размер в байтах (не в штуках!), обычно доля от ActivityManager.memoryClass. Держит готовые к отрисовке объекты.
  2. Диск — размер в мегабайтах, переживает перезапуск. Обычно это DiskLruCache; учтите, что это не класс платформы, а реализация из библиотек (OkHttp, Glide) или скопированная в проект — на этом иногда ловят уточняющим вопросом.
  3. Сеть — источник истины.

Промах на каждом уровне проваливается ниже, попадание — поднимается наверх.

Ключ. Не URL, а URL плюс параметры трансформации (целевой размер, обрезка). Иначе миниатюра и полноразмерная картинка перезапишут друг друга — классический баг.

Вытеснение. LRU по умолчанию; для медиа иногда лучше LFU или сегментированный кэш, чтобы одна прокрутка длинной ленты не вымыла всё полезное (это называется cache pollution и его стоит упомянуть).

Инвалидация — самая сложная часть, и ответ на неё зависит от того, меняется ли содержимое по тому же URL:

  • если URL контентно-адресуемый (в нём хеш) — инвалидация не нужна вообще, это лучший вариант, и его стоит предложить как требование к бэкенду;
  • если нет — TTL плюс валидация по ETag;
  • ручной сброс по событию (пользователь сменил аватар) должен чистить оба уровня, и про память забывают чаще, чем про диск.

Что сказать в конце: в реальном проекте это не пишут руками — берут Coil или Glide, где всё перечисленное уже есть, включая привязку к жизненному циклу и отмену загрузки для переиспользуемых ячеек. Умение спроектировать нужно, чтобы понимать, что именно делает библиотека и где она может ошибаться.

6.3. Выбор библиотек: что сегодня правда

Короткий срез, чтобы не цитировать устаревшее:

  • Сериализация. 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-проектах.

6.4. Экономия трафика

Короткий набор, который закрывает 🟢-пункт чеклиста: сжатие (gzip/brotli, обычно уже включено на уровне OkHttp), дельта-синхронизация вместо полной выгрузки (клиент присылает версию, сервер отдаёт только изменения), батчинг мелких запросов, разные политики для Wi-Fi и мобильной сети (NetworkType.UNMETERED в констрейнтах WorkManager для тяжёлого), пагинация вместо полной выгрузки и предзагрузка только того, что пользователь с высокой вероятностью откроет.


7. Чеклист самопроверки

  1. Чем offline-first отличается от «добавили кэш» и что усложняется?
  2. Почему у UI должен быть ровно один источник чтения?
  3. Flow или suspend в DAO: правило и механика инвалидации.
  4. Почему Room эмитит лишние значения и что с этим делать?
  5. Зачем @Transaction при @Relation?
  6. Что произойдёт, если поднять версию базы и забыть миграцию?
  7. Чем авто-миграции отличаются от ручных и когда первых не хватает?
  8. Как тестируют миграции и что должен проверять такой тест кроме успеха?
  9. Почему запись идёт в базу до сети и что обязано быть в одной транзакции?
  10. Три уровня идемпотентности. Где чаще всего ошибаются?
  11. Шесть стратегий разрешения конфликтов и критерий выбора между ними.
  12. Почему нельзя доверять часам клиента?
  13. Пройдите весь путь оптимистичного лайка: офлайн, смерть процесса, возврат сети, ошибка.
  14. Offset против cursor: что такое page drift и почему курсор непрозрачен?
  15. Что Paging 3 даёт поверх самописной пагинации и в чём роль RemoteMediator?
  16. Три значения LoadType и зачем нужна таблица ключей.
  17. Почему PagingData было нельзя вынести из UI-слоя и что изменилось?
  18. addInterceptor против addNetworkInterceptor: четыре различия и практические выводы.
  19. Пять запросов получили 401 — как обновить токен ровно один раз?
  20. Какая строка в решении с мьютексом на самом деле убирает гонку?
  21. Что ретраить, а что нет; зачем джиттер и Retry-After?
  22. Room, DataStore, файлы, SharedPreferences — критерии выбора.
  23. Почему пачка apply() может дать ANR и где именно?
  24. Спроектируйте многоуровневый кэш изображений: уровни, ключ, вытеснение, инвалидация.
  25. REST, GraphQL, SSE, WebSocket, polling — критерии и деградация.