What Is a ViewModel in Android? A Complete Guide for Developers

admin
admin

NDK

What Is a ViewModel in Android? A Complete Guide for Developers

The Architecture Problem ViewModel Solves

Android components like Activities and Fragments have lifecycle callbacks (onCreate, onStart, onResume, onPause, onStop, onDestroy) that are tightly coupled to user navigation and system events. A rotation, configuration change, or process kill can destroy and recreate an Activity. Without a ViewModel, any UI state—a loaded list, a text field value, a network response—stored in the Activity is lost. Developers historically used onSaveInstanceState bundles, which are limited to primitive types and small serializable objects (typically under 1 MB), or retained Fragments, which are complex and error-prone. ViewModel provides a clean, lifecycle-aware container that survives configuration changes and only clears when the associated LifecycleOwner is permanently destroyed.

Defining the ViewModel: Core Characteristics

A ViewModel is a class from the androidx.lifecycle package designed to store and manage UI-related data in a lifecycle-conscious way. It is never a replacement for a database or a repository; it is a short-lived, in-memory cache that holds the current state of the UI. Key characteristics include:

  • Lifecycle Awareness: Automatically scoped to a LifecycleOwner (Activity or Fragment). It persists through configuration changes and is cleared when the owner finishes permanently.
  • No Direct View Reference: ViewModels should never hold references to Activities, Fragments, Views, or Context. Doing so creates memory leaks because the ViewModel outlives the UI. Instead, use Application context via an AndroidViewModel subclass if needed.
  • Automatic Scope Management: The system retains the ViewModel instance during configuration changes. When the Activity is recreated, the same ViewModel is returned.
  • Asynchronous Support: ViewModel works seamlessly with coroutines, LiveData, and Flow for reactive data streams.

The Lifecycle of a ViewModel: Step-by-Step

  1. Creation: The first time an Activity/Fragment requests a ViewModel via ViewModelProvider, the ViewModel is instantiated. onCleared() is called when the Activity finishes (e.g., user presses Back) or the Fragment is detached.
  2. Configuration Change: The Activity/Fragment is destroyed and recreated. The ViewModel is not destroyed. The new instance obtains the same ViewModel.
  3. Process Kill: Android can kill the app process to reclaim memory. In this case, the ViewModel is destroyed with the process. Use SavedStateHandle (available in ViewModel 1.2.0+) to survive process death by serializing critical state to the saved instance state bundle.

When Is a ViewModel Not the Right Tool?

  • Complex Business Logic: ViewModel should not contain business logic that belongs in a domain layer or repository.
  • Permanent Storage: ViewModel is in-memory only. Do not use it to persist data that must survive app restarts—use a database or SharedPreferences.
  • UI-Only State: Simple boolean flags or temporary animation states that are recreated on each rotation may not need a ViewModel; consider remember in Compose or savedInstanceState in Views.

Creating a Basic ViewModel: Practical Code

// Correct implementation: no context reference
class UserViewModel : ViewModel() {
    private val _users = MutableLiveData>()
    val users: LiveData> get() = _users

    fun loadUsers() {
        viewModelScope.launch {
            val result = repository.fetchUsers()
            _users.value = result
        }
    }
}

// When Application context is necessary (e.g., system services)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
    val sharedPrefs = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
}

To instantiate in an Activity:

val viewModel: UserViewModel = ViewModelProvider(this).get(UserViewModel::class.java)

ViewModel and Coroutines: viewModelScope

Every ViewModel includes a built-in coroutine scope called viewModelScope. When the ViewModel is cleared, this scope is automatically cancelled, preventing leaked coroutines. This eliminates the need to manually manage coroutine jobs and lifecycle callbacks. Always use viewModelScope.launch for any asynchronous work that should be tied to the ViewModel’s lifecycle.

Handling Input State with a ViewModel

A common pattern is to store UI state as a single immutable data class. This reduces the number of LiveData/Flow streams and improves testing.

data class LoginUiState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow = _uiState.asStateFlow()

    fun updateEmail(email: String) {
        _uiState.update { it.copy(email = email) }
    }
}

ViewModel and Jetpack Compose

In Compose, ViewModel is accessed via viewModel() function. Compose automatically handles recomposition when observed state changes. The same lifecycle rules apply: ViewModel survives configuration changes and is scoped to the nearest NavBackStackEntry in navigation.

@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsState()
    TextField(
        value = state.email,
        onValueChange = viewModel::updateEmail
    )
}

ViewModelFactory: Custom Constructor Parameters

When a ViewModel requires constructor parameters not provided by the default factory (e.g., a repository), create a custom ViewModelProvider.Factory:

class MyViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
    override fun  create(modelClass: Class): T {
        if (modelClass.isAssignableFrom(MyViewModel::class.java)) {
            return MyViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown class")
    }
}

Alternatively, use Hilt (Dagger) or Koin for automatic injection. With Hilt, annotate the ViewModel with @HiltViewModel and use SavedStateHandle for process-death resilience.

Common Antipatterns to Avoid

  • Storing Context in ViewModel: Use AndroidViewModel only when absolutely necessary. Prefer passing Context to repositories or use dependency injection.
  • Exposing MutableLiveData Directly: Always expose immutable LiveData or StateFlow to the UI. Keep MutableLiveData or MutableStateFlow private.
  • Heavy Work in Init: Avoid loading large datasets or performing network calls in the init block. Use viewModelScope.launch and trigger loading from the UI.
  • ViewModels for Non-UI Logic: Do not use ViewModel for background services or long-running tasks that outlive the UI. Use a dedicated Service or WorkManager.

ViewModel in Multi-Module Apps

In modularized apps, ViewModels should reside in the feature module that owns the screen. Shared ViewModels across multiple screens (e.g., a shopping cart) should be scoped to an Activity or a Navigation graph using hiltViewModel() and the navGraphViewModels() approach. Avoid sharing ViewModels across unrelated features to prevent tight coupling.

Testing ViewModels

Because ViewModels do not depend on Android lifecycle classes directly, they are trivial to unit test. Inject repositories as constructor parameters and use TestCoroutineDispatcher for coroutine-based code.

@Test
fun `loadUsers sets data on success`() = runTest {
    val repo = FakeUserRepository()
    val viewModel = UserViewModel(repo)
    viewModel.loadUsers()
    assertThat(viewModel.users.value).isNotEmpty()
}

Performance Considerations

  • ViewModel instances are stored in a ViewModelStore HashMap, keyed by the ViewModel’s class name or a custom key. This is memory-efficient for typical screen counts.
  • Avoid storing large bitmaps or heavy objects. If needed, serialize them to disk and store only a reference or ID in the ViewModel.
  • Multiple ViewModels per screen are acceptable; create one per distinct data domain to follow single responsibility.

ViewModelStoreOwner and Scoping

A ViewModel is scoped to the nearest ViewModelStoreOwner. Activities and Fragments are the primary owners. Navigation Composable destinations also implement ViewModelStoreOwner. Use viewModelStoreOwner = LocalViewModelStoreOwner.current in Compose to ensure correct scoping. For Activity-level sharing, use the activity’s ViewModelStore across multiple fragments.

The Future: ViewModel in the MVVM/MVI Pattern

ViewModel is the backbone of both MVVM (Model-View-ViewModel) and MVI (Model-View-Intent) architectures in modern Android. Combined with StateFlow, it enforces unidirectional data flow: the UI sends events (intents), ViewModel updates state, and the UI observes and reacts. This pattern leads to predictable, testable, and maintainable codebases.

Detailed Code Walkthrough: A Complete Example

// data class for immutable state
data class TaskListUiState(
    val tasks: List = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

// sealed class for user intents
sealed class TaskIntent {
    data class LoadTasks(val filter: String) : TaskIntent()
    object Refresh : TaskIntent()
}

class TaskViewModel(
    private val repository: TaskRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _uiState = MutableStateFlow(TaskListUiState())
    val uiState: StateFlow = _uiState.asStateFlow()

    init {
        // Restore filter from process death
        savedStateHandle.get("filter")?.let { filter ->
            processIntent(TaskIntent.LoadTasks(filter))
        }
    }

    fun processIntent(intent: TaskIntent) {
        when (intent) {
            is TaskIntent.LoadTasks -> loadTasks(intent.filter)
            is TaskIntent.Refresh -> loadTasks()
        }
    }

    private fun loadTasks(filter: String? = null) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            try {
                val tasks = repository.getTasks(filter)
                _uiState.update { it.copy(tasks = tasks, isLoading = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, error = e.message) }
            }
        }
    }
}

This example demonstrates state immutability, intent-driven updates, error handling, SavedStateHandle for process death, and testable coroutine logic. The ViewModel acts as a pure state machine, decoupled from any Android UI framework.

When to Avoid ViewModel Entirely

For simple utility screens with no state (e.g., a static About screen), a ViewModel adds unnecessary complexity. Similarly, for global app-level state (e.g., user authentication status), consider a singleton repository or a shared ViewModel scoped to the Application, though be cautious of memory retention. ViewModel excels at screen-level, short-lived state management—not as a global cache or a database replacement.

Leave a Reply

Your email address will not be published. Required fields are marked *