Offline-First Apps with Room and Jetpack Compose

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.