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.