In many articles and youtube videos, I have stumbled upon many suggestions about best way to load data in viewmodel.
As I started to do another side project, I thought this was a good time to organize these.
class MyViewModel(
private val repo: MyRepository,
): ViewModel() {
private val _state = MutableStateFlow<MyState>(MyState.Loading)
val state = _state.asStateFlow()
init {
viewModelScope.launch {
val user = repo.getUser()
val articles = repo.getArticles()
_state.value = ReduceToState(user, articles)
}
}
}
This design is often used when first developing in android. Eagerly loads data when the viewmodel is created for the first time.
I do not think there is any better aspect about this approach other than it's just simple and easy to understand for those just have started.
There are many downsides to this code.
The init{}
block only runs when the viewmodel is created for the first time. For those who don't know the life cycle of android, viewmodel often outlives the lifetime of UI. So when the user sends the app to background and comes back, the UI is destroyed and recreated, but the viewmodel doesn't. Leading to stale data on the screen.
Even if the state is not being collected, the coroutine will be launched no matter what. For most cases, states are always collected to be used, but eagerly loading ahead of collection can lead to inefficient use of resource.
Both user and article fetching functions would be suspend functions, meaning the data is loaded one by one. As more data is required in initialization, these suspending functions will increase the loading time.
The code only fetches data from repo once. It does not observe the change in the data it fetches. If, somehow user or the article content changes, there's no way to acknowledge the change made.
class MyViewModel @Inject constructor(
private val repo: MyRepository,
) : ViewModel() {
private val _state = MutableStateFlow(value = MyState.Loading)
val state = _state
.onStart { initialize() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L),
initialValue = screenState.value,
)
private suspend fun initialize() {
val user = repo.getUser()
val articles = repo.getArticles()
_state.value = ReduceToState(user, articles)
}
}
Now this is the code fixes two problems above.
Every time the user comes back to the app, the UI will start collecting from the viewmodel. This will trigger onStart
block, which contains initialization for the state.
The flow will only launch when the UI that uses it collects.
As the emission of the state does not depend on the time of the viewmodel creation, it's easier to test with collect
.
The initialization block is still launched in a single suspend function, loading data one by one.
Since the data is loaded only once in the initialization function, while the user stays on the screen, refreshing the data can only be done manually.
class MyViewModel(
private val repo: MyRepository,
): ViewModel() {
val state = combine(
repo.user.distinctUntilChanged(),
repo.getArticles(),
) { user, feed ->
ReduceToState(user, feed)
}.stateIn(
scope = viewModelScope,
initialValue = HomeState.Loading,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 1.seconds,
replayExpirationMillis = 9.seconds,
),
)
}
This approach solves the remaining problems.
With combine
, each flow values are collected in parallel. Meaning the loading time would take only that of longest of all flow, not the sum of all suspend functions.
As the state combines two flows that emit new value on change, the state will also change when any one of the flow values changes.
In the MVI design, the whole state must be updated to change its value. In this design, manual state update might occur in another part of the code simultaneously with combined flow collection. This could lead to loss of data and break the single source of truth principle.
class MyViewModel(
private val repo: MyRepository,
): ViewModel() {
private val _userInput = MutableSharedFlow<UserInput>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val state = combine(
repo.user.distinctUntilChanged(),
repo.getArticles(),
_userInput,
) { user, feed, userInput ->
ReduceToState(user, feed, userInput)
}.stateIn(
scope = viewModelScope,
initialValue = HomeState.Loading,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 1.seconds,
replayExpirationMillis = 9.seconds,
),
)
}
Final form of the code handling user input.
With user input collected as flow with other flows, state change is centralized to this flow and this flow only. Solving distributed and simultaneous updates of states.
This article explored four approaches to loading data in ViewModels, each with increasing sophistication:
Basic Init Block:
OnStart with StateIn:
Combine Flow:
All in one Flow:
Each approach represents an improvement in thinking about state management, from simple imperative code to a fully reactive architecture. The right choice would depend on the app's complexity, but for most Android applications, approaches 3 or 4 provide the best balance of reactivity, efficiency, and maintainability.
As a developer, seeking better-performing and cleaner code is one of the essential traits to have. I might stick to this code for a while, but I won't hesitate when I find a better one.
As a developer, seeking better performing and cleaner code is one of the essential traits to have. I might stick to this code for a while, but I won't hesitate when I find a better approach. This mindset of continuous improvement is what drives the progression of my projects.
The ONLY Correct Way to Load Initial Data In Your Android App?
Stale Data & Leaks were killing my Kotlin apps for 5 years. Here’s the fix.