
В предыдущих статьях я добавил несколько функций в приложение TrendNow, включая отображение актуальных новостей по выбранной теме и загрузку большего количества новостей. Просмотрите эти предыдущие статьи, если что-то пропустили:
После добавления этих функций приложение стало довольно часто обращаться к конечной точке Bonai News API, что создало новую проблему — рейт-лимит API (ограничение скорости API). Поскольку я использую бесплатный тарифный план, предоставленный мне лимит — 100 обращений в день — довольно жесток.
В этой статье расскажу о первом шаге на пути решения проблемы ограничения скорости API — реализации механизма кэширования для новостных тем.
Напомню еще раз, что исходный код в репозитории Github может отличаться от приведенных здесь фрагментов кода, поскольку я работал над ним до написания этой статьи.
Что я собираюсь делать?
- Сохранять загруженные новостные темы в локальной базе данных с помощью
Room.
- Выполнять загрузку новостных тем в приложение из локальной базы данных, не дожидаясь возможности сделать API-вызов, когда он доступен.
- Использовать
Room, поскольку мне нужно, чтобы кэш был постоянным, и, судя по моему тестированию, отклик на новостные темы практически не менялся. Вот почему я хочу сохранять его локально в течение длительного периода времени.
Добавление зависимости Room
// внутри libs.versions.toml
[versions]
room = "2.6.1"
[libraries]
androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ksp = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
Я использую Room KSP. Ознакомьтесь с этим руководством по установке плагина KSP.
Подготовка базы данных
Начну с создания сущности и DAO (Data Access Object — интерфейс, определяющий методы для взаимодействия с базой данных Room) для новостных тем. После этого перейду к созданию базы данных.
Создание сущности темы
Обновляю существующий класс данных Topic.
@Entity(tableName = "topics") // наименование таблицы
data class Topic(
@PrimaryKey(autoGenerate = false) val id: String,
val name: String,
// новое поле
val createdAt: Long = System.currentTimeMillis(),
)
idтемы в качестве первичного ключа: чтобы легко вставить тему сid, аRoomавтоматически заменит существующие данные в таблицеtopics.
- Поле
created: в качестве метаданных для сообщения о добавленииTopicв локальную базу данных.
Создание DAO темы
@Dao
interface TopicDao {
@Query("SELECT * FROM topics")
suspend fun getAllTopics(): List<Topic>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(topic: List<Topic>)
}
getAllTopics(): получает все темы из локальной базы данных в таблицеtopics.
insertAll(...): вставляет все заданные темы в таблицуtopicsлокальной базы данных.
- Не использую
FlowдляgetAllTopics(), потому что мне не нужно, чтобы новостные темы были реактивными. На данный момент они используются только для чтения и записи вNewsScreen.
Пока нужны только эти 2 функции (позже их можно будет улучшить, исходя из потребностей).
DAO темы создан в файле TopicDao.kt в каталоге data/local/.
data/
├── local/
│ ├── TopicDao.kt
Создание базы данных новостей
@Database(entities = [Topic::class], version = 1)
abstract class NewsDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao
}
- Использовал общее имя для базы данных —
NewsDatabase. Это позволит в будущем добавить в базу данных больше таблиц.
База данных создается в файле NewsDatabase.kt в каталоге data/local/.
data/
├── local/
│ ├── NewsDatabase.kt
│ ├── TopicDao.kt
Создание модуля базы данных для внедрения зависимостей
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
private const val NEWS_DATABASE = "news_database"
@Provides
@Singleton
fun provideNewsDatabase(@ApplicationContext context: Context): NewsDatabase =
Room.databaseBuilder(
context,
NewsDatabase::class.java,
NEWS_DATABASE
).build()
@Provides
@Singleton
fun provideTopicDao(newsDatabase: NewsDatabase): TopicDao = newsDatabase.topicDao()
}
- Использовал
@Singletonи выполнил регистрацию наSingletonComponent, потому что база данных будет использоваться глобально во всем приложении.
- Создание экземпляра базы данных требует больших затрат, поэтому рекомендуется создавать базу данных один раз в течение жизненного цикла приложения.
Генерация схемы базы данных Room
Наконец, пришло время для генерации схемы базы данных. Помещаю приведенную ниже конфигурацию в gradle-файл сборки приложения app/build.gradle.kts.
ksp {
// генерация схем для базы данных Room
arg("room.schemaLocation", "$projectDir/schemas")
}
- Схема в основном используется для отслеживания изменений структуры базы данных в процессе разработки и миграции (например, при обновлении версии базы данных).
- Сейчас она может не понадобится, но я создам ее для будущего развития.
Создание локального источника данных новостей
@ActivityRetainedScoped
class NewsLocalDataSource @Inject constructor(
private val topicDao: TopicDao
) : NewsDataSource {
override suspend fun getSupportedTopics(): ApiResult<List<Topic>> =
// возврат данных о новостных темах из базы данных
ApiResult.Success(topicDao.getAllTopics())
override suspend fun getTrendingNews(
topic: String,
language: String,
page: Int?
): ApiResult<NewsResult> {
// пока оставляю пустым
TODO("Not yet implemented")
}
}
- Как и
NewsRemoteDataSource,NewsLocalDataSourceпросто возвращает новостные темы из локальной базы данных вместо API-вызова.
- Использовал
@ActivityRetainedScopedпо той же причине, что иNewsRemoteDataSource(о котором упоминал в первой статье): мне нужно было указать, что эта зависимость относится только к жизненному циклуActivity, а не к жизненному циклу приложения.
Локальный источник данных создается в файле NewsLocalDataSource.kt в каталоге data/datasource/.
data/
├── datasource/
│ ├── NewsDataSource.kt
│ ├── NewsLocalDataSource.kt
│ ├── NewsRemoteDataSource.kt
Регистрация локального источника данных новостей для внедрения зависимостей
Обновляю существующий модуль NewsModule.
@Module
@InstallIn(ActivityRetainedComponent::class)
object NewsModule {
...
@Provides
@ActivityRetainedScoped
fun provideNewsLocalDataSource(topicDao: TopicDao): NewsLocalDataSource =
NewsLocalDataSource(topicDao)
}
Обновление репозитория новостей
@ActivityRetainedScoped
class NewsRepositoryImpl @Inject constructor(
...,
// внедрение локального источника данных новостей
private val localDataSource: NewsLocalDataSource,
// внедрение TopicDAO
private val topicDao: TopicDao,
// внедрение текущего провайдера данных со значением по умолчанию
private val currentDateProvider: () -> Calendar = { Calendar.getInstance() }
) : NewsRepository {
override suspend fun fetchSupportedTopics(): ApiResult<List<Topic>> =
withContext(Dispatchers.IO) {
// попытка получения тем новостей сначала из локальной базы данных
val localResult = localDataSource.getSupportedTopics()
(localResult as? ApiResult.Success)?.let { local ->
// если существует, проверьте время создания первых данных
local.data.firstOrNull()?.let { first ->
val diffInDays = TimeUnit.DAYS.fromMs(
abs(currentDateProvider().timeInMillis - first.createdAt)
)
// MAX_TOPICS_CACHE_IN_DAYS = 60 дней
// проверка того, составляет ли срок действия кэша менее 60 дней
if (diffInDays < MAX_TOPICS_CACHE_IN_DAYS) {
// используйте данные из локальной базы данных
return@withContext localResult
}
}
}
// локальные данные не существуют или устарели
// вызов API тем новостей
val remoteResult = remoteDataSource.getSupportedTopics()
if (remoteResult is ApiResult.Success) {
// внедрение тем новостей из ответа в локальную базу данных
topicDao.insertAll(
// нужно переустановить поле createdAt,
// потому что Retrofit будет игнорировать значение по умолчанию внутри класса данных
remoteResult.data.map {
it.copy(createdAt = currentDateProvider().timeInMillis)
}
)
}
return@withContext remoteResult
}
...
}
- Внедрил
currentDateProvider: для целей тестирования (позже), чтобы легко вводить текущую дату и время в фиктивное значение (mock value).
- Сначала надо взять данные из локальной базы данных и проверить срок действия кэша: если он действителен, можно его использовать.
- Если локальные данные не действительны или срок их действия истек, надо вызвать API новостных тем и вставить данные из ответа в локальную базу данных, чтобы использовать их позже.
- Примечание: переустановите значение
createdAtв моделиTopic, потому чтоRetrofitбудет игнорировать значение по умолчанию внутри класса данныхTopicпри парсинге ответа.
Проверка механизма кэширования
Я использовал точки останова, чтобы вручную проверить, правильно ли реализован механизм кэширования.


