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
inandroidMain
):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
iniosMain
):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
incommonMain
):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
inandroidMain
):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 thecreateDataStore
function. -
iOS Implementation (
DataStoreModule.ios.kt
iniosMain
):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() }
- In your iOS
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.