В предыдущей статье я рассказал о реализации механизма кэширования трендовых новостей в приложении TrendNow с помощью OkHttp Cache (что повысило эффективность приложения, хотя и не решило всех проблем). Можете ознакомиться с этой статьей, если пропустили ее:
Кэширование трендовых новостей в приложении TrendNow с помощью OkHttp Cache. Часть 6
В этой статье поделюсь с вами решением оставшихся проблем путем объединения реализации OkHttp Cache с базой данных Room.
Напоминаю, что исходный код в репозитории Github может отличаться от приведенных здесь фрагментов кода, поскольку я работал над ним до написания этой статьи.
В чем проблемы?
- OkHttp может проверять только доступность кэша, но нет способа проверить срок его действия.
- Приложение всегда будет использовать кэшированный ответ, когда он будет доступен, и пользователь не сможет обновлять трендовые новости.
Что я собираюсь делать?
- Использовать базу данных Room для сохранения метаданных кэша (например, createdAt).
- Объединить реализацию с OkHttp Cache с использованием базы данных для проверки срока действия кэша.
Подготовка кэша новостей
Сколько времени осталось до истечения срока действия кэша?
Я собираюсь использовать 18 часов в течение одного дня и часовой пояс устройства, поскольку:
- бесплатный тарифный план Bonai News API предусматривает 24-часовую задержку;
- кэширование трендовых новостей более чем на 24 часа не имеет смысла, так как новостные статьи обычно обновляются ежедневно или даже ежечасно, что делает старые данные менее актуальными.
Какие поля необходимы для метаданных?
url: URL трендовой новости.
parentUrl: первая страница URL трендовой новости, включая параметры запроса темы и языка.
createdAt: временная метка создания локального кэша.
parentUrl необходим, например, для обработки разных дат кэширования страниц трендовых новостей:
- Кэш трендовых новостей на общие темы доступен до 2-й страницы с разным значением
createdAt. - Когда приложение запрашивает 1-ю страницу, срок действия кэша новостей истекает, и оно получает результат из сети, а не из кэша.
- Но когда приложение запрашивает 2-ю страницу, кэш новостей все еще действителен, и оно получает результат из кэша.
- Трендовые новости на 2-й странице становятся недействительными.
Поэтому, когда срок действия кэша parentUrl истекает, нужно удалить все метаданные кэша URL следующей страницы (2-й, 3-й и т. д.).
Подготовка базы данных для метаданных кэша новостей
Сначала надо создать сущность и DAO для кэша новостей, а затем обновить базу данных NewsDatabase, чтобы добавить таблицу кэша новостей.
Создание сущности для кэша новостей
@Entity(
tableName = "news_cache",
// убедитесь в том, что поле url помечено как уникальное,
// чтобы можно было легко ввести новые данные с тем жe url
// для замены прежних данных
indices = [Index(value = ["url"], unique = true)]
)
data class NewsCache(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val url: String,
val parentUrl: String,
val createdAt: Long
)
- пометка
urlкак уникального: чтобы можно было легко вставить кэш новостей с тем жеurlдля замены существующих данных;
createdAt: в качестве метаданных, чтобы сообщить, когдаNewsCacheбыл добавлен в локальную базу данных.
url: полный URL конечной точки, включающий параметр запросаpage, напримерhttps://news-api14.p.rapidapi.com/v2/trendings?topic=general&language=en&page=3.
parentUrl: URL без параметров запросаpage, напримерhttps://news-api14.p.rapidapi.com/v2/trendings?topic=general&language=en.
Кэш новостей создается в файле NewsCache.kt в каталоге data/model/.
data/
├── model/
│ ├── News.kt
│ ├── NewsCache.kt
│ ├── ...
Создание DAO для кэша новостей
@Dao
interface NewsCacheDao {
@Query("SELECT * FROM news_cache WHERE url = :url")
suspend fun getNewsCache(url: String): NewsCache?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertNewsCache(newsCache: NewsCache)
@Query("DELETE FROM news_cache WHERE parentUrl = :parentUrl")
suspend fun deleteNewsCache(parentUrl: String)
}
getNewsCache(...): получение метаданных кэша новостей из локальной базы данных на основе определенного URL.insertNewsCache(...): вставка метаданных кэша в локальную базу данных.
deleteNewsCache(...): удаление всех метаданных кэша на основе определенногоparentUrl.
- По той же причине, что и в
TopicDao, я не использую Flow дляgetNewsCache(...): мне не нужно, чтобыNewsCacheбыл реактивным.
DAO для кэша новостей создается в файле NewsCacheDao.kt в каталоге data/local/.
data/
├── local/
│ ├── NewsCacheDao.kt
│ ├── TopicDao.kt
Обновление базы данных новостей
@Database(entities = [..., NewsCache::class], ...) // добавить сущность NewsCache
abstract class NewsDatabase : RoomDatabase() {
...
// добавить NewsCacheDao
abstract fun newsCacheDao(): NewsCacheDao
}
Просто добавляю новую таблицу, не изменяя существующую структуру таблиц. Поэтому не нужно создавать миграцию базы данных, приложение должно работать отлично.
Ознакомьтесь с исходным кодом на Github: NewsCache.kt, NewsCacheDao.kt и NewsDatabase.kt.
Создание менеджера кэша новостей
Менеджер кэша новостей используется для проверки доступности кэша и вставки новых метаданных кэша в базу данных.
@Singleton
class NewsCacheManager @Inject constructor(
// внедрение синглтона Okhttp Cache
private val cache: Cache,
// внедрение NewsCacheDao
private val newsCacheDao: NewsCacheDao,
// обходной путь, чтобы мы могли легко сымитировать текущее время даты в модульных тестах
private val currentDateProvider: () -> Calendar = {
Calendar.getInstance()
}
) {
/**
* Проверка того, доступен или недоступен кэш данного url.
* Если он доступен, проверьте, действителен ли он еще или уже недействителен.
*
* @param url - полный url конечной точки, включая параметры запроса
* @return - использовать ли кэш для данного url или нет
*/
suspend fun isCacheAvailable(url: String): Boolean {
TODO("Not yet implemented")
}
/**
* Добавьте новости в локальную базу данных.
* При этом будут кэшироваться только метаданные новостей.
* Кэш новостного контента будет обрабатываться Okhttp Cache.
*
* @param url - полный url конечной точки, включая параметры запроса
*/
suspend fun addNewsCache(url: String) {
TODO("Not yet implemented")
}
}
Проверка доступности кэша
suspend fun isCacheAvailable(url: String): Boolean {
// проверьте доступность кэша в Okhttp Cache
val responseCache = cache.urls().find { it == url }
if (responseCache == null) {
// кэш недоступен
return false
}
// кэш доступен
// необходимо проверить действительность кэша
val cacheDateInMillis = newsCacheDao.getNewsCache(url)?.createdAt
if (cacheDateInMillis != null) {
// календарь на сегодня
val todayCalendar = currentDateProvider()
// календарь кэша
val cacheCalendar = Calendar.getInstance().apply {
timeInMillis = cacheDateInMillis
timeZone = todayCalendar.timeZone
}
// различия между кэшами в часах
val diffInHours = TimeUnit.HOURS.fromMs(
todayCalendar.timeInMillis - cacheCalendar.timeInMillis
)
if (diffInHours < 0) {
// Это означает, что дата кэширования наступила после сегодняшнего дня,
// что, скорее всего, говорит о недействительности
return false
}
// MAX_NEWS_CACHE_IN_HOURS должно быть 18
return (diffInHours < MAX_NEWS_CACHE_IN_HOURS)
// убедитесь, что это все еще в пределах одного дня
&& (todayCalendar.day() == cacheCalendar.day()
&& todayCalendar.month() == cacheCalendar.month()
&& todayCalendar.year() == cacheCalendar.year())
}
return false
}
cache.urls().find { it == url }: проверка наличия кэша вOkHttpCache.
newsCacheDao.getNewsCache(url)?.createdAt: получение метаданных кэшаcreatedAtиз локальной базы данных.
diffInHours < 0: предотвращение недействительного кэша при изменении даты на устройстве.
currentDateProvider().timeInMillis: использование даты устройства в качествеcreatedAtкэша. Понимаю, что это может вызвать проблему, если пользователь изменит дату на своем устройстве, но это все, что у меня есть. При наличии описанной выше логики для решения подобного рода проблем (diffInHours < 0) все должно быть в порядке.
- Сравнение даты кэша и сегодняшней даты для определения действительности кэша.
Добавление метаданных кэша новостей
suspend fun addNewsCache(url: String) {
val parentUrl = getParentUrl(url)
if (parentUrl == url) {
// Существует 2 ситуации, при которых вызывается эта часть:
// 1. кэш новостей устарел и нуждается в обновлении,
// 2. загрузка новостей в первый раз.
// Если кэш новостей устарел,
// это означает, что кэш для этого url недействителен;
// поэтому нам нужно вручную удалить кэш родительского url,
// включая дочерние кэши.
newsCacheDao.deleteNewsCache(parentUrl = parentUrl)
}
val cache = NewsCache(
url = url,
parentUrl = parentUrl,
createdAt = currentDateProvider().timeInMillis
)
newsCacheDao.insertNewsCache(cache)
}
getParentUrl(url): получение parentUrl, URL без параметров запросаpage. Я просто использую регулярное выражение для замены слова&page=*на пустую строку.
newsCacheDao.deleteNewsCache(parentUrl = parentUrl): удаление всех кэшей новостей с конкретнымparentUrl, чтобы сделать их недействительными.

