Kotlin Flows in practice
Summary
TLDRThis script introduces the concept of reactive programming in Android development using Kotlin Flow. It explains how to model data streams, optimize for lifecycle events, and handle configuration changes. The talk uses the analogy of water infrastructure to illustrate the benefits of setting up data 'pipes' instead of manual data requests. It also covers creating, transforming, and collecting flows, as well as best practices for lifecycle-aware collection and testing strategies.
Takeaways
- 🚀 **Reactive Programming in Android**: The talk introduces reactive programming concepts to Android development, focusing on stream data modeling using Kotlin Flow.
- 🔄 **Optimizing Flows for UI Lifecycle**: Discusses how to optimize data flows for Android's unique UI lifecycle, including handling rotations and backgrounding the app.
- 💧 **Flow as a Stream of Data**: Uses the analogy of water flow to explain the concept of data streams in Android, emphasizing the efficiency of 'observing' data rather than 'requesting' it.
- 🔧 **Building Infrastructure for Data**: Advocates for creating infrastructure (like pipes for water) in apps to manage data flow more effectively, reducing redundancy and potential errors.
- 🔀 **Unidirectional Data Flow**: Highlights the importance of keeping data flow in one direction to simplify management and reduce bugs in the application.
- 🛠️ **Kotlin Flow for Data Management**: Recommends using Kotlin Flow, part of the Coroutines library, for sophisticated data combination and transformation within an app.
- 📡 **Creating and Transforming Flows**: Covers the creation of flows using flow builders and the transformation of data through intermediate operators like map and filter.
- 🛑 **Error Handling in Flows**: Introduces the catch operator for handling exceptions within a flow, ensuring robust data stream management.
- 🔗 **Collecting Flows in UI Layer**: Explains the process of collecting data from flows in the UI layer using terminal operators and the importance of doing so efficiently.
- 🔄 **Lifecycle Awareness in Flow Collection**: Discusses the use of lifecycle-aware APIs like repeatOnLifecycle and flowWithLifecycle to manage flow collection based on the UI's visibility.
- 🔍 **Testing Flows**: Provides insights into testing flows, emphasizing the need for strategies to handle the asynchronous nature of stream data.
Q & A
What is the main concept discussed in the script related to Android redevelopment?
-The main concept discussed is reactive programming, specifically using Kotlin Flow for modeling streams of data in Android redevelopment.
How does the script illustrate the idea of reactive programming in the context of fetching data?
-The script uses the analogy of Pancho fetching water from a lake, explaining that instead of repeatedly requesting data (walking to the lake), it's more efficient to set up an infrastructure (installing pipes) to observe data changes automatically.
What is the significance of keeping data flow in one direction in the script's explanation?
-Keeping data flow in one direction is highlighted as a best practice because it reduces the likelihood of errors and simplifies the management of data streams within an application.
What is the role of a producer in the context of Kotlin Flow as described in the script?
-A producer in Kotlin Flow is responsible for emitting data into the flow, which can then be collected by a consumer, typically a UI layer in an Android application.
How can intermediate operators be used to transform or filter data streams in Kotlin Flow?
-Intermediate operators like 'map' can transform data to a different type, while 'filter' can be used to select only certain items that meet specific criteria, such as containing important notifications.
What is the purpose of the 'catch' operator in handling errors within a flow in Kotlin?
-The 'catch' operator is used to handle exceptions that may occur while processing items in the upstream flow, allowing the application to either rethrow exceptions or emit alternative values.
Why is it recommended to use 'repeatOnLifecycle' or 'flowWithLifecycle' when collecting flows from the Android UI layer?
-These APIs are lifecycle-aware and help optimize the collection of flows by automatically managing the lifecycle of coroutines based on the UI's visibility, preventing unnecessary resource usage when the app is in the background.
What is the difference between 'repeatOnLifecycle' and 'flowWithLifecycle' in collecting flows?
-'repeatOnLifecycle' is a suspend function that automatically launches and cancels coroutines based on lifecycle states, suitable for collecting from multiple flows. 'flowWithLifecycle', on the other hand, is used when there is only one flow to collect, as it emits items and manages the lifecycle of the producer directly.
How does the script suggest handling configuration changes like device rotation in the context of data flows?
-The script recommends using StateFlow, which acts as a buffer holding data and can share it between multiple collectors, ensuring a smooth transition during configuration changes without restarting upstream flows.
What is the role of 'StateFlow' in the context of view models and activities with different lifecycles?
-StateFlow is used to safely expose flows from a view model to activities or fragments, ensuring that the data remains consistent and available even when the UI elements are recreated due to lifecycle changes.
How can testing of flows be approached as discussed in the script?
-Testing flows can involve replacing dependencies with fake producers to emit specific test data, or by using terminal operators like 'first' or 'take' to collect and verify the output of the flow under test.
Outlines
🌐 Introduction to Reactive Programming with Kotlin Flows
The video script introduces the concept of reactive programming in the context of Android development, focusing on Kotlin Flows for data stream management. It discusses the importance of optimizing data flows in response to UI lifecycle events such as screen rotations and app backgrounding. The talk aims to demonstrate loading, transforming, and displaying data using flows, and emphasizes the benefits of setting up infrastructure for data management, illustrated with the analogy of Pancho fetching water from a lake and evolving to a more efficient system with pipes. The reactive system is highlighted as one where observers automatically respond to data source changes, promoting a unidirectional data flow to reduce errors and simplify management.
🛠 Creating and Transforming Flows in Android
This section delves into the mechanics of creating and customizing flows in Android. It explains the use of the flow builder for periodic tasks, such as fetching user messages, and how to emit data into a flow using suspend functions within a coroutine context. The summary covers intermediate operators like map for data transformation, filter for stream refinement, and catch for error handling within flows. The importance of collecting flows, particularly in the UI layer using terminal operators, is underscored. The discussion also touches on the efficiency of not collecting from flows when the UI is not active, hinting at lifecycle-aware collection strategies.
🔄 Optimal Collection of Flows in Android UI
The paragraph discusses best practices for collecting flows within the Android UI, taking into account the UI lifecycle and resource optimization. It introduces lifecycle-aware APIs like 'asLiveData', 'repeatOnLifecycle', and 'flowWithLifecycle' for efficient flow collection that ceases when the UI is not displayed. The summary explains how 'repeatOnLifecycle' automatically manages coroutines based on lifecycle states, and the risks of collecting flows inappropriately, such as during app backgrounding or configuration changes. The paragraph also contrasts newer APIs with older practices, advocating for safe and efficient flow management to prevent resource wastage and potential app crashes.
🔧 Handling Configuration Changes with StateFlow
This part of the script addresses the complexities of handling data flow across different lifecycles, especially during configuration changes like device rotations. It introduces StateFlow as a robust solution, capable of holding data and sharing it among multiple collectors, even if they are recreated after lifecycle changes. The summary explains how StateFlow acts as a buffer, ensuring a seamless用户体验 during transitions. It also discusses the use of 'stateIn' for converting flows to StateFlow and the significance of the 'WhileSubscribed' timeout in determining whether to stop upstream flows during backgrounding or short stops like rotations. The paragraph emphasizes the importance of testing flows and provides strategies for doing so effectively.
📚 Conclusion and Further Learning on Reactive Architecture
The final paragraph wraps up the discussion on reactive programming with Kotlin Flows, highlighting the benefits of investing in such an architecture for Android development. It encourages further exploration of the topic through guides, blog posts, and real-world examples like the Google I/O app. The summary stresses the importance of testing flows and provides methods for unit testing both when a flow is received and when it is exposed. The paragraph concludes with an invitation for viewers to learn more about StateFlow and SharedFlow, and to apply the concepts discussed in their own Android development projects.
Mindmap
Keywords
💡Reactive Programming
💡Kotlin Flow
💡UI Lifecycle
💡Data Transformation
💡Error Handling
💡Flow Collection
💡Lifecycle-aware Collection
💡StateFlow
💡Configuration Changes
💡Testing Flows
Highlights
Introduction to reactive programming concepts applied to Android redevelopment.
Exploration of Kotlin Flow for modeling streams of data in Android.
Optimizing flows to handle Android's UI lifecycle events like rotations and backgrounding.
The importance of testing in ensuring the functionality of Android apps.
Use cases for data handling in Android apps, including database operations and server communication.
Loading data into a flow, transforming it, and exposing it to views for display.
The story of Pancho and the analogy of installing pipes for efficient data retrieval.
Comparing the traditional data request method with the reactive approach of observing data.
Benefits of a unidirectional data flow to reduce errors and simplify management.
The use of Kotlin Flow within the Coroutines library for data stream manipulation.
Terminology explanation: producers, consumers, and the flow of data in Android.
How to create flows using flow builders and the role of suspend functions.
Transformation of data streams using intermediate operators like map and filter.
Error handling in data streams with the catch operator.
Collecting flows in the UI layer and the use of terminal operators.
Optimizing flow collection during app backgrounding and UI lifecycle awareness.
Using StateFlow to handle data sharing between activities and fragments during lifecycle changes.
Conversion of flows to StateFlow for buffering and sharing data across multiple collectors.
Testing strategies for flows, including fake producers and collecting specific numbers of items.
Conclusion on the benefits of a reactive architecture using Kotlin Flow and further learning resources.
Transcripts
Hi, everyone.
Today, Jose and I want to bring their reactive programming concept
close to Android redevelopment.
Then, we'll see how to work with flow Kotlin type for modeling streams of data.
Android has a particular UI lifecycle.
We'll see how to optimize flows for things like rotations
and sending the app to the background.
Lastly, as with all good stories,
we need to test that everything works as expected.
Every Android app needs to send data around one way or another.
And there is a million different use cases:
loading a username from a database,
fetching a document from a server, authenticating a user.
In this talk, we'll look at how you can load data into a flow,
transform it, and expose it to a view so that it's displayed.
To help me explain why we use flow, here's Pancho.
Pancho lives on a mountain.
And when Pancho wants fresh water from a lake,
they do what any beginner would do.
They grab a bucket and walk up to the lake and down again.
But sometimes, Pancho finds that the lake is dry.
So they wasted time walking up to the lake,
as they have to find water elsewhere.
After doing this multiple times, they
realize it would be much better to create some kind of infrastructure.
So the next time they walk up to the lake, they install some pipes.
Now, if they need more water and the lake is not dry,
they just open the tap.
Once you know how to install pipes,
it's easy to get fancy and combine multiple sources of data--
sorry, water--
so that Pancho doesn't have to check if the lake is dry anymore.
In an Android app, you can take the easy path
and request data every time you need it.
For example, when the view starts,
you request data to a view model,
which in turn requests data to the data layer.
And then, everything happens in the other direction.
You can do this easily with suspend functions.
However, after doing that for a while,
developers like Poncho tend to realize
investing in some infrastructure really pays out.
Instead of requesting data, we observe it.
Observing data is like installing tubes for water.
Once they're in place,
any update to the source of data will flow down to the view automatically.
You don't have to walk to the lake anymore.
We call a system that uses these patterns reactive
because observers react automatically to changes in the things being observed.
Another important design choice is
to keep data flowing in just one direction,
as this is less prone to errors and easier to manage.
In this example app, the auth manager tells the database
that a user logged in.
And this one, in turn, has to tell the remote data source
to load a different set of items,
all of this while telling the view
to show a loading spinner while they grab new data.
I mean, this is doable, but prone to bugs.
A better way to do this is to let data flow in just one direction
and create some infrastructure, some tubes
to combine and transform these streams of data.
If something changes that requires modifications,
such as when the user logs out, the tubes can be reinstalled.
As you can imagine, we need sophisticated tools
to do all these combinations and transformations.
And in this talk, we're going to use Kotlin Flow for that.
It's not the only streams builder out there,
but it's part of Coroutines and very well supported.
The stream of water analogy we've been using so far
can be modeled in a concrete type called flow,
a type that is part of the Coroutines library.
Instead of water, flows can be of any type thing,
for example, user data or UI state.
There is some terminology we'll be using during the talk
that is important to define.
A producer emits data into the flow
that a consumer collects from the flow.
In Android, a data source, or repository,
is typically a producer of application data
that has the UI as the consumer
that ultimately displays the data on screen.
Let's start with how flows are created.
For that, let's take a walk to the lake.
Most of the time, you don't need to create a flow yourself.
The libraries you depend on in your data sources
are already integrated with coroutines and flows.
This is the case of popular libraries such as DataStore,
Retrofit, Room, or WorkManager.
They act like a water dam.
They provide you data using flows.
You just plug into a pipe without knowing
how the data is being produced.
Taking Room as an example,
you can get notified of changes in the database
by exposing a flow of type x.
The Room library acts as a producer
and emits the content of the query
every time an update happens.
If you really need to create a flow yourself,
there are different alternatives you can choose.
One of the options is the flow builder.
Imagine that we are in a user messages data source
and you want to check for messages every so often from your app.
We can expose the user messages as a flow of type list of messages.
To create a flow, we use the flow builder.
The flow builder takes us to suspend block as a parameter,
which means it can call suspended functions.
And this is because the flow is executed in the context of a coroutine.
Inside it, we can have our WhileTrue loop to repeat our logic periodically.
First, we fetch the messages from the API.
And then, we add the result into the flow
using the emit suspend function.
This step suspends the coroutine until the collector receives the item.
Lastly, we suspend the coroutine for some time.
In our flow, operations are executed sequentially
in the same coroutine.
Due to the WhileTrue loop,
this flow keeps infinitely fetching the latest messages
until the observer goes away and stops collecting items.
Also, the suspend block passed to the flow builder
is often called producer block.
In Android, layers in between the producer and consumer
can modify the stream of data
to adjust it to the requirements of the following layer.
To transform flows, you can use intermediate operators.
If we consider the latest messages stream as the flow starting point,
we can use the map operator to transform the data to a different type.
For example, inside the map lambda,
we are transforming the Room messages coming from the data source
to a messages UI model
that is a better abstraction for this layer of the app.
Each operator creates a new flow
that emits data according to its functionality.
We can also filter the stream to get the flow for those messages
that contain important notifications.
Now, how can we handle errors that happen as part of the stream?
The catch operator catches exceptions
that could happen while processing items in the upstream flow.
The upstream flow refers to the flow produced by the producer block
and those operators called before the current one.
Similarly, we can refer to everything that happens after the current operator
as the downstream flow.
Catch can also rethrow the exception if needed
or emit new values.
For example, this code rethrows IllegalArgumentExceptions,
but emits an empty list if any other exception occurs.
At this point, we've seen how streams are produced
and how they can be modified.
It's time to learn about how to collect them.
Collecting flows usually happens from the UI layer,
as it is where we want to display the data on the screen.
In our example, we want to display the latest messages on a list
so that Poncho can keep up with what's going on.
We need to use a terminal operator to start listening for values.
To get all the values in the stream as they are limited, use collect.
Collect takes a function as a parameter that is called on every new value.
And as it is a suspend function,
it needs to be executed within a coroutine.
When you apply a terminal operator to a flow,
the flow is created on demand and starts emitting values.
On the contrary, intermediate operators
just set up a chain of operations
that are executed lazily when an item is emitted into the flow.
Every time collect is called on user messages,
a new flow, or pipe, will be created.
And its producer block will start refreshing the messages
from the API at its own interval.
In coroutines jargon, we refer to this type of flows as called flows
as they are created on demand
and emit data only when they are being observed.
Let's see now how to optimally collect flows
from the Android UI.
There are two main things to consider:
The first one is about not wasting resources
when the app is in the background,
and the second one is about configuration changes.
Let's imagine we are in messages activity
and we want to display the list of messages on the screen.
For how long should we be collecting from the flow?
The UI should be a good citizen
and stop collecting from the flow
when the UI is not displayed on the screen.
Back to the water analogy,
Pancho should close the tap while brushing their teeth
or going for a nap.
Poncho shouldn't be wasting water.
Similarly, the UI shouldn't be collecting from flows
if the information isn't going to be displayed on the screen.
To do this, there are different alternatives.
And all of them are aware of the UI life cycle.
You can use life data or lifecycle coroutine-specific APIs
such as repeatOnLifecycle and flowWithLifecycle.
The asLiveData flow operator compares the flow to live data
that observes items only while the UI is visible on the screen.
This conversion is something we can do in the view model class.
In the UI, we just consume the live data as usual.
But OK, this is cheating a bit
because it's adding a different technology into the mix,
which shouldn't be needed.
RepeatOnLifecycle is the recommended way to collect flows from the UI layer.
RepeatOnLifecycle is a suspend function
that takes a life cycle step as a parameter.
This API is lifecycle aware,
as it automatically launches a new coroutine with a block pass to it
when the lifecycle reaches that step.
Then, when the lifecycle falls below that state,
the ongoing coroutine is canceled.
Inside the block, we can call collect,
as we are in the context of a coroutine.
As repeatOnLifecycle is a suspend function,
it also needs to be called in a coroutine.
As you are in an activity, we can use lifecycleScope to start one.
As you can see, the best practice
is to call this function when the lifecycle is initialized,
for example, in onCreate in this activity.
RepeatOnLifecycle's restartable behavior
takes into account the UI lifecycle automatically for you.
Something important to note is that the coroutine that calls repeatOnLifecycle
won't resume executing until the lifecycle is destroyed.
So, if you need to collect from multiple flows,
you should create multiple coroutines using launch
inside the repeatOnLifecycle block.
You can also use the flowWithLifecycle operator
instead of repeatOnLifecycle
when you have only one flow to collect.
This API emits items and cancels the underlying producer
when the lifecycle moves in and out of the target state.
To show how this works visually,
let's take a tour through the activity lifecycle
when it's first created,
then sent to the background because the user pressed the Home button,
which makes the activity receive the onStop signal
and then, opening the app again when onStart is called.
When you call repeatOnLifecycle with the start state,
the UI processes flow emissions while it's visible on the screen.
And the collection is canceled when the app goes to the background.
RepeatOnLifecycle and flowWithLifecycle
are new APIs added in the stable 2.4 version
of the Lifecycle runtime ktx library.
Because they are new, you might be collecting flows
from the Android UI in a different way.
For example, you might be collecting directly from a coroutine
launched by lifecycleScope.
While this might seem OK to use,
collecting flows in this way is not always safe.
This collects from the flow and updates UI elements,
even if the app is in the background.
In fact, this is not the only case.
Other solutions like the lifecycle coroutine scope
launch when X API family suffer from similar problems.
You don't like Poncho wasting water, do you?
Then, we shouldn't be collecting from the flow
if those items aren't going to be displayed on the screen.
If you collect directly from lifecycleScope. launch,
the activity keeps receiving flow updates while in the background.
That can be both wasteful and dangerous,
as, for example, showing dialogues when the app is in the background
can make your application crash.
To solve this issue,
you could manually start collecting in onStart
and stop collecting in onStop.
While that's OK,
using repeatOnLifecycle removes all that boilerplate code.
If we look at launchWhenStarted as an alternative,
it is better than lifecycleScope. launch
because it suspends the flow collection while the app is in the background.
However, this solution keeps the flow producer active,
potentially emitting items in the background
that can fill the memory with items
that aren't going to be displayed on the screen.
As the UI doesn't really know how the flow producer is implemented,
it is always better to play safe and use repeatOnLifecycle
or flowWithLifecycle to avoid collecting items
and keeping the flow producer active when the UI is in the background.
If this optimizes flow collection when the app goes to the background,
Jose is going to tell you some tricks
for when the app goes through configuration changes.
When you expose a flow to a view,
you have to take into account that you are trying to pass data
between two elements that have different lifecycles.
And not any lifecycle.
The lifecycle of activities and fragments, which can be tricky.
As a crucial example, remember that,
when a device is rotated or receives a configuration change,
all activities might be restarted,
but a view model survives that.
So from a view model, you can't just expose any flow,
for example, a call flow like this one.
A call flow results every time it's collected for the first time.
So, the repository would be called again after a rotation.
What we need is some kind of buffer, something that can hold data
and share it between multiple collectors
no matter how many times they are recreated.
StateFlow was created for exactly that.
A StateFlow is a water tank in our lake analogy.
It holds data even if there are no collectors.
You can collect multiple times from it,
so it's safe to use with activities or fragments.
You could use the mutable version of StateFlow
and update its value whenever you want,
for example, from a coroutine like in here.
But that's not very reactive, is it?
Pancho would suggest you improve your game.
Instead, you can convert any flow to a StateFlow.
If you do that, the StateFlow receives all the updates from the upstream flows
and stores the latest value.
And it can have zero or more collectors, so this is perfect for view models.
There are more types of flows, but this is what we recommend
because we can optimize StateFlow very precisely.
To convert a flow to StateFlow,
you can use the stateIn operator on it.
It takes three parameters:
Initial value, because a StateFlow always needs to have a value,
a coroutine scope, which controls when the serving is started.
We can use the ViewModel scope for this.
And started, which is the interesting one.
We're going to get to what that WhileSubscribed(5000) means.
But first, let's look at two scenarios.
The first scenario is a rotation where the activity,
which is the collector of the flow,
is destroyed for a short period of time and then recreated.
The second scenario is a navigation to home
where our app is put in the background.
In the rotation scenario, we don't want to restart any flows
to make the transition as fast as possible.
In the navigation to home however,
we want to stop all flows to save battery and other resources.
So, how do we detect which one is which?
We do that with a timeout.
When a StateFlow stops being collected,
we don't immediately stop all the upstream flows.
Instead, we wait for some time, for example, five seconds.
If the flow is collected again before the timeout,
no upstream flows are canceled.
That is exactly what the WhileSubscribed(5000) does.
In this diagram, we show what happens when the app goes to the background.
Before the Home button is pressed,
the view is receiving updates
and the StateFlow has its upstream flows producing normally.
Now, when the view stops, the collection ends immediately.
However, the StateFlow, because of how we configured it,
takes five seconds to stop its upstream flows.
Now the timeout passes and upstream flows are canceled.
Only when the user opens the app again, if that ever happens,
the upstream flows are automatically restarted.
In the rotation scenario however,
the view is only stopped for a very short time,
less than five seconds anyway.
So the StateFlow never gets restored and keeps all upstream flows active,
acting as though nothing happened
and making the rotation instant to the user.
So in summary, we recommend that you use StateFlow
to expose your flows from a view model, or keep using as like data,
which does exactly the same thing.
If you want to learn even more about StateFlow or its parent, SharedFlow,
check out the resources at the end.
Now, you might be wondering, "How do I test this?"
Well, testing a flow can get tricky
because you're dealing with streams of data.
So there are a couple of tricks you can use.
First, there are two scenarios.
In the first one, the unit and the test,
whatever you're testing, is receiving a flow.
The easy way to test this is to replace the dependency
with a fake producer.
You would program this fake repository, in this example,
to emit whatever you need for the different test cases,
for example, with a simple call flow.
The test itself would make assertions on the output of the subject and the test,
which is a flow or something else.
Secondly, if the unit under test is exposing a flow
and that value or stream of values is what you want to verify,
you have multiple ways to collect it.
You can call the first method on the flow,
and this is going to collect until it receives the first item
and then stop collecting.
But also, you can use operators, such as take(5),
and call the two list terminal operator to collect exactly five messages,
which can be useful.
Hopefully, in this talk you've learned
why a reactive architecture is a good investment
and how to build your infrastructure with Kotlin Flow.
If you feel inspired, we have a ton of content around this,
a guide that covers the basics,
and blog posts where we do deep dives on some topics.
Also, if you want to see all of this in context,
check out the Google I/O app,
which we updated earlier this year to include flows everywhere.
Thank you.
5.0 / 5 (0 votes)