Offline-First Android with XML & Kotlin: Best Techniques for Reliable UIs

Creating robust and reliable Android applications often requires considering how your app will function when a network connection is unavailable. An offline-first approach prioritizes making the core functionality of the app accessible even without an active internet connection. This technique enhances the user experience, particularly in areas with unreliable or intermittent connectivity. While Jetpack Compose is gaining popularity, many Android apps still use XML for their layouts, coupled with Kotlin for their logic. This post explores several techniques for building offline-first UIs using XML layouts and Kotlin in Android development.

What is Offline-First?

Offline-first is a design approach where an application is designed to work primarily without an internet connection. Data is stored locally on the device, and the application interacts with this local data. When a network connection becomes available, the app synchronizes the local data with a remote server.

Why Adopt an Offline-First Approach?

  • Enhanced User Experience: Users can continue to use the app and access data even when offline.
  • Improved Performance: Local data access is generally faster than fetching data from a remote server.
  • Increased Reliability: The app remains functional even with unreliable network connectivity.

Techniques for Building Offline-First UIs

1. Local Data Storage

The foundation of an offline-first app is local data storage. There are several options available:

  • SQLite: Android’s built-in relational database for structured data.
  • SharedPreferences: For storing small amounts of primitive data.
  • Room Persistence Library: A higher-level abstraction over SQLite that simplifies database access and management.
  • DataStore: A data storage solution from Jetpack that allows you to store key-value pairs or typed objects with protocol buffers.

Example using Room Persistence Library:

Step 1: Add Dependencies

Include the Room dependencies in your build.gradle file:

dependencies {
    implementation("androidx.room:room-runtime:2.6.1")
    kapt("androidx.room:room-compiler:2.6.1") // Use kapt for Kotlin
    implementation("androidx.room:room-ktx:2.6.1") // Kotlin extensions for Room
}
Step 2: Define the Entity and DAO

Create an entity representing your data and a DAO (Data Access Object) for database interactions.


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
)

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query

@Dao
interface ItemDao {
    @Query("SELECT * FROM items")
    suspend fun getAllItems(): List<Item>

    @Insert
    suspend fun insertItem(item: Item)
}
Step 3: Create the Room Database

Define the Room database class:


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

@Database(entities = [Item::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun itemDao(): ItemDao
}
Step 4: Access the Database in Your Application

Instantiate the database and use the DAO to perform CRUD operations.


import android.content.Context
import androidx.room.Room

class DatabaseProvider(context: Context) {
    val db: AppDatabase by lazy {
        Room.databaseBuilder(
            context.applicationContext,
            AppDatabase::class.java,
            "app_database"
        ).build()
    }
}

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class MyViewModel(context: Context) : ViewModel() {
    private val databaseProvider = DatabaseProvider(context)
    private val itemDao = databaseProvider.db.itemDao()

    suspend fun getAllItems(): List<Item> = withContext(Dispatchers.IO) {
        itemDao.getAllItems()
    }

    suspend fun insertItem(item: Item) = withContext(Dispatchers.IO) {
        itemDao.insertItem(item)
    }
}

2. Caching API Responses

To provide a seamless experience, cache API responses locally and use this cached data when the device is offline. Retrofit and OkHttp can be used with interceptors to cache responses.

Step 1: Add Dependencies

Include the necessary dependencies in your build.gradle file:

dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.okhttp3:okhttp:4.9.1")
    implementation("com.squareup.okhttp3:logging-interceptor:4.9.1")
}
Step 2: Configure OkHttp Cache

import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
import java.util.concurrent.TimeUnit

class OkHttpClientProvider(context: Context) {
    val okHttpClient: OkHttpClient by lazy {
        val cacheSize = (5 * 1024 * 1024).toLong() // 5MB
        val httpCacheDirectory = File(context.cacheDir, "http-cache")
        val cache = Cache(httpCacheDirectory, cacheSize)

        val loggingInterceptor = HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        }

        OkHttpClient.Builder()
            .cache(cache)
            .addInterceptor(loggingInterceptor)
            .readTimeout(30, TimeUnit.SECONDS)
            .connectTimeout(30, TimeUnit.SECONDS)
            .build()
    }
}
Step 3: Configure Retrofit

import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class ApiClient(context: Context) {
    private val okHttpClientProvider = OkHttpClientProvider(context)

    val retrofit: Retrofit by lazy {
        Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(okHttpClientProvider.okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}
Step 4: Use the Cached Responses

When making API calls, Retrofit will automatically use cached responses if available.

3. Using WorkManager for Background Synchronization

Use WorkManager to schedule background tasks for synchronizing local data with a remote server when the network is available. WorkManager handles task scheduling, even if the app is closed or the device is restarted.

Step 1: Add Dependency
dependencies {
    implementation("androidx.work:work-runtime-ktx:2.9.0")
}
Step 2: Create a Worker Class

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters

class SyncWorker(appContext: Context, workerParams: WorkerParameters) :
    CoroutineWorker(appContext, workerParams) {

    override suspend fun doWork(): Result {
        return try {
            // Implement data synchronization logic here
            // Fetch data from remote server and update local database

            Result.success()
        } catch (e: Exception) {
            Result.failure()
        }
    }
}
Step 3: Schedule the Work

import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit

class MyViewModel(application: Application) : AndroidViewModel(application) {

    fun scheduleSync() {
        val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
            .setInitialDelay(1, TimeUnit.HOURS) // Example: Run after 1 hour
            .build()

        WorkManager.getInstance(application).enqueue(syncRequest)
    }
}

4. Handling UI State

When the app is offline, update the UI to reflect the current state of the local data. Provide informative messages when data synchronization is not possible due to the lack of network connectivity.


import android.widget.TextView
import android.widget.Toast

fun updateUI(data: List<Item>?, isOnline: Boolean, textView: TextView) {
    if (isOnline) {
        if (data != null) {
            textView.text = "Data: ${data.joinToString { it.name }}"
        } else {
            textView.text = "No data available"
        }
    } else {
        textView.text = "Offline: displaying cached data"
        Toast.makeText(textView.context, "App is offline", Toast.LENGTH_SHORT).show()
    }
}

5. Using ConnectivityManager to Detect Network State

Check for network connectivity before attempting to synchronize data.


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

fun isNetworkAvailable(context: Context): Boolean {
    val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
    val network = connectivityManager.activeNetwork ?: return false
    val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false

    return when {
        activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
        activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
        else -> false
    }
}

6. XML Layout Considerations

When designing your XML layouts, keep the following considerations in mind for offline-first:

  • Placeholder Content: Use placeholder images and text when data is not available.
  • Error Messages: Include TextViews to display error messages when offline or when data cannot be loaded.
  • Refresh Indicators: Use SwipeRefreshLayout to allow users to manually refresh data when back online.

Example XML Layout


<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipeRefreshLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <TextView
        android:id="@+id/errorMessage"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="No data available. Please check your internet connection."
        android:visibility="gone"
        android:gravity="center"/>

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

Conclusion

Building offline-first Android applications using XML layouts and Kotlin involves local data storage, caching API responses, background synchronization with WorkManager, and careful consideration of UI state. By implementing these techniques, you can create apps that provide a seamless and reliable user experience, even when the device is offline.