- Первое изображение (выше):
fetchSupportedTopics()возвращает данные из вызова API, поскольку локальные данные недоступны.
- Второе изображение (ниже):
fetchSupportedTopics()возвращает данные из локальной базы данных без обращения к API.
Теперь кэш новостных тем реализован.
Итоги реализации механизма кэширования с помощью Room
- В моем случае реализация механизма кэширования оказалась полезной для снижения ограничения скорости API. Теперь приложению не нужно вызывать API новостных тем каждый раз, когда пользователь открывает его.
- В зависимости от ваших потребностей, для данных, которые редко меняются, не требуют обновления в режиме реального времени и не содержат конфиденциальных данных, вы можете рассмотреть возможность внедрения механизма кэширования с помощью
Room, чтобы снизить нагрузку на сервер и повысить эффективность.
В следующей статье расскажу о том, как я реализовал еще один механизм кэширования для трендовых новостей с помощьюOkhttp Cache.
Ознакомьтесь с исходным кодом на Github.
Читайте также:
- 5 функций-расширений в арсенале каждого разработчика Jetpack Compose
- Вопросы для собеседования по Android: как обрабатывать валидацию ввода в Jetpack Compose?
- 5 функций-расширений в арсенале каждого разработчика Jetpack Compose
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dani Mahardhika: Use Room Database for Efficient Client-Side Caching





