Building for mobile environments means accepting that internet connection is highly unstable. If a user opens a utility tool while entering a subway or in a remote area, they should not see an endless loading spinner or a generic connection error.
An offline-first architecture ensures that the device maintains a robust local database that serves as the single source of truth, synchronizing with remote cloud servers in the background.
In an offline-first system, the UI component should never fetch data from the network directly. Instead, the UI subscribes to a reactive data stream provided by a local database (typically using SQLite via the Room Database abstraction library).
Here is how a clean Repository pattern is set up:
class ProductRepository(
private val productDao: ProductDao,
private val apiService: ApiService
) {
// Expose a flow from the local database
val allProducts: Flow<List<Product>> = productDao.observeAllProducts()
// Background fetch to sync cloud data into local storage
suspend fun refreshProducts() {
try {
val networkProducts = apiService.fetchLatestProducts()
// Room handles transaction insert and alerts active flows
productDao.upsertProducts(networkProducts.map { it.toEntity() })
} catch (e: Exception) {
// Log sync fail but keep database intact
Log.e("ProductRepository", "Network refresh failed: ${e.message}")
}
}
}
By subscribing to observeAllProducts(), the UI updates automatically whenever the database is updated. Network failures are handled silently, and the user continues to see their cached files without interruption.
Always perform database entries and network calls outside of the main UI thread. Use Dispatchers.IO to ensure that data flows do not freeze input responses and cause Application Not Responding (ANR) warnings.
To collect reactive flows inside Jetpack Compose screens, we must do so in a lifecycle-aware manner. Failing to pause flow collection when the app is in the background causes continuous CPU cycles and battery drain.
We utilize collectAsStateWithLifecycle() to handle this:
@Composable
fun ProductListScreen(viewModel: ProductViewModel) {
// Safely collect flow when lifecycle is at least STARTED
val products by viewModel.productsFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp)
) {
items(products, key = { it.id }) { product ->
ProductRow(product = product)
}
}
}
Using collectAsState without lifecycle boundaries will keep active connections open in the background. Upgrading to collectAsStateWithLifecycle can reduce background CPU wakeups by up to 30%.
For background synchronization, native Android provides the WorkManager API. It allows you to schedule background synchronization tasks that execute under specific constraints—such as only when the device is charging and connected to unmetered Wi-Fi.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresCharging(true)
.build()
val syncWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(8, TimeUnit.HOURS)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"DataSyncWork",
ExistingPeriodicWorkPolicy.KEEP,
syncWorkRequest
)
By combining WorkManager with Room caching, we ensure our applications are fast, battery-conservative, and completely functional in offline zones.