Менеджер кэша новостей создается в файле NewsCacheManager.kt в каталоге core/cache/.
core/
├── cache/
│ ├── NewsCacheManager.kt
Ознакомьтесь с исходным кодом на Github: NewsCacheManager.kt.
Регистрация менеджера кэша новостей для внедрения зависимостей
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideNewsCacheManager(
cache: Cache,
newsCacheDao: NewsCacheDao,
): NewsCacheManager = NewsCacheManager(cache, newsCacheDao)
}
- Использую
@Singletonи выполняю регистрацию вSingletonComponent, потому что менеджер кэша новостей будет использоваться глобально во всем приложении.
Модуль приложения создается в файле AppModule.kt в каталоге di/.
di/
├── AppModule.kt
Реализация менеджера кэша новостей
Сначала нужно обновить реализацию в отношении перехватчика кэша и удаленного источника данных новостей.
Обновление перехватчика кэша
class CacheInterceptor @Inject constructor(
@ApplicationContext private val context: Context,
// внедрение менеджера кэша новостей
private val newsCacheManager: NewsCacheManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
...
// проверить, доступен ли кэш или нет;
// здесь не нужно фильтровать url конечной точки,
// фильтрация должна осуществляться в менеджере кэша новостей
val isCacheAvailable = runBlocking(Dispatchers.IO) {
newsCacheManager.isCacheAvailable(url)
}
val newRequest = if (isCacheAvailable) {
requestBuilder
.cacheControl(CacheControl.FORCE_CACHE)
.build()
} else {
requestBuilder.build()
}
return chain.proceed(newRequest)
}
}
- Замените проверку кэша с
cache.urls().find { it == url }наnewsCacheManager.isCacheAvailable(url).
Обновление удаленного источника данных новостей
Сначала надо создать новый класс модели для результата трендовых новостей, потому что нам понадобятся две переменные:
fromCache: Boolean: чтобы определить, откуда получен результат — из кэша или сети.
url: String: полный URL-адрес конечной точки трендовых новостей.
data class NewsResult(
val data: List<News>,
val fromCache: Boolean,
val url: String
)
Результат новостей создается в файле NewsResult.kt в каталоге data/mode/.
data/
├── model/
│ ├── NewsResult.kt
Теперь можно обновить интерфейс источника данных новостей.
interface NewsDataSource {
...
suspend fun getTrendingNews(
...
// изменить возвращаемый объект из ApiResult<List<News>>
// на ApiResult<NewsResult>
): ApiResult<NewsResult>
}
- Изменяю возвращаемый объект на
ApiResult<NewsResult>.
Наконец, обновляю реализацию в удаленном источнике данных новостей.
@ActivityRetainedScoped
class NewsRemoteDataSource @Inject constructor(
private val retrofit: Retrofit,
private val gson: Gson
) : NewsDataSource {
...
override suspend fun getTrendingNews(
...
// обновление возвращенного объекта
): ApiResult<NewsResult> = try {
val response = retrofit.create(NewsService::class.java)
// получение данных новостей из конечной точки trendings
.fetchTrendingNews(...)
val body = response.body()
if (body != null) {
// заполнить класс данных NewsResult
val result = NewsResult(
data = body.data,
fromCache = response.raw().networkResponse == null,
url = response.raw().request.url.toUrl().toString()
)
ApiResult.Success(result, Meta(body.size, body.page, body.totalPages))
} else {
...
}
} catch (e: Exception) {
...
}
}
fromCache: определяю, откуда пришел ответ — из кэша или сети, используюresponse.raw().networkResponse == null.
Обновление репозитория новостей
@ActivityRetainedScoped
class NewsRepositoryImpl @Inject constructor(
...,
// внедрение NewsCacheManager
private val newsCacheManager: NewsCacheManager
) : NewsRepository {
...
override suspend fun fetchTrendingNews(
...
): ApiResult<List<News>> = withContext(Dispatchers.IO) {
val result = remoteDataSource.getTrendingNews(...)
when (result) {
is ApiResult.Success -> {
if (!result.data.fromCache) {
// добавлять в кэш только в том случае, если результат получен из сети;
// игнорировать, если он поступил из кэша
newsCacheManager.addNewsCache(result.data.url)
}
ApiResult.Success(result.data.data, result.meta)
}
...
}
}
}
!result.data.fromCache: добавление метаданных кэша только в том случае, если результат получен из сети.
Наконец, реализация механизма кэширования завершена.
Ознакомьтесь с полной реализацией на Github: NewsResult.kt, CacheInterceptor.kt, NewsDataSource.kt, NewsRemoteDataSource.kt и NewsRepository.kt.
Проверка механизма кэширования
Я проведу ручную проверку с помощью точки останова, чтобы убедиться в корректной реализации механизма кэширования.
Проверка доступности и действительности кэша

