How to load and collect data in the ViewModel

Skele·2025년 5월 8일
0

Android

목록 보기
14/15

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.

Part 1 : Easiest Mistake

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.

Pro

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.

Con

There are many downsides to this code.

1. Not Lifecycle aware

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.

2. Eager loading

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.

3. Loading time

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.

4. Ignorant about data change

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.

Part 2 : OnStart and StateIn

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.

Pro

1. Lifecycle aware

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.

2. Lazy loading

The flow will only launch when the UI that uses it collects.

3. Testable

As the emission of the state does not depend on the time of the viewmodel creation, it's easier to test with collect.

Con

1. Loading time

The initialization block is still launched in a single suspend function, loading data one by one.

2. Ignorant about data change

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.

Part 3 : Combine Flow

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.

Pro

1. Parallel loading

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.

2. Observing data change

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.

Con

1. User input

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.

Part 4 : More Flow


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.

Summary

This article explored four approaches to loading data in ViewModels, each with increasing sophistication:

  1. Basic Init Block:

    • Simple but not lifecycle-aware
    • Loads data eagerly and only once
    • Sequential loading increases wait time
    • Cannot react to data changes
  2. OnStart with StateIn:

    • Lifecycle-aware through collection mechanism
    • Lazy loading improves resource efficiency
    • More testable through predictable flow behavior
    • Still loads sequentially and doesn't observe data changes
  3. Combine Flow:

    • Parallel data loading reduces wait time
    • Automatically observes and reacts to data changes
    • Maintains lifecycle awareness through StateIn
    • May struggle with handling user input separately from data streams
  4. All in one Flow:

    • Adds user input handling through additional flows
    • Centralizes state management (single source of truth)
    • Most robust solution for complex UIs

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.

reference

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.

profile
Tireless And Restless Debugging In Source : TARDIS

0개의 댓글