Перейти к основному содержимому
Мобилка33ГЛАВА 33Кэш и офлайн: база как источник истинысетьданные от 12:40работает без сети

Кэш и офлайн: база как источник истины

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

У нас есть два умения, которые пока не связаны друг с другом. Приложение ходит в сеть — глава про Retrofit. Приложение сохраняет данные на устройстве — глава про Room. Но репозиторий по-прежнему обращается только к сети и отдаёт ошибку, если её нет, а база лежит рядом без дела.

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

Главная мысль главы одна, и она не про технику: источник истины — база, а не сеть. Сеть только обновляет базу, а экран всегда смотрит на базу. Всё остальное — следствия.

Две стратегии и цена ожидания​

Сначала посмотрим, что видит пользователь при каждом подходе. Модель считает не «сработало или нет», а миллисекунды пустого экрана:

// Модель: одни и те же данные, две стратегии загрузки. // Часы виртуальные: сеть отвечает за 900 мс, чтение из базы — за 4 мс. object Clock { var ms = 0L fun reset() { ms = 0 } fun tick(d: Long) { ms += d } } class Net(var online: Boolean) { fun fetch(): List<String> { Clock.tick(900) if (!online) throw RuntimeException("нет соединения") return listOf("Кофе 250", "Чай 180", "Какао 200") } } class Db(var rows: List<String>, var stamp: String?) { fun read(): List<String> { Clock.tick(4); return rows } fun write(new: List<String>, at: String) { Clock.tick(6); rows = new; stamp = at } } var blankMs = 0L var lastFrameAt = 0L var lastFrameBlank = false fun frame(blank: Boolean, text: String) { if (lastFrameBlank) blankMs += Clock.ms - lastFrameAt lastFrameAt = Clock.ms lastFrameBlank = blank println(" %4d мс | %s".format(Clock.ms, text)) } fun startScreen() { Clock.reset(); lastFrameAt = 0; lastFrameBlank = false } fun networkFirst(net: Net, db: Db) { startScreen() frame(true, "[крутилка]") try { val fresh = net.fetch() db.write(fresh, "12:53") frame(false, "[список] ${fresh.size} позиции") } catch (e: Exception) { frame(true, "[ошибка] Нет соединения. Повторить") } } fun cacheFirst(net: Net, db: Db) { startScreen() val saved = db.read() if (saved.isEmpty()) frame(true, "[крутилка]") else frame(false, "[список] ${saved.size} позиции · данные от ${db.stamp}") try { val fresh = net.fetch() db.write(fresh, "12:53") frame(false, "[список] ${fresh.size} позиции · данные от ${db.stamp}") } catch (e: Exception) { if (saved.isEmpty()) frame(true, "[ошибка] Нет соединения. Повторить") else frame(false, "[список] ${saved.size} позиции · обновить не удалось, данные от ${db.stamp}") } } fun run(title: String, online: Boolean, cached: List<String>) { println("== $title ==") blankMs = 0 println(" network-first:") networkFirst(Net(online), Db(cached.toList(), if (cached.isEmpty()) null else "12:40")) val nfBlank = blankMs blankMs = 0 println(" cache-first:") cacheFirst(Net(online), Db(cached.toList(), if (cached.isEmpty()) null else "12:40")) val cfBlank = blankMs println(" пользователь смотрел на пустой экран: network-first $nfBlank мс, cache-first $cfBlank мс") println() } fun main() { val cached = listOf("Кофе 250", "Чай 180", "Какао 200") run("сеть есть, кэш есть (обычный повторный вход)", online = true, cached = cached) run("авиарежим, кэш есть", online = false, cached = cached) run("авиарежим, первый запуск (кэш пуст)", online = false, cached = emptyList()) }

Живой вывод:

== сеть есть, кэш есть (обычный повторный вход) ==
network-first:
0 мс | [крутилка]
906 мс | [список] 3 позиции
cache-first:
4 мс | [список] 3 позиции · данные от 12:40
910 мс | [список] 3 позиции · данные от 12:53
пользователь смотрел на пустой экран: network-first 906 мс, cache-first 0 мс

