androidengineers.Book a session

Modern Android Architecture Components

Exercise: Paging + Room Integration

exercise50 minMedium

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

ProblemFix
Data duplicates on refreshAlways clear DB in LoadType.REFRESH inside withTransaction
Infinite loading spinnerEnsure endOfPaginationReached = true when last page is empty
Stale data showncachedIn(viewModelScope) is required — without it, each collector restarts the flow
PREPEND always reachedReturn MediatorResult.Success(endOfPaginationReached = true) for PREPEND unless you support bi-directional paging

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Paging + Room Integration | Android System Design | Android Engineers