In this article, well, take a look at how to DataStore and how to use it in a Kotlin Multiplatform project.
Recently Google announced a couple of Jetpack Multiplatform libraries. One is DataStore, a storage solution that allows you to store key-value pairs. This has been rewritten & is now available on Kotlin Multiplatform, targeting Android & iOS.
Something to take note of is that these libraries are experimental and should not be used on production apps!
What you will learn:
- Setting up Datastore.
- Dependency Injection.
- Creating a repository
- I am writing a test.
TvManiac Project
A lot is changing at the moment on this project as I am cleaning up and creating a better structure for this project. The changes referenced here can be found in this commit.
Flow redux settings by c0de-wizard · Pull Request #48 · c0de-wizard/tv-maniac
Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because there are no changes…github.com.
Let’s get started!
For our project, we need to add the preference core dependency.
dependencies {
implementation("androidx.datastore:datastore-preferences-core:1.1.0-dev01")
}
Now we need to create a function in
commonMain
to help us create an instance of our DataStore object. We can use
expect/actual
pattern and have each platform have its implementation, but I will create a function and set it up when setting up injection. It’s a straightforward function that takes in two parameters: Coroutine scope and the filePath.
fun createDataStore(
coroutineScope: CoroutineScope,
producePath: () -> String
): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
scope = coroutineScope,
produceFile = { producePath().toPath() },
)
internal const val dataStoreFileName = "tvmainac.preferences_pb"
And that’s it. ? We can now move over to the injection of the DataStore. As I mentioned in my previous article, we have a “hybrid injection setup” where we use Hilt on the Android side and Koin on iOS; however, I plan on migrating to Koin in the future. ?
Dependency Injection:
Android:
This should be self-explanatory:
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
@DefaultCoroutineScope defaultScope: CoroutineScope
): DataStore<Preferences> = createDataStore(
coroutineScope = defaultScope,
producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
)
IOS Koin Module:
For iOS, it’s almost similar. We need to create a function that creates the dataStoretore object and then add that to koin module
fun dataStore(scope: CoroutineScope): DataStore<Preferences> = createDataStore(
coroutineScope = scope,
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(documentDirectory).path + "/$dataStoreFileName"
}
)
actual fun settingsModule(): Module = module {
single { dataStore(get()) }
}
Boom. We can now create a repository and use it to get the theme and set the theme. The implementation looks like so.
class SettingsRepositoryImpl(
private val dataStore: DataStore<Preferences>,
private val coroutineScope: CoroutineScope
) : SettingsRepository {
override fun saveTheme(theme: String) {
coroutineScope.launch {
dataStore.edit { settings ->
settings[KEY_THEME] = theme
}
}
}
override fun observeTheme(): Flow<Theme> = dataStore.data.map { theme ->
when (theme[KEY_THEME]) {
"light" -> Theme.LIGHT
"dark" -> Theme.DARK
else -> Theme.SYSTEM
}
}
companion object {
val KEY_THEME = stringPreferencesKey("app_theme")
}
Testing
The test is pretty simple.
Creating the DataStore & repository objects.
private var preferencesScope: CoroutineScope = CoroutineScope(testCoroutineDispatcher + Job())
private val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
scope = preferencesScope,
produceFile = { "test.preferences_pb".toPath() },
)
private val repository = SettingsRepositoryImpl(dataStore, testCoroutineScope)
Clear/remove the key after running a test.
The other thing we need to do is clear the saved item after running a test. We could do this two ways, delete the file in the application dir or remove the preferences using the key. We will use the latter. We do that by creating a function and using
@AfterTest
annotation. We also need to cancel the context associated with the data store.
@AfterTest
fun clearDataStore() = runBlockingTest {
dataStore.edit {
it.remove(KEY_THEME)
}
preferencesScope.cancel()
}
Since DataStore uses coroutines, we will use Turbine for tests. This isn’t complicated. Here, we test that the theme gets updated.
@Test
fun when_theme_is_changed_correct_value_is_set() = runBlockingTest {
repository.observeTheme().test {
repository.saveTheme("dark")
awaitItem() shouldBe Theme.SYSTEM //Default theme
awaitItem() shouldBe Theme.DARK
}
}
One thing that I will improve in the future is deleting the generated test file. Right now, we ignore all
.preferences_pb
files created during testing.
This is how our test class looks like after stitching everything together.
private var preferencesScope: CoroutineScope = CoroutineScope(testCoroutineDispatcher + Job())
private val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
scope = preferencesScope,
produceFile = { "test.preferences_pb".toPath() },
)
private val repository = SettingsRepositoryImpl(dataStore, testCoroutineScope)
@AfterTest
fun clearDataStore() = runBlockingTest {
dataStore.edit {
it.remove(KEY_THEME)
}
preferencesScope.cancel()
}
@Test
fun default_theme_is_emitted() = runBlockingTest {
repository.observeTheme().test {
awaitItem() shouldBe Theme.SYSTEM
}
}
@Test
fun when_theme_is_changed_correct_value_is_set() = runBlockingTest {
repository.observeTheme().test {
repository.saveTheme(Theme.DARK)
awaitItem() shouldBe Theme.SYSTEM //Default theme
awaitItem() shouldBe Theme.DARK
}
}
}
Final Thoughts
We’ve covered steps for using DataStore in a Kotlin Multiplatform project. I must say, it was super simple to get this up and running. You can use this in your ViewModel. For my project, I’m using a FlowRedux StateMachine, which I will discuss in the next post.
Any comments, good or bad, are always welcome.
Resources
- Check out the DiceRoller sample app.
- Jetpack Multiplatform Libraries.
Thanks to Marton Braun for reviewing this article.