Compose Multiplatform: Data Management Strategies in Jetpack Compose

As Kotlin Multiplatform (KMP) gains traction, building apps that share code across different platforms becomes more appealing. Managing data efficiently in these multiplatform applications, especially within the Jetpack Compose UI layer, is essential for creating a smooth and maintainable user experience. This blog post explores several strategies for effective data management in Compose Multiplatform apps, leveraging Jetpack Compose principles and KMP capabilities.

What is Kotlin Multiplatform (KMP)?

Kotlin Multiplatform is a feature of Kotlin that allows you to share common code between multiple platforms, such as Android, iOS, desktop, and web. By using KMP, you can write shared business logic, data models, and other non-UI components once and reuse them across different targets, reducing development time and ensuring consistency.

Data Management Challenges in Compose Multiplatform Apps

  • Platform Differences: Handling data persistence and access can vary significantly between platforms.
  • UI State Management: Coordinating UI state across different platforms while keeping it consistent.
  • Data Serialization: Converting data between different formats that are compatible with each platform.

Strategies for Data Management in Compose Multiplatform

1. Utilizing expect/actual for Platform-Specific Implementations

The expect/actual mechanism in Kotlin allows you to define a common interface in the shared code and provide platform-specific implementations. This is useful for handling platform-specific data operations such as database access or file storage.

Step 1: Define expect Declarations in Common Code

Create an expect declaration in your common module:


// commonMain
expect class DatabaseHelper {
    fun saveData(data: String)
    fun loadData(): String?
}
Step 2: Provide actual Implementations for Each Platform

Provide actual implementations for Android:


// androidMain
import android.content.Context
import android.content.SharedPreferences

actual class DatabaseHelper(private val context: Context) {
    private val sharedPreferences: SharedPreferences =
        context.getSharedPreferences("AppData", Context.MODE_PRIVATE)

    actual fun saveData(data: String) {
        sharedPreferences.edit().putString("data", data).apply()
    }

    actual fun loadData(): String? {
        return sharedPreferences.getString("data", null)
    }
}

And for iOS (using e.g., NSUserDefaults):


// iosMain
import platform.Foundation.NSUserDefaults

actual class DatabaseHelper {
    private val userDefaults = NSUserDefaults.standardUserDefaults

    actual fun saveData(data: String) {
        userDefaults.setObject(data, forKey = "data")
        userDefaults.synchronize()
    }

    actual fun loadData(): String? {
        return userDefaults.stringForKey("data") as String?
    }
}

2. Using Ktor for Networking and Data Retrieval

Ktor is a Kotlin-based framework for building asynchronous clients and servers. You can use Ktor to fetch data from a remote server in your shared code, then expose this data to your Compose UI.

Step 1: Add Ktor Dependencies

In your build.gradle.kts, add the necessary Ktor dependencies:


dependencies {
    implementation("io.ktor:ktor-client-core:$ktor_version")
    implementation("io.ktor:ktor-client-serialization:$ktor_version")
    implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
    // Platform-specific engines
    implementation("io.ktor:ktor-client-android:$ktor_version") // For Android
    implementation("io.ktor:ktor-client-ios:$ktor_version")     // For iOS
}

Ensure that you configure platform-specific HTTP engines for each target.

Step 2: Implement Data Retrieval using Ktor

Create a data retrieval function in your common code:


import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class SampleData(val id: Int, val name: String)

class ApiClient {
    private val httpClient = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                isLenient = true
            })
        }
    }

    suspend fun fetchData(): List<SampleData> {
        return httpClient.get("https://example.com/data").body()
    }
}

3. Data Modeling with Kotlin Serialization

Kotlin Serialization is a Kotlin library for converting objects to and from serial formats like JSON. This is crucial for handling data from network requests or local storage.

Step 1: Add Kotlin Serialization Plugin

In your build.gradle.kts file, add the Kotlin Serialization plugin:


plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
}
Step 2: Define Serializable Data Classes

Mark your data classes with the @Serializable annotation:


import kotlinx.serialization.Serializable

@Serializable
data class User(val id: Int, val name: String, val email: String)

4. UI State Management with Compose and ViewModel

Utilize Jetpack Compose’s state management features, along with ViewModels (or similar constructs for non-Android platforms), to manage UI state. Create platform-specific implementations of ViewModels using expect/actual to handle data retrieval and UI updates.

Step 1: Define ViewModel in Common Code

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.CoroutineScope

expect open class BaseViewModel() {
    val scope: CoroutineScope
}

class SharedViewModel() : BaseViewModel() {
    private val _data = MutableStateFlow<List<SampleData>>(emptyList())
    val data: StateFlow<List<SampleData>> = _data

    init {
        loadData()
    }

    fun loadData() {
        scope.launch {
            val apiClient = ApiClient()
            _data.value = apiClient.fetchData()
        }
    }
}
Step 2: Implement Platform-Specific ViewModels

For Android:


import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

actual open class BaseViewModel : ViewModel() {
    actual val scope = viewModelScope
}

For iOS:


import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.CoroutineContext

actual open class BaseViewModel {
    private val job = SupervisorJob()
    private val dispatcher: CoroutineContext = Dispatchers.Main

    actual val scope = CoroutineScope(dispatcher + job)
}
Step 3: Integrate with Compose UI

Collect data from the StateFlow in your Compose UI:


import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.Composable

@Composable
fun DataView(viewModel: SharedViewModel) {
    val dataList = viewModel.data.collectAsState().value

    LazyColumn {
        items(dataList) { item ->
            Text(text = "Item: ${item.name}")
        }
    }
}

5. Data Persistence using SQLDelight

SQLDelight is a Kotlin library that generates Kotlin code from SQL, providing type-safe access to your databases. It supports multiple platforms, making it ideal for KMP applications.

Step 1: Add SQLDelight Plugin

Add the SQLDelight plugin to your build.gradle.kts file:


plugins {
    id("app.cash.sqldelight") version "2.0.0"
}

sqldelight {
    database("MyDatabase") {
        packageName = "com.example.mydatabase"
    }
}
Step 2: Define Your Database Schema

Create a .sq file with your database schema:


-- Person.sq
CREATE TABLE Person (
  id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT NOT NULL
);

insertPerson:
INSERT INTO Person (name, email) VALUES (?, ?);

selectAllPersons:
SELECT * FROM Person;
Step 3: Use Generated Code for Data Access

Use the generated code to access your database:


import com.example.mydatabase.MyDatabase
import com.squareup.sqldelight.db.SqlDriver

class DatabaseManager(sqlDriver: SqlDriver) {
    private val database = MyDatabase(sqlDriver)

    fun insertPerson(name: String, email: String) {
        database.personQueries.insertPerson(name, email)
    }

    fun getAllPersons(): List<Person> {
        return database.personQueries.selectAllPersons().executeAsList()
    }
}

Provide platform-specific SqlDriver implementations using expect/actual.

Conclusion

Efficient data management in Compose Multiplatform applications involves leveraging KMP features like expect/actual, Kotlin Serialization, and multiplatform libraries like Ktor and SQLDelight. By combining these tools with Jetpack Compose’s state management capabilities, you can create robust, maintainable, and platform-consistent applications. Choose the strategies that best fit your project’s requirements to streamline development and provide a seamless user experience across all supported platforms.