[MUSIC PLAYING]
SIMONA STOJANOVIC: Hi there.
Welcome back to the MAD Skills series on Jetpack DataStore.
In the following two episodes of this series,
we will cover additional concepts
to understand how DataStore works under the hood
so that you have everything at your disposal to use it
in a production environment.
In this episode specifically, we will
be focusing on Kotlin data class serialization,
synchronous work, and dependency injection via Hilt. Let's jump
into Android Studio to take a look at these concepts in code.
In our previous episodes, we referred
to our Preferences and Proto Code labs
for showcasing DataStore code.
We'll continue using the same codebases and work off of them.
In these codelabs, we interacted with our DataStore instances
through the delegate construction
once at the top level of a Kotlin file.
We would then use the same instance
throughout the application as a Singleton.
We require a Singleton because creating more than one instance
of DataStore for one given file can break all DataStore
functionality.
However, in a production environment,
we would usually obtain the DataStore instance
via dependency injection.
So let's take a look at how DataStore would work with Hilt,
a dependency injection library that
will help us reduce the boilerplate of doing
manual dependency management.
If you're not familiar with Hilt,
we encourage you first to go through the Hilt codelab
to learn the basic concepts.
To begin using Hilt, make sure you've
added all the necessary setup steps to enable it.
All apps that use Hilt must contain an application
class that is annotated with the Hilt Android app annotation
to trigger Hilt's code generation.
In our case, we will create a simple tasks app
and add it to our Android Manifest.
To obtain a Preferences DataStore instance
through injection, we need to tell Hilt
how to properly create it.
We use the PreferencesDataStoreFactory
and add it to a Hilt module.
Let's break this down a bit.
Corruption Handler is invoked if a corruption exception is
thrown by the serializer when the data cannot be
de-serialized, which instructs DataStore how to replace
the corrupted data.
Migrations is a list of data migrations for moving
previous data into DataStore.
This defines the scope in which I/O operations and data editing
functions will execute.
In this case, we're re-using the same scope as the DataStore API
default. produceFile generates the file objects for DataStore
based on the provided context and name.
Now that we have a module to instruct
Hilt on how to create our DataStore,
we need to make a few more adjustments
to be able to build successfully.
Hilt can provide dependencies to other Android
classes that have the AndroidEntryPoint annotation.
If you annotate an Android class with this annotation,
then you also must annotate other Android classes
that depend on it.
That means we need to add the following--
AndroidEntryPoint annotation to our activity
and a HiltViewModel one to our ViewModel.
In this sample, we don't have any other more complex
injections, such as custom builders, factories,
or interface implementations.
So we can rely on Hilt and construction
injection for any other dependencies
that we need to pass.
We will use the Inject annotation
in the constructor of a class to instruct Hilt how to provide
[? it to ?] instances.
Proto DataStore store would follow a very similar pattern.
You'd additionally need to provide
a UserPreferencesSerializer and a precise instruction
on how to migrate from SharedPreferences, which we've
covered in more detail in our previous Proto DataStore
episode.
And that's it.
Now you'll be able to run the app
and verify that all the dependencies are
being injected properly.
In our previous episodes, we've covered
how Preferences in Proto DataStore
approach structuring and serializing
your persisted data.
Proto uses type objects backed by protocol buffers,
while Preferences uses key-value pairs
as our data representation, similarly to SharedPreferences.
Under the hood, both implementations
save data on disk in a file using protocol buffers.
But DataStore also allows you to customize this and use
data classes and Kotlin serialization.
PreferencesSerializer just has one additional step
of transforming the key value pairs into Protobufs and vice
versa.
This means Preferences DataStore simplifies
working with Protobufs by adding an additional layer on top
of its low-level Proto implementation.
This way you get a lot of the benefits
of working with DataStore, but using
SharedPreferences-like way of structuring data
using key value pairs.
However, if you'd like to use Kotlin serialization
to structure your data, all you need to do
is define a fully immutable data class
and implement a DataStore Serializer.
DataStore relies on equals and hashCode,
which are automatically generated for data classes.
They also generate toString and copy functions,
which are useful for debugging and updating data.
It is very important to ensure that your class is immutable,
since DataStore is not compatible with mutable types.
Using mutable types with DataStore
will result in bugs due to data inconsistency and race
conditions.
Data classes aren't necessarily immutable by default,
so you need to make sure to use vals everywhere
instead of vars.
Arrays are mutable, so you shouldn't expose them.
Even if we use the read-only list as a member of our data
class, it's still mutable.
Instead, you should consider using immutable or persistent
collections.
Keep in mind that using mutable types as a member of your data
class makes it mutable.
Instead, you should ensure that all members
are immutable types.
Kotlin serialization supports multiple formats, including
JSONs and protocol buffers.
In this example, we'll continue with JSON.
In order to read and write your data class
to JSON using Kotlin serialization,
you need to annotate your data class with a Serializable
annotation and override the serializers writeTo
and readFrom.
Here's an example with UserPreferences.
Note that Parcelables are not safe to use with DataStore,
because the data format may change
between Android versions.
Pass the newly created serializer into DataStore
when constructing it.
Reading data looks the same as with Protobufs.
You can just use the generated copy function to update data.
Using DataStore with Kotlin serialization and data classes
can reduce boilerplate and help simplify your code.
However, you must be careful not to introduce bugs
through mutability.
Throughout this series, we've mentioned
DataStore's fully asynchronous API
coming from its internal usage of Kotlin coroutines and flow.
To prevent potential ANRs and UI jank
happening when heavier I/O operations are done on the UI
thread, DataStore does not offer ready-to-use synchronous
support.
DataStore saves its data set in a file and, under the hood,
performs all data operations on Dispatchers.IO
unless specified otherwise, keeping your UI thread
unblocked.
This API structure is one of the main advantages of DataStore
over its predecessor, SharedPreferences,
in how you would use DataStore in the majority of cases.
However, if you do find that your code requires
you to work synchronously with DataStore,
whether it's because of a dependency to another API
running on the main thread or because your current setup
requires you to retrieve some persisted values for your UI
setup, you could use runBlocking coroutine builder to read
DataStore synchronously.
This will block the calling thread until DataStore returns.
If you find yourself in a situation
where you need to use this approach,
do spend some time figuring out if it's absolutely necessary
to block the main thread.
Think about how you could use the provided DataStore
async alternatives or refactor your current code
to avoid runBlocking, for example, by preloading
the data asynchronously.
If this isn't possible, make sure you cover all potential UI
jank scenarios with error handling, cancellation,
and timeouts or some nice visuals
to make the user experience as smooth as possible.
Today we've covered three completely new DataStore
concepts--
Kotlin data class serialization, how to perform synchronous work
with DataStore, and how to inject
it using Hilt. Join us for the next episode, where we continue
with other concepts, such as how to do
DataStore-to-DataStore migrations and testing.
See you soon.
[MUSIC PLAYING]