Another way of using LiveData with Jetpack Compose
Jetpack Compose is coming on leaps and bounds and we’ve had a glimpse of what LiveData usage within compose may look like but nothing officially recommended as the “go to” standard.
Jetpack Compose provides a very convenient way of hooking into the compose lifecycle with a few callbacks the first of which is onActive { }
.
Composition lifecycle callbacks
In our compose functions (those marked with @Composable
we can hook into this composition quite simply by doing the following.
In the example our root composition starts with setContent
(if you are not familiar with compose yet, this is where we can write our declarative UI). Inside this block, we can put our onActive
composition lifecycle callback to add a block of code that will be only executed once per composition. Within the onActive
block we can specify another block onDispose
which will get called when this composition is invalidated, therefore providing convenient hooks for callback/subscription based API’s such as LiveData<T>
.
LiveData integration example
Given the basics out of the way the next code snippet shows a usage pattern of a custom function observe
that makes use of compose lifecycle callbacks which accepts a LiveData as its first argument and has a convenient callback onResult
where the live data result will be delivered (effectively wrapping the observe behaviour of live data).
The example is not small, but requires some explanation. First up we remember
a model HelloModel
. This model is annotated with @Model
which is composes way of specifying observable data that when changed, can cause a recomposition.
Next up on our observe
block which we give the result of a function call to getHello
that returns a LiveData<HelloResult>
. (the implementation of the observe
function is where the magic happens which I will show and break down later).
As mentioned we specify another block inside the observe
call onResult { }
. Here we can take the result of the live data call and pass it to a property on our model with hello.state = result
, where result is a property available in only the onResult
block.
The final code block in setContent
is where we decide which message to show depending on the value ofHelloModel::state
. It is a simple when(state){}
block when that will show an appropriate message depending on the state being Pending
, Hello
or Error
. (Currently no error could occur with this code example, however for brevity its here to give you an idea of how you could handle an error).
Under the hood of the observe
function
Inside this custom observe
function is where all the magic happens. Where we hook into the composition using onActive
and onDispose
.
The function is not as simple as it could be since I plan to expand it if nothing more comes along hence the usage of receivers for scoped functionality.
So what is going on here? Firstly the observe
function takes an argument data: LiveData<T>
which is the live data we wish to load. secondly we have a trailing lambda block: ObserveScope<T>.() -> Unit
. which is effectively is a delegate for our live data observer (which we never need to use with this setup).
The function does some setup and calls context.onStartBlock()
giving integrations the chance to hook into the moment just before the LiveData is observed with onStart {}
(maybe for showing a loading spinner or something).
Moving on we create an observer
and in the onChanged
callback we call context.onResultBlock
which ultimately calls onResult {}
which we saw previously in the usage sample.
Next we observeForever(observer)
which kicks off the live data observation. finally we declare a onDispose
block and ensure we remove observers. The rest of the code is mostly specific to my exploration and usage of this pattern.
Looking back at the observe
function you may have noticed that the data: LiveData<T>
argument is annotated with @Pivotal
. What this does is tell the compose compiler that this argument should act as unique ID for this block and if a recomposition occurs and this unique ID changes then this part becomes invalid and should be recomposed causing the onDispose
block to be called and onActive
to be called again. Without compose knowing its unique this would never happen.
Handling errors and retry
So I mentioned that error handling is not in the usage example snippet (well handling is there just not throwing an error and retrying), so the only way I have found out so far to retry a composition is by using a convenient composable called Recompose
which I will explain with another example that builds on the previous example.
Here we wrap our code with aRecompose
block which gives us a lambda callback we call recompose
which we call later if an error occurs as the action of a button click. The code mimics an error with a field doError
firstly set to true which causes loading with an error. Then in our retry Button
onClick
callback we set doError
to false so when we call recompose()
Our live data will be loaded again but this time with a successful result HelloResult.Hello
.
An important point to note is we check hello.state != result
before setting the state on the model as I found it causes infinite recomposition.
I don’t know if this is the best way, to use Recompose in this manner, what do you think?
Conclusion
These are my most recent dabbling with Jetpack Compose and LiveData integration, not 100% sure if its right however that was partly the point of this article, to get my thoughts out there and what I have found out so far.
I am using this method in my little compose app and it works well so far https://github.com/fluxtah/memset
What do you think? Have you found a better way of LiveData integration that ticks all the boxes such as error handling and reloading (recomposing?) I am interested to hear about this in the comments or reach out to me, maybe join the Jetpack Compose slack channel and you can find me there! :)