Modern Data Storage in Android: Using Preferences DataStore with Kotlin Multiplatform

SharedPreferences has long been the go-to solution for storing small amounts of key-value data in Android applications. However, a more modern and efficient alternative is now available: Preferences DataStore. This guide explains how to use and set it up.

What is Preferences DataStore?

Preferences DataStore offers a superior approach to storing simple key-value pairs. It leverages Kotlin’s Flow for asynchronous data handling, providing improved performance and ensuring data consistency. This makes it a robust replacement for the older SharedPreferences. It utilizes Kotlin coroutines for fully asynchronous operation. This avoids blocking the main thread, leading to a smoother user experience.

Setting Up DataStore in a Kotlin Multiplatform Project

This guide focuses on integrating Preferences DataStore within a Kotlin Multiplatform project, allowing you to share data persistence logic across both Android and iOS. We’ll use Koin, a lightweight dependency injection framework, to manage our dependencies.

1. Adding Dependencies

First, you need to add the necessary libraries to your project. Manage your dependencies using a libs.versions.toml file. This is better to keep the code orginized

[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" }

Next, include these dependencies in your shared module’s build.gradle.kts file (typically composeApp or similar):

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)
    }
}

2. Creating a DataStore Instance (Common Code)

Create a file named DataStoreInstance.kt in your commonMain source set. This file will contain the core DataStore creation logic:

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"

This code defines a createDataStore function that takes a function (producePath) as an argument. This producePath function will provide the file path for the DataStore, which will differ between Android and iOS. The synchronized block ensures thread safety when creating the DataStore instance.

3. Platform-Specific DataStore Creation

Now, create platform-specific implementations of the createDataStore function.

  • Android (DataStoreInstance.android.kt in androidMain):
    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
        }
    }
    

    This uses the Android Context to get the application’s files directory.

  • iOS (DataStoreInstance.ios.kt in iosMain):

    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"
        }
    }
    

    This uses iOS-specific APIs (NSFileManager, NSDocumentDirectory) to get the appropriate directory.

4. Dependency Injection with Koin

To provide the DataStore instance to your ViewModels or repositories, use Koin for dependency injection.

  • Common Module (DataStoreModule.kt in commonMain):
    import org.koin.core.module.Module
    
    expect val dataStoreModule: Module
    

    This declares an expect module, which will be implemented differently for each platform.

  • Android Implementation (DataStoreModule.android.kt in androidMain):

    import com.rainday.datastorecmp.createDataStore //replace this with your project name and path
    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()) } }
    

    This provides the Android Context to the createDataStore function.

  • iOS Implementation (DataStoreModule.ios.kt in iosMain):

    actual val dataStoreModule: Module
        get() = module { single { createDataStore() } }
    

    This calls the iOS-specific createDataStore function.

5. Initializing Koin

Initialize Koin in your application. Modify the initKoin function in commonMain:

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

        modules(dataStoreModule)
    }
}
  • In your Android Application class:
    class YourApplicationClass: Application() {
        override fun onCreate() {
            super.onCreate()
            initKoin(
                config = {
                    androidContext(this@YourApplicationClass) // Use 'this' directly
                }
            )
        }
    }
    
    • In your iOS MainViewController:
        fun MainViewController() = ComposeUIViewController(
            configure = {
            initKoin()
            }
            ) { App() }
    

6. Using DataStore in a ViewModel
Create a ViewModel in the 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)
                }
            }
        }
      }
    }

Define a Koin Module in commonMain

    val viewModelModule = module {
    viewModel { AppViewModel(get()) }
    }

Update our initKoin function:

    fun initKoin(
    config: (KoinApplication.() -> Unit)? = null
      ) {
       startKoin {
        config?.invoke(this)
        modules(viewModelModule, dataStoreModule) // add viewModelModule
      }
    }

7. UI Layer (Jetpack Compose)

Inject the AppViewModel into your composable:

    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")
                        }
                    }
                }
            }
        }
    }

Now you can run your application on both Android and iOS, and the data will be persisted using Preferences DataStore.

Innovative Software Technology: Streamlining Your Data Persistence

At Innovative Software Technology, we specialize in crafting robust and efficient mobile applications, including seamless data persistence solutions. By leveraging technologies like Preferences DataStore and Kotlin Multiplatform, we can help you build cross-platform apps that store and retrieve data reliably. Our expertise in dependency injection frameworks like Koin ensures clean, maintainable, and testable code. Optimize your app’s performance and data management with our custom software development services, focusing on keywords like “Android Data Storage,” “Kotlin Multiplatform Development,” “Cross-Platform App Persistence,” “Preferences DataStore Implementation,” and “Mobile App Data Management.” Contact us today to enhance your application’s data handling capabilities and improve user experience.

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed