[MUSIC PLAYING]
SIMONA STOJANOVIC: Hi there.
Welcome back to the MAD Skills series on Jetpack DataStore.
In this episode, we will learn about Proto DataStore,
one of two DataStore implementations.
We will discuss how to create it, read and write data,
and how to handle exceptions to better understand
the scenarios that make Proto DataStore store
a great choice for you.
Proto DataStore uses typed objects
backed by protocol buffers to store smaller data sets
while providing type safety.
It removes the need for using key value pairs,
making it structurally different from its SharedPreferences
predecessor and its sibling implementation, Preferences
DataStore.
However, that's not all.
DataStore brings many other improvements
over SharedPreferences.
Feel free to jump back quickly to our first episode
in the series and take a look at a detailed comparison we've
made there.
Going forward, we will refer to Proto DataStore as just Proto
unless specified otherwise.
To quickly sum up, Proto relies on flows error
signaling mechanism, allowing you to efficiently
catch and handle exceptions when reading or writing data.
It provides a fully asynchronous API
for storing data using the power of Kotlin coroutines and flow.
It also comes with a mechanism for quick data migrations.
To start using Proto DataStore, you
need to get familiar with protocol buffers, a language-
and platform-neutral mechanism for serializing
structured data.
It is faster and smaller than XML
and easier to read than other similar data formats.
Now let's move on to some code to learn how Proto works.
We will use the Proto DataStore store Codelab sample.
If you prefer a more hands-on approach
with full implementation, make sure to check out
the Working With Proto DataStore Store Codelab.
This 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.
We will firstly look at Proto dependencies and some
of the basic Protobuf settings in our module's build.gradle.
If you're interested in a more advanced customization
of the Protobuf's compilation, check out the Protobuf Plugin
for Gradle notes.
Firstly, this requires adding the Protobuf plugin ID as well
as the dependencies for both DataStore and Protobufs.
The plugin adds a Protobuf block in your module's build.gradle,
which allows us to configure the Protobuf compilation.
Here we specify which repositories to download from,
how the compiler should generate the code
and configure the task's output type.
We're using Java Lite as it has smaller code size
and is the recommended option for Android.
Our journey with Proto DataStore starts
by defining the structure of your persisted data
in a Proto file.
Think of it as a readable schema for you
and a blueprint for the compiler.
We name ours users_prefs.proto and add it to the app's Proto
directory.
Following the Protobuf language guide,
in this file we will add a message for each data structure
we want to serialize, then specify a name and a type
for each field in the message.
Next is our message definition.
A message is an aggregate containing
a set of typed fields.
Many standard simple data types are
available as field types, including Booleans, ints,
strings, and others.
You can also add further structure to your messages
by using other message types as field types,
like we did with SortOrder here.
These markers on each element identify the unique tag
that the field uses in the binary encoding,
like an ID of sort.
Once your message type is in use,
these numbers should not be changed.
When you run the protocol buffer compiler on a Proto file,
the compiler generates the code in your chosen language.
In our specific case, when the compiler is run,
this leads to the generation of the user preferences
class found in your app's build Proto directory.
You can also try out the newly announced Kotlin DSL support
for protocol buffers to use a more idiomatic way
of building your data model.
Now that we have user preferences,
we need to specify the guidelines for how Proto
should read and write them.
We do this via the DataStore serializer
that determines the final format of your data once stored
and how to properly access it.
This requires overriding defaultValue,
what to return if no data is found, writeTo,
how to transform the memory representation of our data
object into a format fit for storage, readFrom, inverse
from the above, how to transform from a storage format
into a corresponding memory representation.
To keep your code as safe as possible,
handle the CorruptionException to avoid unpleasant surprises
when a file cannot be deserialized due to format
corruption.
You interact with Proto through an instance of the DataStore
interface, which grants access to the persisted information--
in our case, in the form of the generated user preferences.
To create this instance, it is recommended
to use the delegate DataStore and pass a mandatory file name
and serialize your arguments.
We will talk about the produceMigrations parameter
a bit later when we discuss migrations
from SharedPreferences.
FileName is used to create a file for storing the data.
This is why the DataStore delegate is a Kotlin extension
property whose receiver type must be an instance of context,
as this is needed for the actual file creation.
Avoid using this file in any way outside of Proto,
as it would break the consistency of your data.
In the DataStore delegate, you can
pass one more optional argument-- corruptionHandler.
This handler is invoked if a corruption exception is thrown
by the sterilizer when the data cannot be deserialized.
CorruptionHandler would then instruct Proto
how to replace the corrupted 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 episodes, we will see how
to do this via dependency injection.
To read the stored data, in user preferences repository,
we expose a flow of user preferences
from DataStore instance.
This provides efficient access to the latest saved state
and emits with every change.
This is one of the biggest strengths of Proto.
Your flows values already come in the shape
of the generated user preferences.
This means you don't have to do any additional transformations
from the saved data into a Kotlin data class model,
like you would with SharedPreferences
or Preferences DataStore.
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 this patch's IO so your UI thread isn't blocked.
Do not create any cache repositories
to marry the current state of your Proto 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 first flow operator.
For writing data, we will use a suspend updateData function
that takes in another suspend transform function.
We can use this to change our showCompleted Boolean.
Let's break that down.
We're currently using the user preferences store
as the concrete implementation of our DataStore.
The transform suspend block is used
to apply the specified changes to our persisted data of type
T. Critical buffers simplify the write process as well, removing
the need for any manual transformation
from into data classes.
There's a few steps to analyze here.
toBuilder gets the builder version
over current preferences, which unlocks it for changes.
setShowCompleted sets the new value.
Finally, the build finishes the update process by converting it
back to user preferences.
Updating data is done transactionally
and in an atomic read, modify, write operation.
Only after the transform and update data coroutines complete
successfully, the data will be persisted durably to disk
and userPreferencesStore flow will be reflecting the update.
Keep in mind that this is the only way of making changes
to the DataStore state.
Keeping a user preferences reference
and mutating it manually after the transform completes
will not change the persisted data in Proto,
so you shouldn't attempt to modify user preferences outside
of the transform block.
So that covers Proto DataStore reading and writing.
Now, if you've previously used SharedPreferences in your app
and would like to safely transfer its data to Proto,
you can use SharedPreferences migration.
Requires a context, SharedPreferences name,
and an instruction on how to transform
your SharedPreferences key value pairs to user preferences
within the migrate parameter.
Pass this via the produce migrations
parameter of the DataStore to migrate easily.
In this example, we go through the process
of building the user preferences and setting at sortOrder
to what was previously stored in the corresponding
SharedPreferences key value pair or simply defaulting to none.
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 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 as it has a default listOf 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.
You can safely handle this by using the catch flow operator
and omitting some form of backup data--
for example, getDefaultInstance over user preferences.
Or we can just add a try catch block for writing data.
If a different type of an exception is thrown,
prefer rethrowing it.
We've covered protocol buffers and DataStore's Proto
implementation, when and how to use it for reading and writing
data, how to handle errors, and how to migrate
from SharedPreferences.
The next two episodes, we will go a step further
and look at how DataStore fits in your app's architecture,
how to inject it with Hilt, and, of course, how to test it.
So hit Subscribe and see you soon.
[MUSIC PLAYING]