Hi there. Welcome back to the MAD Skills series
on Jetpack DataStore.
In this episode, we will take a look at Preferences DataStore,
one of two DataStore implementations.
We will go over how to create it, read and write data,
and how to handle exceptions,
all of which should hopefully provide you with enough information
to decide if it's the right choice for your app.
Preferences DataStore uses key-value pairs
to store smaller data sets without defining the schema upfront.
This might remind you of SharedPreferences,
but only in the way it structures your data models.
There are a variety of benefits brought by DataStore
over its SharedPreferences predecessor.
Feel free to quickly jump back to our previous episode
and take a look at a detailed comparison we've made there.
Going forward, we will refer to Preferences DataStore
as just Preferences, unless specified otherwise.
To quickly recap,
Preferences provides a fully asynchronous API
for retrieving and saving data
using the power of Kotlin Coroutines and Flow.
It allows easy and quick data migrations, but it does not provide full type safety.
Now let's dive into some code and learn how Preferences
should be implemented.
We will use the Preferences DataStore codelab code.
If you're interested in a more hands-on approach with implementation,
we really encourage you to go through Working with Preferences DataStore codelab
on your own.
The sample app displays a list of tasks,
and the user can choose to filter them by their completed status
or sort by priority and deadline.
We want to store their selection,
a Boolean for displaying completed tasks,
and a sort order enum in DataStore.
Let's start with adding the necessary dependency.
In our module's build.gradle file,
we need to add the DataStore Preferences dependency
using the latest stable release.
You interact with data saved in DataStore
through an instance of DataStore Preferences.
DataStore is an interface that grants access to the persisted information.
Preferences is an abstract class, similar to a generic map,
used specifically in the Preferences implementation of DataStore
to keep track of your key-value pairs.
To create this instance,
it is recommended to use the delegate preferencesDataStore
and pass a mandatory name parameter.
This delegate is a Kotlin extension property
whose receiver type must be an instance of Context,
subsequently needed for constructing the file object
where DataStore stores the data.
You shouldn't create more than one instance of DataStore
for a given file, as doing so can break all DataStore functionality.
In this codelab, we construct the instance
at the top level of the activity for simplicity
and pass it as a singleton.
In later posts, we will see how to do this via dependency injection.
DataStore provides a quick way of constructing keys
for different data types, such as Boolean preference key,
in preference key, and many more.
You just need to pass the key name as value.
These keys will be used to save and retrieve data
from DataStore.
While this does put some constraints on data types,
keep in mind that it doesn't provide definite type safety.
By specifying a preference key of a certain type,
we hope for the best and rely on our assumptions
that a value of a certain type would be returned.
If you feel your code is structured in a way to handle this safely,
feel free to continue with Preferences.
If not, consider using Preferences' sibling Proto DataStore,
as it provides full type safety.
To read the stored data in user preferences repository,
we expose a Preferences Flow from DataStore data.
This provides efficient access to the latest saved state
and emits with every change.
Using Kotlin data classes, we can observe any emissions
and transform the provided DataStore Preferences object
into our own user preferences model
using only the key-value pairs we're interested in.
The Flow will always either emit a value
or throw an exception when attempting to read from disk.
We will look at exception handling in later sections.
DataStore also ensures that the work is always performed
on dispatcher's I/O so your UI thread isn't blocked.
Do not create any cache repositories
to mirror the current state of your Preferences data.
Doing so would invalidate DataStore's guarantee
of data consistency.
If you require a single snapshot of your data
without subscribing to further Flow emissions,
prefer using the firstFlow operator on DataStore data.
For writing data, we will use DataStore suspentEdit function
that takes in another suspend transform function.
As an example, we will change our showCompleted flag here.
Let's break that down.
The edit function is performed on the implementation
of the DataStore Preferences interface.
The transformed function is a suspend block
used to apply the specified changes to our persisted data.
Mutable Preferences is a mutable subclass of Preferences,
similar to mutable map,
that allows us to make changes to our key-value pairs.
Editing data is done transactionally in an atomic read-modify-write operation,
which guarantees consistency and prevents raised conditions.
Only after the transform and edit coroutines complete successfully,
the data will be persisted durably to disk,
and DataStore's data flow will be reflecting the update.
Keep in mind that this is the only way of making changes to the DataStore state.
Keeping immutable Preferences reference
and mutating it manually after transform completes
will not change the persisted data in DataStore.
So you shouldn't attempt to modify Mutable Preferences
outside of the transform block.
If the writing operation fails for any reason,
the transaction is aborted and an exception is thrown.
So we've covered DataStore reading and writing.
If you've previously used SharedPreferences in your app
and would like to safely transfer its data to Preferences,
you can use SharedPreferences migration.
It requires a context, SharedPreferences name,
and an optional set of keys you wish to migrate,
or just leave it as the default MIGRATE_ALL_KEYS value.
Looking into the SharedPreferences migration,
you will see a getMigration function,
which is responsible for fetching all required stored key-value pairs
and then adding them to Preferences using the same keys.
You can then pass this via the produceMigrations parameter
of the DataStore delegate to migrate easily.
produceMigrations will ensure that the migrations
are run before any potential data access to DataStore.
This means your migration must have succeeded
before DataStore emits any further values
and before it begins making any new changes to the data.
Once you've successfully migrated, it's safe to stop using SharedPreferences,
as the keys are migrated only once, and then removed from SharedPreferences.
The produceMigration accepts a list of data migrations.
We will see in later episodes
how we can use this for other types of data migrations.
If you don't need to migrate, you can ignore this parameter,
as it has a default argument provided already.
One of the main advantages of DataStore over SharedPreferences
is its neat mechanism, for caching and handling exceptions.
While SharedPreferences throws parsing errors
as runtime exceptions,
leaving room for unexpected, uncaught crashes,
DataStore throws an IOException when an error occurs
with reading or writing data.
We can safely handle this by using the catch flow operator
right before the map one
and emitting some form of backup data, such as emptyPreferences.
Or we can just use a try-catch block for writing data.
If a different type of an exception is thrown,
prefer re-throwing it.
We've covered DataStore's Preferences implementation,
when and how to use it for reading and writing data,
how to handle errors, and how to migrate from SharedPreferences.
In the next episode, we will cover the same topics with Proto DataStore,
and a bit more.
So hit Subscribe and stay tuned.