I was perusing Reddit the other day when someone asked how they could use SavedStateHandle
with a StateFlow
similar to the SavedStateHandle.getLiveData()
version. The most upvoted comment originally was saying that this functionality is not officially supported, but one could convert the LiveData
to a Flow
using the LiveData.asFlow()
extension function. That seemed pretty simple for anyone to do, however testing that would then require using LiveData
in your tests which might be annoying if you were using StateFlow
to manage state. So after looking over the API, it seemed pretty simple to write a wrapper that could expose this functionality directly as a StateFlow
and that is how the SavedStateFlow library was made!
SavedStateFlow API
At its core, the API for SavedStateFlow
is very simple as it’s supposed to be similar to a MutableStateFlow
. There is a value
property that can be mutated and a method that can expose it as a StateFlow
.
interface SavedStateFlow<T> {
var value: T
fun asStateFlow(): StateFlow<T>
}
The implementation detail of this interface will simply delegate value changes to the SavedStateHandle
and will observe any value changes from the SavedStateHandle.getLiveData()
function. Then the initial value for the SavedStateFlow
will first be retrieved by the SavedStateHandle
and if one does not exist then it will default to the one provided by yourself.
How to create a SavedStateFlow?
SavedStateFlow
is just an interface, so how does one create an instance of one? Well since SavedStateHandle
can’t create this, there is a new wrapper called SavedStateFlowHandle
. The library includes an extension function on SavedStateHandle
to create a reference to a SavedStateFlowHandle
.
val savedStateHandle: SavedStateHandle = TODO()
val savedStateFlowHandle: SavedStateFlowHandle =
savedStateHandle.toSavedStateFlowHandle()
Now this new SavedStateFlowHandle
provides two new functions on top of the original SavedStateHandle
API.
interface SavedStateFlowHandle {
@MainThread
fun <T> getSavedStateFlow(
viewModelScope: CoroutineScope,
key: String,
defaultValue: T
): SavedStateFlow<T>
@MainThread
fun <T> getFlow(key: String): Flow<T>
}
The getFlow()
function is pretty self explanatory and exposes the SavedStateHandle.getLiveData()
as a Flow
directly, which could help for unit testing avoiding the need to mess around with LiveData
directly.
The getSavedStateFlow()
is the real meat and potatoes of this library as that is how to create an instance of a SavedStateFlow
. Notice the first parameter to this function is viewModelScope
, that is because the SavedStateFlow
will use that CoroutineScope
to collect new values from the SavedStateHandle
whenever the value changes and will also stop collecting the values when the ViewModel
itself is cleared. So putting everything together, one simple usage of SavedStateFlow
might look like the following:
class MainViewModel(
savedStateFlowHandle: SavedStateFlowHandle,
private val newsDataSource: NewsDataSource
) : ViewModel() {
private val query: SavedStateFlow<String> =
savedStateFlowHandle.getSavedStateFlow(
viewModelScope = viewModelScope,
key = "main-viewmodel-query-key",
defaultValue = ""
)
init {
observeQuery()
}
fun updateQuery(query: String) {
this.query.value = query
}
private fun observeQuery() {
viewModelScope.launch {
query.asStateFlow()
.flatMapLatest { query ->
// fetch the results for the latest query
newsDataSource.fetchQuery(query)
}
.collect { results ->
// Update with the latest results
}
}
}
}
Since SavedStateFlow
is a wrapper around SavedStateHandle
, the following note from the documentation should be observed. “State must be simple and lightweight. For complex or large data, you should use local persistence.”
How to inject SavedStateFlowHandle?
In the sample above, there was an extension function on SavedStateHandle
to get an instance of a SavedStateFlowHandle
. Some of you might be wondering how one actually injects that into a ViewModel
. Well if you’re doing manual injection, this is pretty simple using the AbstractSavedStateViewModelFactory
and there is a sample of this in the documentation.
However, where this library really shines in the developer experience is when you are using Hilt because there is a separate artifact which can automatically scope a SavedStateFlowHandle
to any @HiltViewModel
.
@HiltViewModel
class MainViewModel @Inject constructor(
savedStateFlowHandle: SavedStateFlowHandle
) : ViewModel()
However, not every ViewModel
can be annotated with @HiltViewModel
when values passed through the constructor are determined at runtime which is when assisted injection can be used. In this scenario, there are a few extension methods provided which can provide a SavedStateFlowHandle
when using assisted injection.
class MyAssistedViewModel @AssistedInject constructor(
@Assisted savedStateFlowHandle: SavedStateFlowHandle,
@Assisted id: String
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(savedStateFlowHandle: SavedStateFlowHandle, id: String): MyAssistedViewModel
}
}
@AndroidEntryPoint
class AssistedFragment : Fragment() {
@Inject
lateinit var factory: MyAssistedViewModel.Factory
private val viewModel: MyAssistedViewModel by assistedViewModel { savedStateFlowHandle ->
factory.create(savedStateFlowHandle, arguments?.getString("some-argument-key")!!)
}
}
For more information on the SavedStateFlow
Hilt integration, please check out the documentation.
Testing
The main motivation for writing this library was for testing and to avoid messing around with LiveData
, so there is a test artifact that can be used for unit tests called TestSavedStateFlow
. The addition to this class allows you to provide a default value or a cached value which is null by default for different testing scenarios. One basic usage of this artifact with Mockk is as shown below:
class SomeTest {
@Test
fun `some test`() = runBlocking {
val savedStateHandle: SavedStateFlowHandle = mockk()
val savedStateFlow = TestSavedStateFlow<String>(
defaultValue = "",
cachedValue = "some cached value"
)
every { savedStateHandle.getSavedStateFlow(any(), "some-key", "") } returns savedStateFlow
val viewModel = MyViewModel(savedStateHandle)
// omitted test code
}
}
For more information on testing and how this could be used with Turbine, please check out the documentation.
Conclusion
In this article we went over how to create/use a SavedStateFlow
, inject a SavedStateFlowHandle
into a ViewModel
and how to test with TestSavedStateFlow
. I highly encourage you to check out the documentation which has more detailed samples and the GitHub repository if you want to take a look at the source code or even make a contribution if you see ways it could be improved.
Hope someone else finds this library useful until Google decides to support this functionality officially sometime in the future. Enjoy!
Resources
- Github Repository
- Project site - documentation
- Publishing Android libraries to MavenCentral in 2021 - I have never published a library before and this article was very helpful for getting this library up on MavenCentral