== авиарежим, кэш есть ==
network-first:
900 мс | [ошибка] Нет соединения. Повторить
cache-first:
4 мс | [список] 3 позиции · данные от 12:40
904 мс | [список] 3 позиции · обновить не удалось, данные от 12:40

== авиарежим, первый запуск (кэш пуст) ==
пользователь смотрел на пустой экран: network-first 900 мс, cache-first 900 мс
СКОЛЬКО ПОЛЬЗОВАТЕЛЬ СМОТРИТ НА ПУСТОЙ ЭКРАНсначала сетькрутилка · 906 мссписоксначала кэшсписок уже на экране · данные от 12:40обновлён4 мс до первого содержимого вместо 906 — и в авиарежиме экран не пустеетно при пустом кэше обе стратегии одинаково беспомощны: показывать нечего
При наполненном кэше содержимое появляется через 4 мс вместо 906; при пустом обе стратегии одинаково беспомощны

Три вывода из трёх сценариев.

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

Авиарежим: здесь разница принципиальная. Первый подход показывает ошибку вместо данных, которые лежат на диске. Второй показывает список и честно сообщает, что обновить не вышло.

Первый запуск без сети: обе стратегии беспомощны, и это важно признать. Кэш не волшебство — показывать нечего, потому что ничего ещё не загружали. Здесь нужен нормальный экран ошибки из главы про отказы сети.

Кто источник истины​

Официальная рекомендация Android формулируется так: локальный источник — канонический источник истины, а сеть — способ его обновить.

БАЗА В СЕРЕДИНЕ, А НЕ СБОКУСетьтолько пишет в базуrefreshБазаисточник истиныFlowЭкранподписан на базуrefresh() ничего не возвращает — и это правильно: экран узнаёт об обновлении от базыданные, записанные в любом месте приложения, доходят до экрана самиесли экран подписан на ответ сети, второй экран о правках не узнает
Сеть пишет в базу, экран подписан на базу; данные доходят до экрана сами

Практическое следствие проще, чем звучит: экран никогда не подписывается на ответ сети. Он подписан на базу. Сеть кладёт данные в базу, а дальше срабатывает механизм из главы про хранение — запрос с Flow будит экран сам.

Отсюда репозиторий с двумя источниками и двумя методами:

class ProductRepositoryImpl(
private val dao: ProductDao,
private val api: ProductApi,
) : ProductRepository {

/** Наблюдение за данными: всегда из базы, никогда из сети. */
override fun observeProducts(): Flow<List<Product>> =
dao.observeAll().map { rows -> rows.map { it.toDomain() } }

/** Обновление: сходить в сеть и записать в базу. Ничего не возвращает. */
override suspend fun refresh() {
val fresh = api.getProducts().map { it.toEntity(nowMillis()) }
dao.upsertAll(fresh)
}
}
Нажми на часть выражения

Почему refresh() возвращает Unit​

Это место, где сначала кажется, что чего-то не хватает. Функция сходила в сеть, получила данные — и ничего не вернула. Проверим, что при этом происходит с экраном:

