Modernizing Application Settings in Kotlin Multiplatform with DataStore and Koin

Kerry Bisset
ProAndroidDev
Published in
8 min readMay 27, 2024

How do you manage application preferences in your projects? If you’re a seasoned Android developer, you’ve likely used SharedPreferences at some point. Perhaps you’ve even implemented custom solutions using SQLite databases to handle more complex data requirements. More recently, you might have explored Google’s DataStore, a modern alternative designed to address the limitations of SharedPreferences.

In Kotlin Multiplatform, managing application settings across different platforms presents some challenges. Ensuring consistency, performance, and type safety is a nicety, especially as applications grow more complex and demand better user experiences.

Let’s explore why SharedPreferences is becoming outdated and how DataStore offers a more modern solution. We’ll then walk you through setting up DataStore in a Kotlin Multiplatform project, implementing it for settings management, and wrapping it in an interface for dependency injection.

The Problem with SharedPreferences

SharedPreferences has been a staple for managing simple key-value pairs in Android applications for years. Its ease of use and straightforward API made it a go-to solution for storing user preferences and other small pieces of data. However, as applications have evolved, the limitations of SharedPreferences have become more apparent.

Limitations of SharedPreferences

  1. Synchronous API Leading to ANR Issues SharedPreferences operates synchronously, blocking the main thread during read and write operations. This can lead to Application Not Responding (ANR) issues, especially when handling larger data sets or performing frequent read/write operations.
  2. Lack of Type Safety SharedPreferences stores data as key-value pairs, where values are stored as primitives (strings, integers, etc.). This approach lacks type safety, leading to potential runtime errors and the need for repetitive type casting.
  3. Difficulties in Managing Complex Data When your application needs to store more complex data structures (e.g., lists, objects), SharedPreferences falls short. Developers often have to serialize and deserialize data manually, adding complexity and potential for errors.
  4. Limited Scalability and Performance SharedPreferences is not designed to handle large amounts of data. As the amount of data grows, performance issues become more pronounced. Additionally, managing multiple preferences files can be cumbersome and error-prone.
  5. No Coroutine or Reactive Support for Settings Change SharedPreferences lacks built-in support for coroutines or reactive programming paradigms. This limitation makes it difficult to handle settings changes efficiently in modern Android applications, which increasingly rely on coroutines and reactive streams (e.g., Flow, LiveData) to manage asynchronous operations and data updates.

Given these limitations, a more modern, efficient, and flexible solution is needed for managing application settings, especially in a multiplatform context where consistency and performance are paramount. This is where DataStore comes into play, offering a compelling alternative to SharedPreferences.

Introducing DataStore for Multiplatform

As mobile applications have evolved, so have the tools and libraries designed to manage their settings and preferences. Enter DataStore, a modern solution developed by Google to address the shortcomings of SharedPreferences. DataStore is not only a alternative for Android but also extends its capabilities to Kotlin Multiplatform projects, ensuring consistent and efficient settings management across different platforms.

Overview of DataStore

DataStore offers a flexible way to store data asynchronously. Unlike SharedPreferences, which is synchronous and can block the main thread, DataStore leverages Kotlin’s coroutines and Flow, providing a more efficient and scalable approach. It comes in two flavors: Proto DataStore and Preferences DataStore.

Proto DataStore

I won’t fully cover this in this article but it is nice to know it is there.

  • Uses Protocol Buffers to define the schema.
  • Provides type safety and structured data.
  • It is ideal for handling more complex data structures.

Preferences DataStore

  • Simpler key-value pair storage similar to SharedPreferences but with added benefits of coroutines and Flow.
  • It is easier to implement when migrating from SharedPreferences.

Benefits of DataStore in a Multiplatform Context

  1. Consistent API Across Platforms DataStore offers a unified API that works across different platforms, ensuring consistency in managing settingsregardless of the operating system.
  2. Asynchronous Operations with Coroutines and Flow By utilizing coroutines and Flow, DataStore enables asynchronous data handling, which leads to better application performance and responsiveness. This approach helps avoid main thread blocking and potential ANR issues.
  3. Type Safety and Structured Data With Proto DataStore, developers can define a schema using Protocol Buffers, ensuring type safety and enabling the storage of complex data structures without the need for manual serialization and deserialization.

Why DataStore is the Future

