androidengineers.Book a session

Modern Android Architecture Components

ViewModel & SavedStateHandle Internals

article25 minHard

How ViewModel Survives Rotation

A ViewModel is NOT recreated when the Activity is destroyed due to a configuration change (rotation, locale change, etc.). Understanding why requires knowing the ViewModelStore.

The ViewModelStore Chain

Activity (destroyed on rotation)
  └── ViewModelStore (SURVIVES rotation — held by NonConfigurationInstance)
        └── HashMap<String, ViewModel>
              └── "UserViewModel" → UserViewModel instance

When the system destroys an Activity for configuration change, it calls Activity.onRetainNonConfigurationInstance() and stores the ViewModelStore. When the new Activity instance is created, it retrieves the store via getLastNonConfigurationInstance(). The ViewModels inside are never destroyed.

// What the framework does internally (simplified)
class ComponentActivity : Activity(), ViewModelStoreOwner {

    private var viewModelStore: ViewModelStore? = null

    override fun getViewModelStore(): ViewModelStore {
        if (viewModelStore == null) {
            // Try to restore from NonConfigurationInstance first
            val nc = lastNonConfigurationInstance as? NonConfigurationInstances
            viewModelStore = nc?.viewModelStore ?: ViewModelStore()
        }
        return viewModelStore!!
    }

    override fun onRetainNonConfigurationInstance(): Any {
        return NonConfigurationInstances(viewModelStore = viewModelStore)
    }

    override fun onDestroy() {
        super.onDestroy()
        // Only clear on genuine finish, not on rotation
        if (!isChangingConfigurations) {
            viewModelStore?.clear() // calls ViewModel.onCleared()
        }
    }
}

Process Death vs Configuration Change

ScenarioViewModel survives?SavedStateHandle survives?
RotationYESYES
Back pressedNONO
Process killed by systemNOYES (via saved state)
Force stop / user kills appNONO

This is the critical distinction: ViewModel is not a persistence mechanism. For true persistence across process death, use SavedStateHandle.


The Factory Pattern

ViewModels cannot be constructed directly by the Activity — they need to go through ViewModelProvider so the framework can manage the store. The factory controls construction.

// Without Hilt — manual factory
class UserViewModel(
    private val userId: String,
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {

    companion object {
        fun factory(userId: String, useCase: GetUserUseCase) =
            object : ViewModelProvider.Factory {
                @Suppress("UNCHECKED_CAST")
                override fun <T : ViewModel> create(modelClass: Class<T>): T {
                    return UserViewModel(userId, useCase) as T
                }
            }
    }
}

// Usage in Fragment
class UserFragment : Fragment() {
    private val userId by lazy { requireArguments().getString("userId")!! }
    private val viewModel: UserViewModel by viewModels {
        UserViewModel.factory(userId, GetUserUseCase(UserRepository()))
    }
}

Hilt ViewModel

Hilt automates factory creation. @HiltViewModel + @Inject is all that's needed.

@HiltViewModel
class UserViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase,
    private val savedStateHandle: SavedStateHandle  // injected automatically
) : ViewModel() {

    // Read navigation argument from SavedStateHandle
    private val userId: String = checkNotNull(savedStateHandle["userId"]) {
        "userId argument is required"
    }
}

// Fragment — no factory, no arguments bundle
@AndroidEntryPoint
class UserFragment : Fragment() {
    private val viewModel: UserViewModel by viewModels()
    // Hilt wires up the factory; SavedStateHandle is populated from
    // the Fragment's arguments Bundle automatically
}

SavedStateHandle Deep Dive

SavedStateHandle is backed by the Activity's saved instance state (Bundle). It survives process death because the system saves the Bundle to disk and restores it on relaunch.

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val searchRepository: SearchRepository
) : ViewModel() {

    // Persisted across process death AND rotation
    // Key "query" maps to Bundle key
    private val queryFlow: StateFlow<String> =
        savedStateHandle.getStateFlow("query", initialValue = "")

    // Updating SavedStateHandle writes to the Bundle
    fun onQueryChanged(query: String) {
        savedStateHandle["query"] = query
        // queryFlow automatically emits the new value
    }

    // Custom objects require a Parcelable or you must use primitive types
    // SavedStateHandle limits: same ~1MB limit as Activity saved state
}

