В предыдущих статьях я добавил несколько функций в приложение 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")
}
}
  • Как и NewsRemoteDataSourceNewsLocalDataSource просто возвращает новостные темы из локальной базы данных вместо 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.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Dani Mahardhika: Use Room Database for Efficient Client-Side Caching

Предыдущая статьяC++: подробное руководство по cортированным векторам
Следующая статьяЯ бросил изучать Python и стал лучшим разработчиком