Offline-First Apps with Room and XML UI

Creating applications that function seamlessly regardless of network connectivity is a critical aspect of modern Android development. Offline-first architecture ensures that your app remains usable and responsive even when the device is offline, offering a better user experience. Combining Room for local data persistence with traditional XML-based UIs provides a robust approach for building such applications. This post will explore how to implement an offline-first architecture in your Android app using Room and XML UI layouts.

What is an Offline-First App?

An offline-first app is designed to prioritize local data storage and retrieval. Instead of relying solely on a network connection, it uses a local database as the primary source of truth. When the app is online, it synchronizes data between the local database and a remote server. This ensures that the user can access and interact with the app even when there is no internet connectivity.

Why Choose Room and XML UI?

  • Room Persistence Library: Provides an abstraction layer over SQLite, making database interactions easier and more efficient. It also offers compile-time verification of SQL queries.
  • XML UI: Although Jetpack Compose is the modern approach, many existing and simpler projects still rely on XML for UI design, leveraging its straightforward layout definitions and compatibility.

How to Build an Offline-First App with Room and XML UI

Follow these steps to create an offline-first app:

Step 1: Set Up the Project

Create a new Android project in Android Studio. Ensure that you have the necessary dependencies added to your build.gradle file.

Step 2: Add Dependencies

Add the Room Persistence Library and any other necessary dependencies (such as Retrofit for network communication) to your build.gradle file:

dependencies {
    implementation "androidx.room:room-runtime:2.5.2"
    kapt "androidx.room:room-compiler:2.5.2"

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:2.5.2"

    // For network calls
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
    implementation "com.squareup.okhttp3:okhttp:4.11.0"
    implementation "com.squareup.okhttp3:logging-interceptor:4.9.0"

    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1"
    kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'


    // lifecycleScope
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"

    // Coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
}
kapt {
    correctErrorTypes = true
}

Don’t forget to sync the Gradle file after adding dependencies.

Step 3: Define the Entity

Create a data class to represent the entity that will be stored in the Room database. Annotate it with @Entity:

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey val id: Int,
    val name: String,
    val description: String
)

Step 4: Create the DAO (Data Access Object)

Create an interface annotated with @Dao to define the database operations:

import androidx.room.*

@Dao
interface ItemDao {
    @Query("SELECT * FROM items")
    suspend fun getAll(): List

    @Query("SELECT * FROM items WHERE id = :id")
    suspend fun getItemById(id: Int): Item?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)
}

Step 5: Create the Room Database

Create an abstract class that extends RoomDatabase to define the database. Annotate it with @Database:

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(entities = [Item::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Step 6: Set Up Retrofit for Network Calls

Define your API interface and Retrofit instance:

import retrofit2.http.GET
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

interface ApiService {
    @GET("items") // Replace with your API endpoint
    fun getItems(): Call>
}

object RetrofitClient {
    private const val BASE_URL = "https://api.example.com/" // Replace with your base URL

    val apiService: ApiService by lazy {
        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        retrofit.create(ApiService::class.java)
    }
}

Step 7: Create a Repository

Implement a repository to handle data operations from local and remote sources:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.awaitResponse
import android.util.Log
class ItemRepository(private val itemDao: ItemDao, private val apiService: ApiService) {

    suspend fun getItems(): List {
        return withContext(Dispatchers.IO) {
            try {
                val response = apiService.getItems().awaitResponse()
                if (response.isSuccessful) {
                    val items = response.body() ?: emptyList()
                    // Refresh local database
                    itemDao.getAll().forEach { itemDao.delete(it) } // Clear existing items
                    items.forEach { itemDao.insert(it) }       // Insert new items
                    Log.d("ItemRepository", "Data fetched from network and updated local db")
                    return@withContext items
                } else {
                   Log.e("ItemRepository", "Failed to fetch from network: ${response.message()}")
                }
            } catch (e: Exception) {
                Log.e("ItemRepository", "Network error: ${e.message}, fetching from local db")
            }
            Log.d("ItemRepository", "Fetching data from local db")
            itemDao.getAll() // Fallback to local database
        }
    }
}

Step 8: Set Up ViewModel

Use a ViewModel to manage UI-related data in a lifecycle-conscious way:

import androidx.lifecycle.*
import kotlinx.coroutines.launch

class ItemViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _items = MutableLiveData>()
    val items: LiveData> = _items

    init {
        loadItems()
    }

    fun loadItems() {
        viewModelScope.launch {
            _items.value = itemRepository.getItems()
        }
    }
}

class ItemViewModelFactory(private val itemRepository: ItemRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(ItemViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return ItemViewModel(itemRepository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Step 9: Design the XML UI

Create the XML layout file for your activity. Include a RecyclerView to display the list of items:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Step 10: Implement the Activity

In your MainActivity, initialize the RecyclerView, ViewModel, and observe the LiveData:

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.Observer
import android.content.Context
class MainActivity : AppCompatActivity() {

    private lateinit var itemViewModel: ItemViewModel
    private lateinit var recyclerView: RecyclerView
    private lateinit var itemAdapter: ItemAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        recyclerView = findViewById(R.id.recyclerView)
        recyclerView.layoutManager = LinearLayoutManager(this)
        itemAdapter = ItemAdapter(emptyList()) // Initially empty list
        recyclerView.adapter = itemAdapter


        val database = AppDatabase.getDatabase(this)
        val itemDao = database.itemDao()
        val apiService = RetrofitClient.apiService
        val itemRepository = ItemRepository(itemDao, apiService)

        itemViewModel = ViewModelProvider(this, ItemViewModelFactory(itemRepository)).get(ItemViewModel::class.java)

        itemViewModel.items.observe(this, Observer { items ->
            itemAdapter = ItemAdapter(items)
            recyclerView.adapter = itemAdapter
            itemAdapter.notifyDataSetChanged()
        })
        
        
        itemViewModel.loadItems() // Explicitly load items after the observer is set

    }
}

Step 11: Create the RecyclerView Adapter

Create an adapter to populate the RecyclerView with data:

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class ItemAdapter(private val items: List) : RecyclerView.Adapter<ItemAdapter.ItemViewHolder>() {

    class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val nameTextView: TextView = itemView.findViewById(R.id.nameTextView)
        val descriptionTextView: TextView = itemView.findViewById(R.id.descriptionTextView)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_layout, parent, false)
        return ItemViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
        val currentItem = items[position]
        holder.nameTextView.text = currentItem.name
        holder.descriptionTextView.text = currentItem.description
    }

    override fun getItemCount(): Int {
        return items.size
    }
}

And the corresponding item_layout.xml:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/nameTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="18sp"
        android:textStyle="bold"/>

    <TextView
        android:id="@+id/descriptionTextView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="14sp"/>

</LinearLayout>

Handling Network State

You should also incorporate network state detection to show user-friendly messages when the device is offline.

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities

object NetworkUtils {

    fun isNetworkAvailable(context: Context?): Boolean {
        if (context == null) return false
        val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
        if (capabilities != null) {
            when {
                capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
                    return true
                }
                capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
                    return true
                }
                capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> {
                    return true
                }
            }
        }
        return false
    }
}

Conclusion

Building offline-first apps with Room and XML UI involves setting up local data storage using Room, implementing data synchronization with a remote server using Retrofit, and designing your UI with XML layouts. This approach allows your app to provide a seamless user experience, even when the device is offline. By following these steps, you can create a robust and user-friendly Android application.