This exercise builds a paginated article list that loads from a network API, caches in Room, and shows cached data while refreshing — the standard offline-first pagination pattern.
Architecture Overview
Network API ──► RemoteMediator ──► Room (cache)
│
Pager(PagingSource from Room)
│
ViewModel (cachedIn)
│
UI (PagingDataAdapter)
Step 1: Room Entity & DAO
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: Int,
val title: String,
val body: String,
val page: Int // page number for invalidation
)
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val articleId: Int,
val prevKey: Int?,
val nextKey: Int?
)
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY id ASC")
fun pagingSource(): PagingSource<Int, ArticleEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(articles: List<ArticleEntity>)
@Query("DELETE FROM articles")
suspend fun clearAll()
}
@Dao
interface RemoteKeyDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(keys: List<RemoteKey>)
@Query("SELECT * FROM remote_keys WHERE articleId = :id")
suspend fun remoteKeyByArticleId(id: Int): RemoteKey?
@Query("DELETE FROM remote_keys")
suspend fun clearAll()
}
Step 2: RemoteMediator
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleEntity>
): MediatorResult {
return try {
val page = when (loadType) {
LoadType.REFRESH -> 1 // always start from page 1 on refresh
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
?: return MediatorResult.Success(endOfPaginationReached = true)
val remoteKey = db.remoteKeyDao().remoteKeyByArticleId(lastItem.id)
remoteKey?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true)
}
}
val response = api.getArticles(page = page, pageSize = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.articleDao().clearAll()
db.remoteKeyDao().clearAll()
}
val keys = response.articles.map { article ->
RemoteKey(
articleId = article.id,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.isLastPage) null else page + 1
)
}
db.remoteKeyDao().insertAll(keys)
db.articleDao().insertAll(response.articles.map { it.toEntity() })
}
MediatorResult.Success(endOfPaginationReached = response.isLastPage)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
Step 3: Repository & ViewModel
class ArticleRepository(private val db: AppDatabase, private val api: ArticleApi) {
@OptIn(ExperimentalPagingApi::class)
fun getArticlesPaged(): Flow<PagingData<ArticleEntity>> = Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
remoteMediator = ArticleRemoteMediator(api, db),
pagingSourceFactory = { db.articleDao().pagingSource() }
).flow
}
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
val articles = repository.getArticlesPaged()
.cachedIn(viewModelScope) // survives recomposition/config change
}
Step 4: PagingDataAdapter in UI
class ArticleAdapter : PagingDataAdapter<ArticleEntity, ArticleViewHolder>(DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArticleViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_article, parent, false))
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
getItem(position)?.let { holder.bind(it) }
}
companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<ArticleEntity>() {
override fun areItemsTheSame(old: ArticleEntity, new: ArticleEntity) = old.id == new.id
override fun areContentsTheSame(old: ArticleEntity, new: ArticleEntity) = old == new
}
}
}
// In Fragment:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.articles.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
// Show loading/error states
adapter.loadStateFlow.collectLatest { states ->
binding.progressBar.isVisible = states.refresh is LoadState.Loading
binding.retryButton.isVisible = states.refresh is LoadState.Error
}
Common Pitfalls
| Problem | Fix |
|---|---|
| Data duplicates on refresh | Always clear DB in LoadType.REFRESH inside withTransaction |
| Infinite loading spinner | Ensure endOfPaginationReached = true when last page is empty |
| Stale data shown | cachedIn(viewModelScope) is required — without it, each collector restarts the flow |
| PREPEND always reached | Return MediatorResult.Success(endOfPaginationReached = true) for PREPEND unless you support bi-directional paging |