import kotlinx.coroutines.* import kotlinx.coroutines.flow.* // Модель «база — источник истины». DAO ведёт себя как настоящий Room: // он оповещает подписчиков при ЛЮБОЙ записи в таблицу, даже если строки те же. data class Item(val id: Int, val title: String) class RoomLikeDao { private val invalidations = MutableStateFlow(0) private var rows: List<Item> = emptyList() fun observe(): Flow<List<Item>> = invalidations.map { rows } suspend fun upsertAll(items: List<Item>) { rows = items; invalidations.value++ } suspend fun setFavorite(id: Int) { invalidations.value++ } // запись в ту же таблицу } enum class Sync { Idle, Loading, Ok, Failed } class Api(var online: Boolean = true, var payload: List<Item> = emptyList()) { suspend fun list(): List<Item> { delay(20) if (!online) throw RuntimeException("нет соединения") return payload } } class Repo(val dao: RoomLikeDao, val api: Api) { val sync = MutableStateFlow(Sync.Idle) var stamp: String? = null suspend fun refresh(at: String) { // возвращает Unit sync.value = Sync.Loading try { dao.upsertAll(api.list()) stamp = at sync.value = Sync.Ok } catch (e: Exception) { sync.value = Sync.Failed } } } fun screen(rows: List<Item>, s: Sync, stamp: String?): String = when { rows.isNotEmpty() -> "[список] ${rows.size} шт · данные от $stamp" + if (s == Sync.Failed) " · обновить не удалось" else "" s == Sync.Loading -> "[крутилка]" s == Sync.Failed -> "[ошибка] Нет соединения. Повторить" s == Sync.Ok -> "[пусто] Здесь пока ничего нет" else -> "[крутилка]" } fun main() = runBlocking { println("── A. refresh() ничего не возвращает, а экран меняется ──") val dao = RoomLikeDao() val api = Api(payload = listOf(Item(1, "Кофе"), Item(2, "Чай"))) val repo = Repo(dao, api) val ui = launch { combine(dao.observe(), repo.sync) { rows, s -> screen(rows, s, repo.stamp) } .distinctUntilChanged() .collect { println(" экран: $it") } } delay(40) println(" repo.refresh(\"12:40\") вернул: ${repo.refresh("12:40")}") delay(40) api.online = false println(" авиарежим, repo.refresh(\"12:55\"):") repo.refresh("12:55") delay(40) ui.cancel() println() println("── B. Сколько раз база разбудит экран ──") val dao2 = RoomLikeDao() val api2 = Api(payload = listOf(Item(1, "Кофе"), Item(2, "Чай"))) val repo2 = Repo(dao2, api2) var raw = 0 var distinct = 0 val a = launch { dao2.observe().collect { raw++ } } val b = launch { dao2.observe().distinctUntilChanged().collect { distinct++ } } delay(30) repeat(3) { repo2.refresh("12:4$it") } // три раза подряд один и тот же ответ сервера delay(30) dao2.setFavorite(1) // правка соседней строки той же таблицы delay(30) a.cancel(); b.cancel() println(" 3 одинаковых ответа сервера + правка соседней строки") println(" эмиссий из DAO без distinctUntilChanged: $raw") println(" эмиссий из DAO с distinctUntilChanged: $distinct") println() println("── C. Список пуст. Три разных экрана ──") val empty = emptyList<Item>() println(" кэш пуст, обновление идёт → ${screen(empty, Sync.Loading, null)}") println(" кэш пуст, сервер ответил «[]» → ${screen(empty, Sync.Ok, "12:40")}") println(" кэш пуст, сети не было → ${screen(empty, Sync.Failed, null)}") println(" rows.isEmpty() во всех трёх случаях: ${empty.isEmpty()}") }

Живой вывод:

── A. refresh() ничего не возвращает, а экран меняется ──
экран: [крутилка]
repo.refresh("12:40") вернул: kotlin.Unit
экран: [список] 2 шт · данные от 12:40
авиарежим, repo.refresh("12:55"):
экран: [список] 2 шт · данные от 12:40 · обновить не удалось

── B. Сколько раз база разбудит экран ──
3 одинаковых ответа сервера + правка соседней строки
эмиссий из DAO без distinctUntilChanged: 5
эмиссий из DAO с distinctUntilChanged: 2

── C. Список пуст. Три разных экрана ──
кэш пуст, обновление идёт → [крутилка]
кэш пуст, сервер ответил «[]» → [пусто] Здесь пока ничего нет
кэш пуст, сети не было → [ошибка] Нет соединения. Повторить
rows.isEmpty() во всех трёх случаях: true

Разберём все три части — в них три отдельных урока.

A. Экран обновился, хотя функция вернула kotlin.Unit. Данные пришли не через возвращаемое значение, а через базу: refresh записал, подписка сработала. Это и есть смысл единого источника истины. Побочный эффект: в авиарежиме экран остался с прежними данными и пометкой, что обновить не вышло, — вместо того чтобы опустеть.

