DEV Community

Rendy Adidarma
Rendy Adidarma

Posted on

How to Implement Preferences DataStore for Compose Multiplatform Mobile (Android and iOS)

What is Preferences Data Store?
Preferences DataStore is a modern way to store small amounts of key-value data in Android, replacing SharedPreferences. It is more efficient because it uses Kotlin Flow for async data handling and ensures data consistency.

By the end of this guide, you’ll have a working DataStore setup that allows you to store and retrieve key-value preferences across both platforms. (in Compose Multiplatform :D)

Photo by [Claudio Schwarz](https://unsplash.com/@purzlbaum?utm_source=medium&utm_medium=referral) on [Unsplash](https://unsplash.com?utm_source=medium&utm_medium=referral)

Setting Up Data Store in Compose Multiplatform Project.

In this article, we will use Koin to manage dependency injection in our project. Koin is a lightweight and easy-to-use DI framework that helps us organize and inject dependencies efficiently.
We will set up Preferences DataStore for Android and iOS separately, and use Koin to provide instances of these storage solutions.

Add Dependencies

In your *libs.versions.toml *file add this versions and libraries:

[versions]

datastore = "1.1.3"
koin = "3.5.6"
koinCompose = "1.1.5"
koinComposeViewModel = "1.2.0-Beta4"

[libraries]

datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinCompose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koinComposeViewModel" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
Enter fullscreen mode Exit fullscreen mode

In your shared module/composeApp (build.gradle.kts), add:

sourceSets {
        androidMain.dependencies {
            implementation(libs.koin.android)
        }
        commonMain.dependencies {
            implementation(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.koin.compose.viewmodel)

            implementation(libs.datastore)
            implementation(libs.datastore.preferences)
        }
}
Enter fullscreen mode Exit fullscreen mode

Create a DataStore instance in **commonMain **package.

Save this as DataStoreInstance.kt in commonMain:

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.internal.SynchronizedObject
import kotlinx.coroutines.internal.synchronized
import okio.Path.Companion.toPath

@OptIn(InternalCoroutinesApi::class)
private val lock = SynchronizedObject() // Used for thread safety
private lateinit var dataStore: DataStore<Preferences> // Late-initialized variable

@OptIn(InternalCoroutinesApi::class)
fun createDataStore(producePath: () -> String): DataStore<Preferences> {
    return synchronized(lock) {
        if (::dataStore.isInitialized) {
            dataStore
        } else {
            PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
                .also { dataStore = it }
        }
    }
}

internal const val DATA_STORE_FILE_NAME = "storage.preferences_pb"
Enter fullscreen mode Exit fullscreen mode

Call this function in Android with path argument (DataStoreInstance.android.kt)

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences

fun createDataStore(context: Context): DataStore<Preferences> {
    return createDataStore {
        context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath
    }
}
Enter fullscreen mode Exit fullscreen mode

Also, we need to call this function in iOS (DataStoreInstance.ios.kt)

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask

@OptIn(ExperimentalForeignApi::class)
fun createDataStore(): DataStore<Preferences> {
    return createDataStore {
        val directory = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )

        requireNotNull(directory).path + "/$DATA_STORE_FILE_NAME"
    }
}
Enter fullscreen mode Exit fullscreen mode

DataStore config setup is complete, now we need to setup Koin for Dependency Injection, the purposes is to inject or provide DataStore in our repository *or *viewmodel.

In commonMain, create a DataStoreModule with expected variable inside it:

commonMain (DataStoreModule.kt)

import org.koin.core.module.Module

expect val dataStoreModule: Module
Enter fullscreen mode Exit fullscreen mode

Add the actual declaration in androidMain and iosMain :

Initialize Actual Declaration

androidMain (DataStoreModule.android.kt)

import com.rainday.datastorecmp.createDataStore
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.module

actual val dataStoreModule: Module
 get() = module { single { createDataStore(androidContext()) } }
Enter fullscreen mode Exit fullscreen mode

iosMain (DataStoreModule.ios.kt)

actual val dataStoreModule: Module
    get() = module { single { createDataStore() } }
Enter fullscreen mode Exit fullscreen mode

Notice in our Android implementation, we require a Context to create the Preferences DataStore, while in iOS, there is no context dependency.

To handle this difference, we can modify our Koin initialization function (initKoin) to accept a configuration function (config). This allows us to set up the Android-specific context without affecting iOS platform.

// commonMain
fun initKoin(
    config: (KoinApplication.() -> Unit)? = null
) {
    startKoin {
        config?.invoke(this)

        modules(dataStoreModule)
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to initialize the Koin in Android and iOS

androidMain (Application Class)

class YourApplicationClass: Application() {
    override fun onCreate() {
        super.onCreate()
        initKoin(
            config = {
                androidContext(this@BaseApplication)
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

iosMain (MainViewController.kt)

fun MainViewController() = ComposeUIViewController(
    configure = {
        initKoin()
    }
) { App() }
Enter fullscreen mode Exit fullscreen mode

That’s it! Our Preferences DataStore is now ready to be used in a Repository or ViewModel.

Here’s an example of how you can use it:

ViewModel in **commonMain **package

class AppViewModel(
    private val dataStore: DataStore<Preferences>
): ViewModel() {

    private val key = stringPreferencesKey("name")

    private var _name = MutableStateFlow("")
    val name = _name.asStateFlow()

    init {
        viewModelScope.launch {
            dataStore.data.collect { storedData ->
                _name.update {
                    storedData.get(key).orEmpty()
                }
            }
        }
    }

    fun updateName(name: String) = _name.update { name }

    fun storeToDataStore() {
        viewModelScope.launch {
            dataStore.updateData {
                it.toMutablePreferences().apply {
                    set(key, name.value)
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Define a Koin Module in**commonMain **which provides a AppViewModel instance for dependency injection.

val viewModelModule = module {
    viewModel { AppViewModel(get()) } 
// automatically injecting the required parameters using get()
}
Enter fullscreen mode Exit fullscreen mode

Update our **initKoin **function:

fun initKoin(
    config: (KoinApplication.() -> Unit)? = null
) {
    startKoin {
        config?.invoke(this)

        modules(viewModelModule, dataStoreModule) // add viewModelModule
    }
}
Enter fullscreen mode Exit fullscreen mode

UI Layer (Shared UI with Jetpack Compose)

Inject AppViewModel in Composable App():

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.KoinContext
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI

@OptIn(KoinExperimentalAPI::class)
@Composable
@Preview
fun App() {
    MaterialTheme {
        KoinContext {
            val viewModel = koinViewModel<AppViewModel>()

            val name by viewModel.name.collectAsStateWithLifecycle()

            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Column(
                    modifier = Modifier.padding(16.dp)
                ) {
                    TextField(
                        value = name,
                        onValueChange = viewModel::updateName,
                        label = { Text("Name") }
                    )

                    Button(onClick = viewModel::storeToDataStore, modifier = Modifier.padding(top = 8.dp)) {
                        Text("Store")
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Voila! 🎉 The Preferences DataStore is now fully set up and ready to use in both Android and iOS. Now, let’s test it in action! Below is video showcasing how it works on both platforms.

Android API 35

Simulator 18.3.1

Want to see the full implementation? The complete code for this tutorial is available on my GitHub. Feel free to explore and try it out!
GitHub - rendyadidarma/DataStoreComposeMultiPlatform: A Compose multiplatform project that…

💬 Let’s stay connected! If you found this tutorial helpful, feel free to reach out and connect with me on LinkedIn or Github

Top comments (0)