Bundle Size Limit

SavedStateHandle is backed by a Bundle — the same ~1 MB limit applies. Storing large lists causes TransactionTooLargeException. Correct approach:

// Wrong: storing large data in SavedStateHandle
savedStateHandle["allUsers"] = userList // potentially huge!

// Correct: store only the key, reload data from repository
savedStateHandle["selectedUserId"] = userId // just the identifier
// Then reload the user from Room/network using that ID

getStateFlow()

getStateFlow() creates a StateFlow backed by SavedStateHandle — the flow survives process death:

@HiltViewModel
class FilterViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    val sortOrder: StateFlow<SortOrder> =
        savedStateHandle.getStateFlow("sort_order", SortOrder.NEWEST)

    val filterCategory: StateFlow<String> =
        savedStateHandle.getStateFlow("filter_category", "all")

    fun setSortOrder(order: SortOrder) {
        savedStateHandle["sort_order"] = order.name
    }
}

ViewModelStoreOwner Hierarchy

Different components provide different ViewModel scopes:

// Activity-scoped: survives Fragment transactions within same Activity
class SharedViewModel : ViewModel() { ... }

class OrderSummaryFragment : Fragment() {
    // activityViewModels() uses the Activity's ViewModelStore
    private val sharedVm: SharedViewModel by activityViewModels()
}

class PaymentFragment : Fragment() {
    // Same instance as OrderSummaryFragment's sharedVm
    private val sharedVm: SharedViewModel by activityViewModels()
}

// NavGraph-scoped: survives within a navigation sub-graph
class CheckoutViewModel : ViewModel() { ... }

class ShippingFragment : Fragment() {
    // Scoped to the checkout nav graph, shared with all fragments in it
    private val checkoutVm: CheckoutViewModel by navGraphViewModels(R.id.checkout_graph)
}

Common Mistake: Passing Context to ViewModel

ViewModels are not tied to the Activity lifecycle for a reason. Storing a reference to an Activity or Fragment causes memory leaks.

// WRONG — ViewModel holds Activity reference
class BadViewModel(private val context: Context) : ViewModel() {
    // If context is an Activity, you've leaked it!
}

// WRONG — even worse
class WorsViewModel(private val activity: MainActivity) : ViewModel()

// CORRECT — use AndroidViewModel for application context
class GoodViewModel(application: Application) : AndroidViewModel(application) {
    // getApplication() returns Application context, not Activity
    private val appContext: Context = getApplication()
}

// BETTER — inject application context via Hilt
@HiltViewModel
class BetterViewModel @Inject constructor(
    @ApplicationContext private val context: Context
) : ViewModel()

// BEST — don't pass Context at all; pass only what you need
@HiltViewModel
class BestViewModel @Inject constructor(
    private val resourceProvider: ResourceProvider  // abstraction over Context
) : ViewModel()

ViewModel and onCleared()

onCleared() is called when the ViewModel is permanently destroyed (user navigates back, Activity genuinely finishes). Use it for cleanup, but with viewModelScope coroutines are cancelled automatically.

@HiltViewModel
class StreamViewModel @Inject constructor(
    private val webSocketClient: WebSocketClient
) : ViewModel() {

    private val job = viewModelScope.launch {
        webSocketClient.messages().collect { message ->
            // handle messages
        }
    }
    // job is cancelled automatically by viewModelScope when onCleared() is called

    override fun onCleared() {
        super.onCleared()
        // Only needed for non-coroutine resources
        webSocketClient.close()
    }
}

Key Takeaways

ConceptSummary
ViewModelStoreHeld by NonConfigurationInstance; survives rotation
Process deathViewModel does NOT survive; SavedStateHandle does
Factory patternRequired to inject parameters into ViewModel
@HiltViewModelAuto-generates factory; supports SavedStateHandle injection
SavedStateHandleBacked by Bundle; 1 MB limit; store IDs not large data
getStateFlow()Converts SavedStateHandle key to StateFlow
Context leakNever store Activity/Fragment reference in ViewModel
onCleared()viewModelScope cancels coroutines automatically

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
ViewModel & SavedStateHandle Internals | Android System Design | Android Engineers