B. База будит экран чаще, чем кажется. Room оповещает подписчиков при любой записи в таблицу, а не когда изменился результат конкретного запроса. Три одинаковых ответа сервера подряд плюс правка соседней строки дали пять эмиссий вместо двух. Лечится оператором из главы про потоки:

dao.observeAll()
.distinctUntilChanged()
.map { rows -> rows.map { it.toDomain() } }

C. Пустой список бывает трёх разных видов, и rows.isEmpty() не различает их вовсе. По одной пустоте состояние экрана определить нельзя — нужны ещё флаг загрузки и признак ошибки. Это ровно та причина, по которой в главе про ViewModel состояние собирали в один объект, а не в россыпь флагов.

СПИСОК ПУСТ — НО ПРИЧИНЫ РАЗНЫЕобновление идёт[крутилка]кэш пуст, запрос в путисервер вернул «[]»[пусто]ничего не найденосети не было[ошибка]нет соединения · повторитьrows.isEmpty() во всех трёх случаях: trueпо одной пустоте состояние не определить — нужны флаг загрузки и признак ошибки
Одна и та же пустая таблица требует трёх разных экранов; различить их можно только по флагам загрузки и ошибки
Отвечено вопросов: 0 из 2
Почему refresh() не возвращает загруженные данные?
Список пуст. Как понять, что показывать пользователю?
Отвечено верно: 0 из 2

Отметка времени​

Без неё кэш вечен: однажды загруженные данные лежат годами, и приложение показывает прошлогодние цены, потому что повода обновиться не возникает.

Лечится одним полем в сущности:

@Entity(tableName = "products")
data class ProductEntity(
@PrimaryKey val id: Int,
val title: String,
val price: Double,
val updatedAt: Long, // System.currentTimeMillis() в момент записи
)

Оно решает сразу две задачи. Первая — решить, идти ли в сеть. Вторая — показать пользователю честную надпись «данные от 12:40», как в модели выше. Такая надпись стоит копейки в реализации и снимает у пользователя главный вопрос офлайна: «это актуально или я смотрю на старое?»

Когда кэш протух​

Есть два повода обновиться, и одного из них недостаточно. Смоделируем день пользователя:

// Модель инвалидации: кэш протухает по времени (TTL) и по действию пользователя. // Время — минуты от полуночи. TTL = 5 минут. const val TTL = 5 class Cache(var rows: List<String> = emptyList(), var updatedAt: Int = -1) { fun ageAt(now: Int) = if (updatedAt < 0) 9999 else now - updatedAt fun invalidate() { updatedAt = -1 } } fun hhmm(m: Int) = "%02d:%02d".format(m / 60, m % 60) var requests = 0 // force = pull-to-refresh: пользователь потянул список вниз fun open(now: Int, cache: Cache, server: List<String>, force: Boolean = false): String { val age = cache.ageAt(now) val ageText = if (cache.updatedAt < 0) " нет" else "%3d м".format(age) return if (age < TTL && !force) { "$ageText ${"свеж".padEnd(13)} → запроса нет → ${cache.rows} · данные от ${hhmm(cache.updatedAt)}" } else { requests++ cache.rows = server cache.updatedAt = now val why = if (force) "потянули вниз" else "протух" "$ageText ${why.padEnd(13)} → ЗАПРОС в сеть → ${cache.rows} · данные от ${hhmm(now)}" } } fun scenario(title: String, invalidateOnAction: Boolean) { println("== $title ==") requests = 0 val cache = Cache() var server = listOf("Кофе", "Чай") println(" время возраст почему запрос что на экране") fun step(t: Int, what: String, force: Boolean = false) = println(" ${hhmm(t)} ${open(t, cache, server, force)} $what") step(12 * 60 + 40, "← первый вход") step(12 * 60 + 42, "← вернулись на экран") step(12 * 60 + 44, "← вернулись ещё раз") step(12 * 60 + 47, "← прошло 7 минут") // пользователь добавил товар: сервер уже знает о нём, кэш — ещё нет server = server + "Какао (новый)" if (invalidateOnAction) cache.invalidate() step(12 * 60 + 48, "← после добавления товара") step(12 * 60 + 49, "← потянул список вниз", force = true) println(" сетевых запросов: $requests") println() } fun main() { scenario("только TTL", invalidateOnAction = false) scenario("TTL + сброс кэша после действия", invalidateOnAction = true) }

