DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Paging 3 & Offline-First Architecture — Loading Data the Right Way

Paging 3 & Offline-First Architecture — Loading Data the Right Way

Building modern Android apps requires handling massive datasets efficiently while maintaining a smooth offline experience. In this guide, we'll explore how to combine Paging 3 with an offline-first architecture to create responsive, resilient applications.

Part 1: Paging 3 Fundamentals

PagingSource Implementation

PagingSource is the core abstraction that defines how to load data from your data source. Whether fetching from an API or database, you implement the load() function:

class ApiPagingSource(private val api: MyApi) : PagingSource<Int, Item>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
        return try {
            val page = params.key ?: 1
            val response = api.getItems(page = page, pageSize = params.loadSize)

            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.items.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
        return state.anchorPosition?.let { position ->
            state.closestPageToPosition(position)?.nextKey?.minus(1)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pager Configuration

Set up a Pager to manage pagination logic and expose paginated data as a Flow:

class MyRepository(private val api: MyApi) {
    fun getItems(): Flow<PagingData<Item>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,
                enablePlaceholders = true,
                maxSize = 100,
                initialLoadSize = 40,
                prefetchDistance = 5
            ),
            pagingSourceFactory = { ApiPagingSource(api) }
        ).flow
    }
}
Enter fullscreen mode Exit fullscreen mode

Key config parameters:

  • pageSize: Items per page
  • initialLoadSize: First page size (often 2x pageSize)
  • prefetchDistance: Trigger next load when user scrolls within this distance
  • maxSize: Keep max items in memory

collectAsLazyPagingItems in Compose

In Compose, collect paginated data as a lazy list state:

@Composable
fun ItemListScreen(viewModel: MyViewModel) {
    val lazyPagingItems = viewModel.items.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = lazyPagingItems.itemCount,
            key = { index -> lazyPagingItems[index]?.id ?: index },
            contentType = { "item" }
        ) { index ->
            val item = lazyPagingItems[index]
            if (item != null) {
                ItemRow(item)
            } else {
                // Placeholder while loading
                ShimmerPlaceholder()
            }
        }

        // Load more indicator at the end
        when (lazyPagingItems.loadState.append) {
            is LoadState.Loading -> {
                item { LoadingIndicator() }
            }
            is LoadState.Error -> {
                item { ErrorMessage("Failed to load more") }
            }
            is LoadState.NotLoading -> {}
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

LoadState Handling

Monitor loading, error, and completion states across the pagination lifecycle:

@Composable
fun PaginationStateUI(lazyPagingItems: LazyPagingItems<Item>) {
    // Initial load state
    when (lazyPagingItems.loadState.refresh) {
        is LoadState.Loading -> {
            Box(Modifier.fillMaxSize()) {
                CircularProgressIndicator(Modifier.align(Alignment.Center))
            }
        }
        is LoadState.Error -> {
            val error = (lazyPagingItems.loadState.refresh as LoadState.Error).error
            ErrorScreen(error = error)
        }
        is LoadState.NotLoading -> {}
    }

    // Append (load more) state
    LaunchedEffect(lazyPagingItems.loadState.append) {
        if (lazyPagingItems.loadState.append is LoadState.Error) {
            val error = (lazyPagingItems.loadState.append as LoadState.Error).error
            showRetrySnackbar(error)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Room PagingSource

For database-backed pagination, Room provides seamless integration:

@Dao
interface ItemDao {
    @Query("SELECT * FROM items ORDER BY id DESC")
    fun pagingSource(): PagingSource<Int, Item>
}

class DbPagingSource(private val dao: ItemDao) : PagingSource<Int, Item>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
        return try {
            val page = params.key ?: 0
            val items = dao.getItemsPaged(
                limit = params.loadSize,
                offset = page * params.loadSize
            )

            LoadResult.Page(
                data = items,
                prevKey = if (page == 0) null else page - 1,
                nextKey = if (items.size < params.loadSize) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Item>): Int? = null
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Offline-First Architecture

Room as Single Source of Truth

Implement the single source of truth pattern where Room database is the primary data store:

@Entity(tableName = "items")
data class ItemEntity(
    @PrimaryKey val id: Int,
    val title: String,
    val description: String,
    @ColumnInfo(name = "sync_status") val syncStatus: String = "synced"
)

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(item: ItemEntity)

    @Update
    suspend fun update(item: ItemEntity)

    @Query("SELECT * FROM items WHERE sync_status = :status")
    suspend fun getItemsBySync(status: String): List<ItemEntity>
}
Enter fullscreen mode Exit fullscreen mode

Repository with Sync Flag

Expose data from Room while tracking sync state:

class ItemRepository(
    private val api: MyApi,
    private val dao: ItemDao,
    private val networkMonitor: NetworkMonitor
) {
    fun getItems(): Flow<PagingData<Item>> {
        return Pager(
            config = PagingConfig(pageSize = 20),
            pagingSourceFactory = { dao.pagingSource() }
        ).flow
            .map { pagingData ->
                pagingData.map { it.toDomain() }
            }
    }

    suspend fun syncItems() {
        if (!networkMonitor.isOnline()) return

        try {
            val response = api.getItems()
            val entities = response.map { it.toEntity() }

            // Update local DB
            entities.forEach { dao.insert(it.copy(syncStatus = "synced")) }
        } catch (e: Exception) {
            // Mark as pending sync on error
            dao.markAllAsPending()
        }
    }

    suspend fun markForSync(itemId: Int) {
        dao.update(dao.getItem(itemId).copy(syncStatus = "pending"))
    }
}
Enter fullscreen mode Exit fullscreen mode

WorkManager Periodic Sync

Schedule background sync to keep offline data fresh:

class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        return@withContext try {
            val repository = // inject repository
            repository.syncItems()
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

// Schedule periodic sync
fun setupPeriodicSync(context: Context) {
    val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
        15, TimeUnit.MINUTES
    ).build()

    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
        "item_sync",
        ExistingPeriodicWorkPolicy.KEEP,
        syncRequest
    )
}
Enter fullscreen mode Exit fullscreen mode

NetworkMonitor with ConnectivityManager

Detect network availability to drive offline-first behavior:

interface NetworkMonitor {
    fun isOnline(): Boolean
    val isOnlineFlow: Flow<Boolean>
}

class ConnectivityNetworkMonitor(context: Context) : NetworkMonitor {
    private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)

    private val _isOnlineFlow = MutableStateFlow(false)
    override val isOnlineFlow = _isOnlineFlow.asStateFlow()

    init {
        val networkCallback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                _isOnlineFlow.value = true
            }
            override fun onLost(network: Network) {
                _isOnlineFlow.value = false
            }
        }
        connectivityManager.registerNetworkCallback(
            NetworkRequest.Builder().build(),
            networkCallback
        )
    }

    override fun isOnline(): Boolean {
        val activeNetwork = connectivityManager.activeNetwork
        val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
        return caps?.hasCapability(NET_CAPABILITY_INTERNET) == true
    }
}
Enter fullscreen mode Exit fullscreen mode

Bringing It Together

Combine Paging 3 with offline-first in your ViewModel:

class ItemViewModel(
    private val repository: ItemRepository,
    private val networkMonitor: NetworkMonitor
) : ViewModel() {
    val items: Flow<PagingData<Item>> = repository.getItems()
    val isOnline = networkMonitor.isOnlineFlow

    init {
        viewModelScope.launch {
            networkMonitor.isOnlineFlow.collect { isOnline ->
                if (isOnline) {
                    repository.syncItems()
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This architecture ensures users always see data (offline), while seamlessly syncing when possible. Paging 3 efficiently manages memory, and WorkManager keeps data fresh in the background.

8 Android App Templates → https://myougatheax.gumroad.com

Top comments (0)