I came across this article:
https://proandroiddev.com/loading-initial-data-in-launchedeffect-vs-viewmodel-f1747c20ce62
In this article, the author, while describing the advantages and disadvantages of different data initialization approaches, concludes that it's best to do it using StateFlow. I decided to give it a try, considering that I use the UiState wrapper as a common state for the screen and MyRepo as the data source:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Error(val code: Int? = null, val message: String? = null) : UiState<Nothing>()
data class Content<T>(val data: T) : UiState<T>()
}
class MyRepo {
fun getMyData(): Flow<List<String>> {
return flow {
delay(1000)
emit(listOf("1", "2"))
}
}
}
This is my implementation before the changes:
class MyViewModel1(repo: MyRepo) : ViewModel() {
data class ScreenStateUI(
val data: List<String> = emptyList(),
val title: String = "Title",
)
private val _screenUiState: MutableStateFlow<UiState<ScreenStateUI>> =
MutableStateFlow(UiState.Loading)
val screenUiState: StateFlow<UiState<ScreenStateUI>> = _screenUiState.asStateFlow()
init {
viewModelScope.launch {
repo.getMyData()
.map<List<String>, UiState<ScreenStateUI>> { UiState.Content(ScreenStateUI(data = it)) }
.collectLatest {
if (it is UiState.Content) {
_screenUiState.emit(it)
}
}
}
}
fun updateTitle(title: String) {
_screenUiState.update {
if (it is UiState.Content) {
it.copy(data = it.data.copy(title = title))
} else {
it
}
}
}
}
During initialization, the state is loaded, and the UI can subscribe to changes via screenUiState. This means there are no issues if you need to use fun updateTitle(title: String).
Now I’ve changed the implementation to remove the initialization in the init block and make everything happen as soon as the UI subscribes to the event:
class MyViewModel2(repo: MyRepo) : ViewModel() {
data class ScreenStateUI(
val data: List<String> = emptyList(),
val title: String = "Title",
)
val screenUiState: StateFlow<UiState<ScreenStateUI>> by lazy {
repo.getMyData()
.map<List<String>, UiState<ScreenStateUI>> { UiState.Content(ScreenStateUI(data = it)) }
.onStart { emit(UiState.Loading) }
.catch { emit(UiState.Error(message = it.message)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading,
)
}
fun updateTitle(title: String) {
...
}
}
So, everything seems to look fine, and indeed, the UI now triggers requests only when it subscribes. However, the state can no longer be updated, and consequently, the function updateTitle
cannot update the title anymore.
What am I missing here?
You have two sources of data. The repository provides the list, the title is stored in the view model.
What you are missing is the storage for the title. Just add another MutableStateFlow that holds the title. It can be changed as usual and it can also be incorporated into your chain of flow operations:
class MyViewModel2(repo: MyRepo) : ViewModel() {
data class ScreenStateUI(
val data: List<String>,
val title: String,
)
private val titleFlow = MutableStateFlow("Title")
val screenUiState: StateFlow<UiState<ScreenStateUI>> by lazy {
combine(
repo.getMyData(),
titleFlow,
) { data, title ->
UiState.Content<ScreenStateUI>(ScreenStateUI(data = data, title = title))
}
.onStart<UiState<ScreenStateUI>> { emit(UiState.Loading) }
.catch<UiState<ScreenStateUI>> { emit(UiState.Error(message = it.message)) }
.stateIn<UiState<ScreenStateUI>>(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading,
)
}
fun updateTitle(title: String) {
titleFlow.value = title
}
}
combine
takes multiple flows as input and merges their content. This is why titleFlow
needs to be a flow, otherwise screenUiState
cannot be updated with the title.
If, later on, you decide to store the title somewhere else (outside of the view model, in a database for example), you just need to replace titleFlow
with the flow from that new data source and change updateTitle
to call that data source's update method accordingly.