Живой вывод:

== только TTL ==
12:40 нет протух → ЗАПРОС в сеть → [Кофе, Чай] · данные от 12:40 ← первый вход
12:42 2 м свеж → запроса нет → [Кофе, Чай] · данные от 12:40 ← вернулись на экран
12:47 7 м протух → ЗАПРОС в сеть → [Кофе, Чай] · данные от 12:47 ← прошло 7 минут
12:48 1 м свеж → запроса нет → [Кофе, Чай] · данные от 12:47 ← после добавления товара
12:49 2 м потянули вниз → ЗАПРОС в сеть → [Кофе, Чай, Какао (новый)] ← потянул список вниз
сетевых запросов: 3

== TTL + сброс кэша после действия ==
12:48 нет протух → ЗАПРОС в сеть → [Кофе, Чай, Какао (новый)] · данные от 12:48 ← после добавления товара
сетевых запросов: 4

Смотрите на строку 12:48. Пользователь сам только что добавил товар — и не увидел его, потому что по времени кэш ещё свежий. Обнаружил он это только в 12:49, когда потянул список вниз вручную.

Отсюда правило: времени мало, нужен ещё сброс по действию. Всё, что меняет данные на сервере — добавление, удаление, оформление заказа, — обязано помечать кэш устаревшим. Цена — один лишний запрос из шести. Плата за то, что пользователь видит результат собственного действия сразу.

Важно

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

Отвечено вопросов: 0 из 2
Пользователь добавил товар, вернулся в список — товара нет. Почему?
Зачем нужен distinctUntilChanged на потоке из Room?
Отвечено верно: 0 из 2

Что кэшировать, а что нет​

Кэшируют то, что дорого получать и не страшно показать чуть устаревшим:

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

Простой вопрос для проверки: что будет плохого, если пользователь увидит эти данные пятиминутной давности? Если ничего — кэшируйте. Если он примет на их основе решение, которое нельзя отменить, — не кэшируйте.

Обновление по требованию​

Кроме автоматического обновления нужен ручной способ — жест «потянуть вниз». В Material 3 он готовый:

PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = viewModel::onRefresh,
) {
LazyColumn { items(state.items, key = { it.id }) { ProductCard(it) } }
}

Важная деталь: isRefreshing — не то же самое, что isLoading из главы про ViewModel. Первая загрузка показывает крутилку на весь экран, потому что показывать нечего. Обновление по жесту происходит поверх уже видимого списка — экран при этом не должен пустеть. Это два разных флага в состоянии.

Картинки​

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

AsyncImage(
model = product.imageUrl,
contentDescription = product.title,
)

Проверить это так же просто, как остальной кэш: открыть экран при сети, включить авиарежим, вернуться. Картинки, которые уже показывались, обязаны нарисоваться.

Совет

Частая путаница: кэш OkHttp — не то же самое, что кэш данных. Строка .cache(Cache(dir, 10L * 1024 * 1024)) при сборке клиента выглядит как решение задачи, но она кэширует HTTP-ответы по правилам заголовков сервера. Если сервер запретил кэширование заголовками, кэша не будет, а требование останется незакрытым. Кэш, который проверяет эксперт, живёт в вашей базе.

Выход из аккаунта​

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

suspend fun logout() {
settings.clear() // токен
dao.clear() // и кэш тоже
}

Правило: всё, что относится к пользователю, чистится вместе. Каталог товаров можно оставить — он общий; корзину, заказы и профиль нужно стереть.

Проверка авиарежимом​

Приёмочный сценарий целиком, ровно тот, который выполнит эксперт:

  1. Открыть экран при работающей сети — список загрузился.
  2. Полностью закрыть приложение, смахнув из недавних.
  3. Включить авиарежим.
  4. Открыть приложение снова.

