Building robust and responsive Android applications often requires considering scenarios where the user might not have a stable internet connection. An offline-first approach addresses this challenge by prioritizing local data storage, allowing the app to function even without network connectivity. This blog post explores how to implement an offline-first architecture in Android using Room persistence library and Jetpack Compose for the UI layer.
What is an Offline-First Architecture?
An offline-first architecture is a design approach where an application is primarily built to work with local data storage. When network connectivity is available, the app synchronizes data between the local storage and a remote server. This ensures that the app is always functional, even when the device is offline.
Why Choose Offline-First?
- Improved User Experience: App remains functional regardless of network connectivity.
- Faster Load Times: Local data is readily available, reducing latency.
- Reliability: Guarantees that the app can handle intermittent network availability.
- Reduced Data Usage: Optimizes network requests and data synchronization.
Components for Offline-First with Room and Jetpack Compose
- Room Persistence Library: Provides an abstraction layer over SQLite for local data storage.
- Jetpack Compose: Modern UI toolkit for building native Android UI.
- Repositories: Manages data access and synchronization logic.
- ViewModels: Prepares and manages data for the UI layer.
- Coroutines: Enables asynchronous programming for background tasks.
Implementation Steps
Step 1: Set Up Project Dependencies
Add necessary dependencies to your build.gradle
file:
dependencies {
implementation("androidx.room:room-runtime:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")
implementation("androidx.room:room-ktx:2.5.2")
implementation("androidx.compose.ui:ui:1.6.0")
implementation("androidx.compose.material:material:1.6.0")
implementation("androidx.compose.ui:ui-tooling-preview:1.6.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Optional: If using Retrofit for network requests
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
}
kapt {
correctErrorTypes = true
}
Step 2: Define the Room Entity
Create a data class annotated with @Entity
to represent a table in the database:
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "items")
data class Item(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val name: String,
val description: String
)
Step 3: Create the Room DAO (Data Access Object)
Define an interface annotated with @Dao
that provides methods for accessing the data:
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import androidx.room.Delete
import kotlinx.coroutines.flow.Flow
@Dao
interface ItemDao {
@Query("SELECT * FROM items")
fun getAllItems(): Flow>
@Insert
suspend fun insertItem(item: Item)
@Update
suspend fun updateItem(item: Item)
@Delete
suspend fun deleteItem(item: Item)
}
Step 4: Build the Room Database
Create an abstract class annotated with @Database
to define the Room database:
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun itemDao(): ItemDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: android.content.Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = androidx.room.Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
Step 5: Implement the Repository
Create a repository class to handle data operations and synchronization. This repository will act as the single source of truth for your data:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class ItemRepository(private val itemDao: ItemDao) {
val allItems: Flow> = itemDao.getAllItems()
suspend fun insertItem(item: Item) {
withContext(Dispatchers.IO) {
itemDao.insertItem(item)
}
}
suspend fun updateItem(item: Item) {
withContext(Dispatchers.IO) {
itemDao.updateItem(item)
}
}
suspend fun deleteItem(item: Item) {
withContext(Dispatchers.IO) {
itemDao.deleteItem(item)
}
}
}
Step 6: Create the ViewModel
Create a ViewModel to hold and manage UI-related data in a lifecycle-conscious way:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
class ItemViewModel(private val repository: ItemRepository) : ViewModel() {
val allItems: LiveData> = repository.allItems.asLiveData()
fun insertItem(item: Item) {
viewModelScope.launch {
repository.insertItem(item)
}
}
fun updateItem(item: Item) {
viewModelScope.launch {
repository.updateItem(item)
}
}
fun deleteItem(item: Item) {
viewModelScope.launch {
repository.deleteItem(item)
}
}
}
class ItemViewModelFactory(private val repository: ItemRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ItemViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return ItemViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Step 7: Implement the UI with Jetpack Compose
Build the UI using Jetpack Compose to display and interact with the data:
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.material.Button
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@Composable
fun ItemListScreen(viewModel: ItemViewModel) {
val items: List- by viewModel.allItems.observeAsState(initial = emptyList())
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Items:")
LazyColumn {
items(items) { item ->
Text(text = "${item.name} - ${item.description}")
}
}
Button(onClick = {
viewModel.insertItem(Item(name = "New Item", description = "This is a new item"))
}) {
Text(text = "Add Item")
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
// Note: This requires a proper setup of the dependencies and database
// For a real preview, mock the ItemViewModel and AppDatabase.
// In this basic example, we skip a full functioning setup
Text("Previewing ItemListScreen requires more setup for the ViewModel")
}
Step 8: Integrate All Components
In your MainActivity
or entry point of the app, initialize the database, repository, and ViewModel, then set up the Compose UI:
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.Composable
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
@Composable
fun MyApp() {
val context = LocalContext.current
val database = AppDatabase.getDatabase(context)
val itemRepository = ItemRepository(database.itemDao())
val viewModel: ItemViewModel = viewModel(factory = ItemViewModelFactory(itemRepository))
ItemListScreen(viewModel = viewModel)
}
Handling Network Synchronization
For data synchronization with a remote server, implement a background service or worker that periodically checks for updates and syncs data. Libraries like Retrofit can be used for making network requests.
Conclusion
Building offline-first apps with Room and Jetpack Compose involves creating a robust local data layer with Room, designing a responsive UI with Compose, and synchronizing data when network connectivity is available. This approach enhances user experience by providing seamless access to data, regardless of the network state. By prioritizing local data storage, your application remains functional, reliable, and fast, making it a preferred choice for users in various connectivity environments.