Кэш и офлайн: база как источник истины
- Зачем это нужно
- Две стратегии и цена ожидания
- Кто источник истины
- Почему
refresh()возвращаетUnit - Отметка времени
- Когда кэш протух
- Что кэшировать, а что нет
- Обновление по требованию
- Картинки
- Выход из аккаунта
- Проверка авиарежимом
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
У нас есть два умения, которые пока не связаны друг с другом. Приложение ходит в сеть — глава про 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 мс
Три вывода из трёх сценариев.
Обычный вход: почти секунда пустого экрана против нуля. Данные всё равно обновятся, но пользователь уже читает список, а не смотрит на крутилку.
Авиарежим: здесь разница принципиальная. Первый подход показывает ошибку вместо данных, которые лежат на диске. Второй показывает список и честно сообщает, что обновить не вышло.
Первый запуск без сети: обе стратегии беспомощны, и это важно признать. Кэш не волшебство — показывать нечего, потому что ничего ещё не загружали. Здесь нужен нормальный экран ошибки из главы про отказы сети.
Кто источник истины
Официальная рекомендация Android формулируется так: локальный источник — канонический источник истины, а сеть — способ его обновить.
Практическое следствие проще, чем звучит: экран никогда не подписывается на ответ сети. Он подписан на базу. Сеть кладёт данные в базу, а дальше срабатывает механизм из главы про хранение — запрос с 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 состояние собирали в один объект, а не в россыпь флагов.
Отметка времени
Без неё кэш вечен: однажды загруженные данные лежат годами, и приложение показывает прошлогодние цены, потому что повода обновиться не возникает.
Лечится одним полем в сущности:
@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, когда потянул список вниз вручную.
Отсюда правило: времени мало, нужен ещё сброс по действию. Всё, что меняет данные на сервере — добавление, удаление, оформление заказа, — обязано помечать кэш устаревшим. Цена — один лишний запрос из шести. Плата за то, что пользователь видит результат собственного действия сразу.
Считать возраст кэша по часам устройства — приём с оговоркой. Пользователь может перевести время вручную, и тогда кэш либо станет «вечно свежим», либо начнёт протухать при каждом открытии. Для чемпионата этого достаточно, но если сервер присылает своё время в заголовке ответа, надёжнее опираться на него.
Что кэшировать, а что нет
Кэшируют то, что дорого получать и не страшно показать чуть устаревшим:
- Да: каталог, категории, карточки товаров, новости, профиль, картинки.
- Спорно: корзина и заказы. Их показывают из кэша, но перед оформлением обязательно обновляют — иначе пользователь оформит заказ по вчерашней цене.
- Нет: одноразовые коды, результаты платежей, всё, где устаревшие данные означают ошибку, а не неудобство.
Простой вопрос для проверки: что будет плохого, если пользователь увидит эти данные пятиминутной давности? Если ничего — кэшируйте. Если он примет на их основе решение, которое нельзя отменить, — не кэшируйте.
Обновление по требованию
Кроме автоматического обновления нужен ручной способ — жест «потянуть вниз». В 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() // и кэш тоже
}
Правило: всё, что относится к пользователю, чистится вместе. Каталог товаров можно оставить — он общий; корзину, заказы и профиль нужно стереть.
Проверка авиарежимом
Приёмочный сценарий целиком, ровно тот, который выполнит эксперт:
- Открыть экран при работающей сети — список загрузился.
- Полностью закрыть приложение, смахнув из недавних.
- Включить авиарежим.
- Открыть приложение снова.
Должно быть: список на месте, картинки на месте, надпись о времени последнего обновления, сообщение о неудачном обновлении — и ни одного падения. Проверьте это сами до защиты; на эмуляторе авиарежим включается за две секунды.
Отдельно проверьте первый запуск без сети — на чистой установке. Там кэша нет, и экран обязан показать нормальную ошибку с кнопкой, а не пустоту.
Типичные ошибки
Экран подписан на сеть, а не на базу. Работает, пока экран один. Как только данные меняются в другом месте приложения, второй экран об этом не узнаёт.
Кэш без 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. Кэш — как раз то место, где ошибку легко проглотить молча: сеть не ответила, показали старые данные, и всё выглядит рабочим.
Что дальше
Данные теперь переживают отсутствие сети, а экран не пустеет. Следующая большая тема — сам пользователь: вход, регистрация, живая сессия и то, как приложение решает, кого куда пускать. Там же пригодится и хранилище из прошлой главы, и кэш из этой — потому что при выходе чистить придётся оба.
Углубиться
NetworkBoundResource— типовой шаблон, оформляющий связку «база плюс сеть» в один переиспользуемый класс.WorkManager— обновление кэша в фоне по расписанию и отложенная отправка того, что не ушло без сети.- Синхронизация правок: что делать, когда пользователь менял данные офлайн и они разошлись с серверными.
- Постраничный кэш: библиотека Paging с
RemoteMediator, когда список длиннее, чем помещается в память. - Ограничение размера кэша: чистка старых записей, чтобы база не росла бесконечно.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Офлайн-первый подход — developer.android.com — первоисточник про единый источник истины.
- Слой данных — репозитории и работа с несколькими источниками.
- Room: асинхронные запросы — подписки на таблицы и их поведение.
- Coil: кэширование изображений — как устроен кэш картинок в памяти и на диске.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.