APIのGET結果をキャッシュしたい!
天気予報アプリも順調に育ってきました。
「画面を表示」 -> 「APIコール」 -> 「表示」はできましたので、APIコールの回数を減らすべく、10分以内のリクエストならDBを参照する様にしたいと思います。
ということで、キャッシュ先をRoomにすべく、導入していきます。
Room
1. gradleの定義
まずは、Roomの依存定義を行います。
plugins {
id 'org.jetbrains.kotlin.android'
+ id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
android {
defaultConfig {
+ kapt {
+ arguments {
+ // ① schema定義を出力しておくと、AutoMigrationできるようになるので
+ // 出力先を指定します
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
+ }
}
}
dependencies {
+ // Room
+ def room_version = "2.5.2"
+ implementation "androidx.room:room-runtime:$room_version"
+ annotationProcessor "androidx.room:room-compiler:$room_version"
+ implementation "androidx.room:room-ktx:$room_version"
+ kapt "androidx.room:room-compiler:$room_version"
}
①「arg("room.schemaLocation", "$projectDir/schemas")」の部分は、後から作成するDatabaseクラスにしている「exportSchema=true」(意味:スキーマ定義をエクスポートするよ)の時に必要になる、スキーマ定義ファイルの出力先を指定しています。
2. データエンティティクラスの作成
地域情報(Areaドメイン)を保存するためのEntity定義をおこないます。
参考:Room エンティティを使用してデータを定義する by Google developers
Areaのソース
data class Area(
val centers: List<Center>,
)
data class Center(
val code: String,
val name: String,
val offices: List<Office>,
)
data class Office(
val code: String,
val name: String,
)
center:office = 1:m という関係なので、それをRDB的に表現します。
@Entity(tableName = "center")
data class CenterEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "code") val code: String,
@ColumnInfo(name = "name") val name: String,
)
@Entity(tableName = "office")
data class OfficeEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "code") val code: String,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "center_code") val centerCode: String,
)
3. DAOの作成
Room DAO を使用してデータにアクセスする by Google develpers
CenterEntityとOfficeEntityにアクセスするための、AreaDaoクラスを作ります。
Entity:DAO=1:1となるように作るのもありだと思います。
地域情報はマスタデータで、全レコード削除 -> 全レコード挿入という操作を想定しています。
@Dao
interface AreaDao {
@Query("select * from center " +
"join office on center.code = office.center_code " +
"order by center.code, office.code")
suspend fun getAll(): Map<CenterEntity, List<OfficeEntity>>
@Query("delete from center")
suspend fun deleteAllCenter()
@Query("delete from office")
suspend fun deleteAllOffice()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCenter(centerEntity: CenterEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllOffice(officeEntityList: List<OfficeEntity>)
}
getAll()の部分は、マルチマップを使用しています。
複数のテーブル定義を格納するためのデータクラスを追加で準備しなくてもいいので、とても便利ですね。(Room 2.4以降です)
@Insert(onConflict = OnConflictStrategy.REPLACE)のonConflictは不要ですが、クラッシュしにくくするために、念のため書いていてます。
4. Databaseクラス
Room関連最後のクラス、Databaseクラスを作成します。
@Database(
entities = [CenterEntity::class, OfficeEntity::class],
version = 1,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun areaDao(): AreaDao
}
versionは、DBのマイグレーション判定に使用するデータベースのバージョンです。
テーブルの追加や削除、カラムの追加、変更、削除など、データベースに関する変更があった場合インクリメントしていきます。
exportSchemaは、マイグレーションが必要な場合に、手動でマイグレーションを記述しないで済むAutoMigrationを使用する際には、必ずtrueにします。この定義と併せて、build.gradleにSchema定義のexport先ディレクトリを指定する記述が必要です。
5. HiltのModuleにProviderを追加
HiltのModuleに、DatabaseクラスとDaoクラスのprovider定義を追加します。
@Singleton
@Provides
fun provideAppDatabase(
@ApplicationContext context: Context,
): AppDatabase {
return Room.databaseBuilder(
context = context,
klass = AppDatabase::class.java,
name = "forecast_db",
).build()
}
@Singleton
@Provides
fun provideAreaDao(
db: AppDatabase,
): AreaDao {
return db.areaDao()
}
DAO経由でデータアクセスする
データ取得
まずは、AreaDao#getAll()を読んでみます。
class AreaRepositoryImpl @Inject constructor(
private val forecastApi: ForecastApi,
+ private val areaDao: AreaDao,
private val dispatchers: CoroutineDispatcher,
) : AreaRepository {
+ private fun getAreaFromLocal(): Flow<Future<Area>> {
+ return flow<Future<Area>> {
+ val areaMap = areaDao.getAll()
+ val area = AreaAdapter.adaptFromDb(areaMap)
+ emit(Future.Success(area))
+ }.catch { cause ->
+ emit(Future.Error(cause))
+ }.flowOn(dispatchers)
+ }
}
flowの中で、データベースから地域情報を取得し、Adapterを経由して、Areaドメインに変換しています。
AreaAdapterとFutureのソース(興味があれば展開してください)
object AreaAdapter {
fun adaptFromDb(areaEntity: Map<CenterEntity, List<OfficeEntity>>): Area {
return Area(
centers = areaEntity.entries.map { (centerEntity, officeEntityList) ->
Center(
code = centerEntity.code,
name = centerEntity.name,
offices = officeEntityList.map { officeEntity ->
Office(
code = officeEntity.code,
name = officeEntity.name,
)
}
)
},
)
}
}
sealed class Future<out T> {
object Idle : Future<Nothing>()
object Proceeding : Future<Nothing>()
data class Success<out T>(val value: T) : Future<T>()
data class Error(val error: Throwable) : Future<Nothing>()
}
データ削除と挿入(トランザクションも使う)
次にデータの削除と挿入をやってみます。
class AreaRepositoryImpl @Inject constructor(
private val forecastApi: ForecastApi,
+ private val db: AppDatabase,
private val areaDao: AreaDao,
private val sharedPref: SharedPref,
private val dispatchers: CoroutineDispatcher,
) : AreaRepository {
+ private fun saveArea(area: Area): Flow<Future<Area>> {
+ return flow<Future<Area>> {
+ db.withTransaction {
+ areaDao.deleteAllCenter()
+ areaDao.deleteAllOffice()
+
+ area.centers.forEach { center ->
+ val centerEntity = CenterEntity(code = center.code, name = center.name)
+ val officeEntityList = center.offices.map { office ->
+ OfficeEntity(code = office.code, name = office.name, centerCode = center.code)
+ }
+
+ areaDao.insertCenter(centerEntity)
+ areaDao.insertAllOffice(officeEntityList)
+ }
+ }
+ emit(Future.Success(area))
+ }.catch { cause ->
+ emit(Future.Error(cause))
+ }.flowOn(dispatchers)
+ }
}
同一トランザクジョンで、データの削除と挿入を行う場合は、「withTransaction」を使用します。
withTransactionはsuspend関数です。
not suspendの時は、「runInTransaction」を使用します。
(ちなみに、withTransactionは、「androidx.room:room-ktx」への依存関係が必要になります。)
Insertの際にエンティティのインスタンスを作成しますが、今回、エンティティに定義してるidはAutoGeneratedです。
その場合、idには0を指定するのがお約束です。
リファレンス by Google developers
data class CenterEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
}
完成
完成コードはこちらです。