DataStore addresses many of the limitations inherent in SharedPreferences, making it a superior choice for managing application settings. Its asynchronous nature, type safety, and support for complex data structures make it well-suited for modern Android and multiplatform applications. By adopting DataStore, developers can ensure a more efficient and scalable approach to settings management, leading to better user experiences and more maintainable codebases.

Setting Up DataStore in Kotlin Multiplatform

Setting up DataStore in a Kotlin Multiplatform project involves configuring your dependencies and creating a structure across different platforms.

Prerequisites

Before we begin, ensure you have the following prerequisites in place:

  • A Kotlin Multiplatform project setup with at least Android and a shared module.
  • The latest version of Kotlin and the Kotlin Multiplatform plugin.
  • libs.toml file for managing dependencies.
[versions]
datastore = "1.1.1"

[libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }

Official Documentation

From: https://developer.android.com/kotlin/multiplatform/datastore

You can define the DataStore class with DataStoreFactory inside the common source of your shared KMP module. Placing these classes in common sources allows them to be shared across all target platforms. You can use actual and expect declarations to create platform-specific implementations.

// shared/src/androidMain/kotlin/createDataStore.kt

/**
* Gets the singleton DataStore instance, creating it if necessary.
*/
fun createDataStore(producePath: () -> String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(
produceFile = { producePath().toPath() }
)

internal const val dataStoreFileName = "dice.preferences_pb"

Create an instance of DataStore. Never create multiple instances of DataStore for a given file; doing so can break all DataStore functionality. You should consider managing your DataStore instance as a singleton.

Implementing Preferences with Koin

Using Koin for dependency injection in your Kotlin Multiplatform project can greatly simplify the management of DataStore instances. This section will guide you through implementing preferences with Koin, providing a way to handle multiple DataStore instances across different parts of your application.

Step-by-Step Implementation

Create a DataStoreFactory Implementation

First, create a class that implements the DataStoreFactory interface. This class will handle the creation of DataStore instances, allowing you to specify the base path and name for each DataStore.


import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.protobuf.PreferenceDataStoreFactory
import org.koin.core.qualifier.Qualifier
import kotlin.io.path.toPath

internal class DataStoreFactoryImpl(private val preferenceBasePath: String) : DataStoreFactory {
companion object {
private const val FILE_EXTENSION = ".preferences_pb"
}

override fun makeDataStore(name: Qualifier): DataStore<Preferences> {
return PreferenceDataStoreFactory.createWithPath {
"$preferenceBasePath${File.separator}${name.value}$FILE_EXTENSION".toPath()
}
}
}

Create a Function to Set Up Preference Areas

Define a function that sets up the preference areas using Koin. This function will create a Koin module and register DataStore instances for each area.

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import org.koin.core.KoinApplication
import org.koin.core.qualifier.Qualifier
import org.koin.core.qualifier.named
import org.koin.dsl.module

/**
* Creates preference areas and registers them in the Koin application.
*
* @param basePath The base path for the data store.
* @param areas A set of qualifiers representing different preference areas.
*/
fun KoinApplication.makePreferenceAreas(basePath: String, areas: Set<Qualifier>) {
val module = module {
val facade = DataStoreFactoryImpl(basePath)
areas.forEach { q ->
single<DataStore<Preferences>>(q) { facade.makeDataStore(q) }
}
}
koin.loadModules(listOf(module))
}

/**
* Retrieves a [DataStore] of [Preferences] for a specified area from Koin.
*
* @param area The qualifier representing the preference area.
* @return The [DataStore] of [Preferences] for the specified area.
*/
fun Koin.getPreferences(area: Qualifier): DataStore<Preferences> = get(area)

Initialize Koin in Your Application Class

Initialize Koin and set up the preference areas in your Android application class. This involves specifying the base path for DataStore files and the qualifiers for each preference area. This way, each platform that uses Koin will need to do this line to define where the DataStore files are placed.

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named

class ExampleApplication : Application() {
private lateinit var koinApplication: KoinApplication

override fun onCreate() {
super.onCreate()

koinApplication = startKoin {
androidLogger()
androidContext(this@ExampleApplication)
makePreferenceAreas(
this@ExampleApplication.filesDir.absolutePath, setOf(
named("AppSettings"),
named("UserSettings")
)
)
modules(
// Other dependencies
appModule,
)
}
}
}

Using DataStore in Your Composable Functions

Retrieve the DataStore instances using the getPreferences extension function provided by Koin. This allows you to easily access the preferences in any of your access layers.

import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import org.koin.androidx.compose.getKoin
import org.koin.core.qualifier.named

@Composable
@Preview
fun SettingsUi() {
val koin = getKoin()
val preferences = koin.getPreferences(named("AppSettings"))
}

5. Migrating from SharedPreferences to DataStore with Koin

Migrating from SharedPreferences to DataStore in a Kotlin Multiplatform project involves setting up migration logic to ensure a smooth transition. Using PreferenceDataStoreFactory.createWithPath, we can specify migrations to transfer data from SharedPreferences to DataStore. This section will guide you through modifying our Koin-based implementation to include SharedPreferences migration.

Step-by-Step Migration

Update DataStoreFactory Implementation

Modify the DataStoreFactoryImpl class to accept migrations and create DataStore instances accordingly:

internal class DataStoreFactoryImpl(private val preferenceBasePath: String) : DataStoreFactory {
companion object {
private const val FILE_EXTENSION = ".preferences_pb"
}

override fun makeDataStore(name: Qualifier, migrations: List<DataMigration<Preferences>> = listOf()): DataStore<Preferences> {
return PreferenceDataStoreFactory.createWithPath(
migrations = migrations
) {
"$preferenceBasePath${File.separator}${name.value}$FILE_EXTENSION".toPath()
}
}
}

Define Migration Logic

Since this is specific to each project, this is an example of having a factory to make the strategy.


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

class SharedPreferencesMigrationFactory {
fun createMigration(context: Context, sharedPreferencesName: String): SharedPreferencesMigration<Preferences> {
return SharedPreferencesMigration(context, sharedPreferencesName)
}
}

Modify Koin Setup to Include Migration

Update your Koin setup to handle the migration by passing the necessary migration instances to DataStoreFactoryImpl. Use the SharedPreferencesMigrationFactory to create the migrations.

fun KoinApplication.makePreferenceAreas(basePath: String, areas: Map<Qualifier, List<DataMigration<Preferences>>>) {
val module = module {
val facade = DataStoreFactoryImpl(basePath)
areas.forEach { (qualifier, migrations) ->
single<DataStore<Preferences>>(qualifier) { facade.makeDataStore(qualifier, migrations) }
}
}
koin.loadModules(listOf(module))
}

Initialize Koin in Your Application Class

In your Android application class, initialize Koin and set up the preference areas with migrations.

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.core.qualifier.named

class ExampleApplication : Application() {
private lateinit var koinApplication: KoinApplication

override fun onCreate() {
super.onCreate()

val sharedPreferencesMigrationFactory = SharedPreferencesMigrationFactory()

koinApplication = startKoin {
androidLogger()
androidContext(this@ExampleApplication)
makePreferenceAreas(
basePath = this@ExampleApplication.filesDir.absolutePath,
areas = mapOf(
named("AppSettings") to listOf(sharedPreferencesMigrationFactory.createMigration(this@ExampleApplication, "app_prefs")),
named("UserSettings") to listOf(sharedPreferencesMigrationFactory.createMigration(this@ExampleApplication, "user_prefs"))
)
)
modules(
appModule,
)
}
}
}

Final Thoughts

Migrating from SharedPreferences to DataStore represents a step forward in modernizing your application’s settings management. DataStore’s asynchronous, type-safe*(if using proto) and scalable approach offer numerous advantages over the legacy SharedPreferences system, making it a valuable tool for contemporary Android and Kotlin Multiplatform projects.

By leveraging Koin for dependency injection, we can streamline the setup and management of DataStore instances, ensuring a clean and modular architecture. The process of migrating settings from SharedPreferences to DataStore is made easier with DataStore’s built-in migration support, allowing for a seamless transition without data loss or disruptions.

Here are some key takeaways:

  1. DataStore operates asynchronously, reducing the risk of blocking the main thread and improving overall application performance. Its type-safe API helps prevent common errors associated with SharedPreferences.
  2. DataStore can handle complex data structures and large data sets more efficiently than SharedPreferences. Its compatibility with Kotlin Multiplatform ensures you can manage settings consistently across different platforms.
  3. With DataStore’s migration support, transitioning from SharedPreferences can be done smoothly*, preserving existing data and minimizing user impact.
  4. Using Koin for dependency injection simplifies creating and managing DataStore instances, promoting a modular and maintainable codebase.

--

--