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.