Должно быть: список на месте, картинки на месте, надпись о времени последнего обновления, сообщение о неудачном обновлении — и ни одного падения. Проверьте это сами до защиты; на эмуляторе авиарежим включается за две секунды.

Отдельно проверьте первый запуск без сети — на чистой установке. Там кэша нет, и экран обязан показать нормальную ошибку с кнопкой, а не пустоту.

Типичные ошибки​

Экран подписан на сеть, а не на базу. Работает, пока экран один. Как только данные меняются в другом месте приложения, второй экран об этом не узнаёт.

Кэш без updatedAt. Данные любой давности показываются вечно.

Забыт distinctUntilChanged. Экран перерисовывается на каждую запись в таблицу, даже не относящуюся к делу.

Кэш через Cache OkHttp вместо базы. Красивая строка в коде, а кэша нет: сервер управляет им заголовками.

Пустой кэш и пустой ответ одним экраном. Либо «нет соединения» при живой сети и честном пустом каталоге, либо вечная крутилка в авиарежиме.

Выход чистит токен, но не кэш. Следующий пользователь видит чужие данные.

Только TTL, без сброса по действию. Пользователь не видит результат собственного действия.

Слова главы​

  • Источник истины — место, откуда экран читает данные; здесь это база.
  • refresh() — обновление базы из сети; ничего не возвращает.
  • cache-first — сначала показать сохранённое, потом обновить.
  • TTL — срок, после которого кэш считается устаревшим.
  • Инвалидация — пометка кэша устаревшим: по времени или по действию.
  • updatedAt — отметка времени записи в кэш.
  • distinctUntilChanged — не пропускать значение, равное предыдущему.
  • PullToRefreshBox — обновление жестом «потянуть вниз».

Тренажёр: набрать вслепую​

Цель: скорость ≥ 100 зн/мин, точность ≥ 88%

override fun observeProducts(): Flow<List<Product>> = dao.observeAll()

На чемпионате​

«Реализуйте кэширование главной страницы» — прямая формулировка задания, и проверяется она поведением, а не кодом. Рядом стоят соседние требования, которые работают вместе с кэшем:

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

И не забудьте про множитель из прошлой главы: у репозитория кэша тоже должна быть шапка с датой и номером участника, а в catch вокруг сетевого вызова — Log.e. Кэш — как раз то место, где ошибку легко проглотить молча: сеть не ответила, показали старые данные, и всё выглядит рабочим.

Отвечено вопросов: 0 из 2
Эксперт включает авиарежим и открывает приложение. Что должно быть на экране?
Строка .cache(Cache(dir, 10 МБ)) при сборке OkHttp закрывает требование кэширования?
Отвечено верно: 0 из 2

Что дальше​

Данные теперь переживают отсутствие сети, а экран не пустеет. Следующая большая тема — сам пользователь: вход, регистрация, живая сессия и то, как приложение решает, кого куда пускать. Там же пригодится и хранилище из прошлой главы, и кэш из этой — потому что при выходе чистить придётся оба.

Отвечено вопросов: 0 из 2
Почему экран подписывают на базу, а не на результат сетевого запроса?
Что чистить при выходе из аккаунта?
Отвечено верно: 0 из 2

Углубиться​

  • NetworkBoundResource — типовой шаблон, оформляющий связку «база плюс сеть» в один переиспользуемый класс.
  • WorkManager — обновление кэша в фоне по расписанию и отложенная отправка того, что не ушло без сети.
  • Синхронизация правок: что делать, когда пользователь менял данные офлайн и они разошлись с серверными.
  • Постраничный кэш: библиотека Paging с RemoteMediator, когда список длиннее, чем помещается в память.
  • Ограничение размера кэша: чистка старых записей, чтобы база не росла бесконечно.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоCreate offline-first appsAndroid Developers
ВидеоOffline First Architecture - Android Developers Office HoursAndroid Developers
ВидеоThe 4 Types of Offline Caches On AndroidPhilipp Lackner
ВидеоДмитрий Васильев — Как кэшировать информацию в Android-приложенииMobius
ВидеоСохраняем локальные данные через Room (SQLite) + KotlinAndroid Heroes

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.