Глядя на приведенное выше изображение, можно сделать следующие выводы:
- кэш доступен для второго запроса;
diffInHoursравен 0, что означает, что кэш все еще действителен, максимальный срок действия кэша составляет 18 часов;
isCacheAvaliable(url)с конкретной строкой URL возвращаетtrue.
Проверка того, что метаданные кэша обновляются, когда он становится недействительным

Глядя на приведенное выше изображение, можно сделать следующие выводы:
- кэш доступен, но является устаревшим или недействительным;
- значение
diffInHoursравно 24, что означает: кэш недействителен и превышает максимальный срок действия кэша 18 часов;
isCacheAvaliable(url)с конкретной строкой URL возвращаетfalse.
Теперь проверю базу данных: надо убедиться, что метаданные кэша обновляются после того, как приложение совершает вызов API.

Проверка базы данных показала:
- данные кэша новостей имеются;
-
idравен 2, а не 1, что указывает на обновление записи. В этом случае данные обновляются, когда кэш новостей недействителен.
Судя по быстрой ручной проверке, механизм кэширования реализован корректно.
Итоги реализации OkHttp Cache и базы данных Room
- Объединение OkHttp Cache и базы данных Room упрощает реализацию механизма кэширования.
- OkHttp Cache будет управлять кэшем ответов, а база данных Room — сохранять метаданные кэша и контролировать истечение срока действия кэша.
Ознакомьтесь с полным исходным кодом на Github.
Читайте также:
- Реверсинг плагина компилятора Compose: перехват фронтенда
- 5 функций-расширений в арсенале каждого разработчика Jetpack Compose
- Как создать атомарный загрузчик в Jetpack Compose
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dani Mahardhika: Combine OkHttp Cache and Room Database for Client-Side Caching





