Skip to content

Commit 38a1aea

Browse files
improve caching with database (#1096)
1 parent 313b5db commit 38a1aea

23 files changed

+826
-273
lines changed

app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,12 @@ dependencies {
230230
implementation(libs.androidx.compose.uitooling)
231231
androidTestImplementation(libs.androidx.compose.junit)
232232

233+
// Room Database
234+
implementation(libs.androidx.room.runtime)
235+
implementation(libs.androidx.room)
236+
implementation(libs.androidx.room.paging)
237+
ksp(libs.androidx.room.compiler)
238+
233239
// Glide
234240
implementation(libs.landscapist.animation)
235241
implementation(libs.landscapist.glide)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.burrowsapps.gif.search.data.db
2+
3+
import androidx.room.Database
4+
import androidx.room.RoomDatabase
5+
import com.burrowsapps.gif.search.data.db.dao.GifDao
6+
import com.burrowsapps.gif.search.data.db.dao.QueryResultDao
7+
import com.burrowsapps.gif.search.data.db.dao.RemoteKeysDao
8+
import com.burrowsapps.gif.search.data.db.entity.GifEntity
9+
import com.burrowsapps.gif.search.data.db.entity.QueryResultEntity
10+
import com.burrowsapps.gif.search.data.db.entity.RemoteKeysEntity
11+
12+
@Database(
13+
entities = [GifEntity::class, QueryResultEntity::class, RemoteKeysEntity::class],
14+
version = 1,
15+
exportSchema = false,
16+
)
17+
internal abstract class AppDatabase : RoomDatabase() {
18+
abstract fun gifDao(): GifDao
19+
20+
abstract fun queryResultDao(): QueryResultDao
21+
22+
abstract fun remoteKeysDao(): RemoteKeysDao
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.burrowsapps.gif.search.data.db.dao
2+
3+
import androidx.room.Dao
4+
import androidx.room.Insert
5+
import androidx.room.OnConflictStrategy
6+
import androidx.room.Query
7+
import com.burrowsapps.gif.search.data.db.entity.GifEntity
8+
9+
@Dao
10+
internal interface GifDao {
11+
@Insert(onConflict = OnConflictStrategy.REPLACE)
12+
suspend fun upsertAll(items: List<GifEntity>)
13+
14+
@Query("DELETE FROM gifs")
15+
suspend fun clearAll()
16+
17+
@Query("SELECT COUNT(*) FROM gifs")
18+
suspend fun count(): Int
19+
20+
@Query("SELECT * FROM gifs WHERE tinyGifUrl = :id LIMIT 1")
21+
suspend fun getById(id: String): GifEntity?
22+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.burrowsapps.gif.search.data.db.dao
2+
3+
import androidx.paging.PagingSource
4+
import androidx.room.Dao
5+
import androidx.room.Insert
6+
import androidx.room.OnConflictStrategy
7+
import androidx.room.Query
8+
import androidx.room.RewriteQueriesToDropUnusedColumns
9+
import com.burrowsapps.gif.search.data.db.entity.QueryResultEntity
10+
import com.burrowsapps.gif.search.ui.giflist.GifImageInfo
11+
12+
@Dao
13+
internal interface QueryResultDao {
14+
// Keep first-seen ordering stable by ignoring duplicates
15+
@Insert(onConflict = OnConflictStrategy.IGNORE)
16+
suspend fun insertAll(items: List<QueryResultEntity>)
17+
18+
@Query("DELETE FROM query_results WHERE searchKey = :searchKey")
19+
suspend fun clearQuery(searchKey: String)
20+
21+
@Query("SELECT COALESCE(MAX(position) + 1, 0) FROM query_results WHERE searchKey = :searchKey")
22+
suspend fun nextPositionForQuery(searchKey: String): Long
23+
24+
@RewriteQueriesToDropUnusedColumns
25+
@Query(
26+
"""
27+
SELECT g.tinyGifUrl AS tinyGifUrl,
28+
g.tinyGifPreviewUrl AS tinyGifPreviewUrl,
29+
g.gifUrl AS gifUrl,
30+
g.gifPreviewUrl AS gifPreviewUrl
31+
FROM query_results qr
32+
INNER JOIN gifs g ON g.tinyGifUrl = qr.gifId
33+
WHERE qr.searchKey = :searchKey
34+
ORDER BY qr.position ASC
35+
""",
36+
)
37+
fun pagingSource(searchKey: String): PagingSource<Int, GifImageInfo>
38+
39+
@RewriteQueriesToDropUnusedColumns
40+
@Query(
41+
"""
42+
SELECT g.tinyGifUrl AS tinyGifUrl,
43+
g.tinyGifPreviewUrl AS tinyGifPreviewUrl,
44+
g.gifUrl AS gifUrl,
45+
g.gifPreviewUrl AS gifPreviewUrl
46+
FROM query_results qr
47+
INNER JOIN gifs g ON g.tinyGifUrl = qr.gifId
48+
WHERE qr.searchKey = :searchKey
49+
ORDER BY qr.position ASC
50+
""",
51+
)
52+
suspend fun allForQuery(searchKey: String): List<GifImageInfo>
53+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.burrowsapps.gif.search.data.db.dao
2+
3+
import androidx.room.Dao
4+
import androidx.room.Insert
5+
import androidx.room.OnConflictStrategy
6+
import androidx.room.Query
7+
import com.burrowsapps.gif.search.data.db.entity.RemoteKeysEntity
8+
9+
@Dao
10+
internal interface RemoteKeysDao {
11+
@Query("SELECT * FROM remote_keys WHERE searchKey = :searchKey LIMIT 1")
12+
suspend fun remoteKeys(searchKey: String): RemoteKeysEntity?
13+
14+
@Insert(onConflict = OnConflictStrategy.REPLACE)
15+
suspend fun upsert(keys: RemoteKeysEntity)
16+
17+
@Query("DELETE FROM remote_keys WHERE searchKey = :searchKey")
18+
suspend fun clearQuery(searchKey: String)
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.burrowsapps.gif.search.data.db.entity
2+
3+
import androidx.room.Entity
4+
import androidx.room.PrimaryKey
5+
6+
@Entity(tableName = "gifs")
7+
internal data class GifEntity(
8+
@PrimaryKey val tinyGifUrl: String,
9+
val tinyGifPreviewUrl: String,
10+
val gifUrl: String,
11+
val gifPreviewUrl: String,
12+
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.burrowsapps.gif.search.data.db.entity
2+
3+
import androidx.room.Entity
4+
import androidx.room.ForeignKey
5+
import androidx.room.Index
6+
import com.burrowsapps.gif.search.data.db.entity.GifEntity
7+
8+
@Entity(
9+
tableName = "query_results",
10+
primaryKeys = ["searchKey", "gifId"],
11+
indices = [
12+
Index(value = ["searchKey", "position"], unique = true),
13+
Index(value = ["gifId"]),
14+
],
15+
foreignKeys = [
16+
ForeignKey(
17+
entity = GifEntity::class,
18+
parentColumns = ["tinyGifUrl"],
19+
childColumns = ["gifId"],
20+
onDelete = ForeignKey.CASCADE,
21+
onUpdate = ForeignKey.CASCADE,
22+
),
23+
],
24+
)
25+
internal data class QueryResultEntity(
26+
val searchKey: String,
27+
val gifId: String, // references GifEntity.tinyGifUrl
28+
val position: Long,
29+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.burrowsapps.gif.search.data.db.entity
2+
3+
import androidx.room.Entity
4+
import androidx.room.PrimaryKey
5+
6+
@Entity(tableName = "remote_keys")
7+
internal data class RemoteKeysEntity(
8+
@PrimaryKey val searchKey: String,
9+
val nextKey: String?,
10+
)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.burrowsapps.gif.search.data.repository
2+
3+
import androidx.paging.ExperimentalPagingApi
4+
import androidx.paging.LoadType
5+
import androidx.paging.PagingState
6+
import androidx.paging.RemoteMediator
7+
import androidx.room.withTransaction
8+
import com.burrowsapps.gif.search.data.api.model.GifResponseDto
9+
import com.burrowsapps.gif.search.data.api.model.NetworkResult
10+
import com.burrowsapps.gif.search.data.db.AppDatabase
11+
import com.burrowsapps.gif.search.data.db.entity.GifEntity
12+
import com.burrowsapps.gif.search.data.db.entity.QueryResultEntity
13+
import com.burrowsapps.gif.search.data.db.entity.RemoteKeysEntity
14+
import com.burrowsapps.gif.search.ui.giflist.GifImageInfo
15+
import kotlinx.coroutines.CoroutineDispatcher
16+
import kotlinx.coroutines.withContext
17+
18+
@OptIn(ExperimentalPagingApi::class)
19+
internal class GifRemoteMediator(
20+
private val queryKey: String,
21+
private val repository: GifRepository,
22+
private val database: AppDatabase,
23+
private val dispatcher: CoroutineDispatcher,
24+
) : RemoteMediator<Int, GifImageInfo>() {
25+
override suspend fun load(
26+
loadType: LoadType,
27+
state: PagingState<Int, GifImageInfo>,
28+
): MediatorResult {
29+
val remoteKeysDao = database.remoteKeysDao()
30+
val gifDao = database.gifDao()
31+
val queryResultsDao = database.queryResultDao()
32+
33+
val currentKey =
34+
when (loadType) {
35+
LoadType.REFRESH -> null
36+
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
37+
LoadType.APPEND -> remoteKeysDao.remoteKeys(queryKey)?.nextKey
38+
}
39+
40+
val result: NetworkResult<GifResponseDto> =
41+
withContext(dispatcher) {
42+
if (queryKey.isBlank()) {
43+
repository.getTrendingResults(currentKey)
44+
} else {
45+
repository.getSearchResults(queryKey, currentKey)
46+
}
47+
}
48+
49+
return when (result) {
50+
is NetworkResult.Error -> MediatorResult.Error(Exception(result.message))
51+
is NetworkResult.Loading -> MediatorResult.Error(IllegalStateException("Unexpected Loading"))
52+
is NetworkResult.Empty -> MediatorResult.Success(endOfPaginationReached = true)
53+
is NetworkResult.Success -> {
54+
val response = result.data
55+
val items = buildGifList(response)
56+
57+
database.withTransaction {
58+
if (loadType == LoadType.REFRESH) {
59+
queryResultsDao.clearQuery(queryKey)
60+
remoteKeysDao.clearQuery(queryKey)
61+
}
62+
63+
// upsert gifs
64+
val gifEntities =
65+
items.map { info ->
66+
GifEntity(
67+
tinyGifUrl = info.tinyGifUrl,
68+
tinyGifPreviewUrl = info.tinyGifPreviewUrl,
69+
gifUrl = info.gifUrl,
70+
gifPreviewUrl = info.gifPreviewUrl,
71+
)
72+
}
73+
gifDao.upsertAll(gifEntities)
74+
75+
// compute start position
76+
val startPos = if (loadType == LoadType.APPEND) queryResultsDao.nextPositionForQuery(queryKey) else 0L
77+
78+
val mappings =
79+
items.mapIndexed { index, item ->
80+
QueryResultEntity(searchKey = queryKey, gifId = item.tinyGifUrl, position = startPos + index)
81+
}
82+
queryResultsDao.insertAll(mappings)
83+
84+
// update remote keys
85+
val nextKey = response?.next?.takeUnless { it == currentKey }
86+
remoteKeysDao.upsert(RemoteKeysEntity(searchKey = queryKey, nextKey = nextKey))
87+
}
88+
89+
MediatorResult.Success(endOfPaginationReached = items.isEmpty())
90+
}
91+
}
92+
}
93+
94+
private fun buildGifList(response: GifResponseDto?): List<GifImageInfo> =
95+
response
96+
?.results
97+
?.map { result ->
98+
val media = result.media.firstOrNull()
99+
val gifUrl = media?.gif?.url.orEmpty()
100+
val gifPreviewUrl = media?.gif?.preview.orEmpty()
101+
val tinyUrl = media?.tinyGif?.url.orEmpty()
102+
val tinyPreview = media?.tinyGif?.preview.orEmpty()
103+
104+
GifImageInfo(
105+
gifUrl = gifUrl,
106+
gifPreviewUrl = gifPreviewUrl,
107+
tinyGifUrl = tinyUrl,
108+
tinyGifPreviewUrl = tinyPreview,
109+
)
110+
}.orEmpty()
111+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.burrowsapps.gif.search.di
2+
3+
import android.content.Context
4+
import androidx.room.Room
5+
import androidx.room.RoomDatabase
6+
import com.burrowsapps.gif.search.data.db.AppDatabase
7+
import com.burrowsapps.gif.search.data.db.dao.GifDao
8+
import com.burrowsapps.gif.search.data.db.dao.QueryResultDao
9+
import com.burrowsapps.gif.search.data.db.dao.RemoteKeysDao
10+
import dagger.Module
11+
import dagger.Provides
12+
import dagger.hilt.InstallIn
13+
import dagger.hilt.android.qualifiers.ApplicationContext
14+
import dagger.hilt.components.SingletonComponent
15+
import javax.inject.Singleton
16+
17+
@Module
18+
@InstallIn(SingletonComponent::class)
19+
internal class DatabaseModule {
20+
@Provides
21+
@Singleton
22+
fun provideDatabase(
23+
@ApplicationContext context: Context,
24+
): AppDatabase =
25+
Room
26+
.databaseBuilder(context, AppDatabase::class.java, "gif-db")
27+
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
28+
.fallbackToDestructiveMigration(false)
29+
.build()
30+
31+
@Provides
32+
@Singleton
33+
fun provideGifDao(db: AppDatabase): GifDao = db.gifDao()
34+
35+
@Provides
36+
@Singleton
37+
fun provideQueryResultDao(db: AppDatabase): QueryResultDao = db.queryResultDao()
38+
39+
@Provides
40+
@Singleton
41+
fun provideRemoteKeysDao(db: AppDatabase): RemoteKeysDao = db.remoteKeysDao()
42+
}

0 commit comments

Comments
 (0)