ViewModel, Lifecycle-Aware Components, Coroutines and Flow

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A ViewModel and the lifecycle-aware machinery around it exist to answer one recurring problem: a screen’s UI state needs a home that outlives the short-lived Activity, Fragment or NavBackStackEntry object that currently displays it, without outliving the process the app runs in. This page covers that home in depth — how a ViewModel is scoped and created, exactly what it survives and what it does not, how coroutines and Flow are collected safely against a UI lifecycle, and how StateFlow, SharedFlow and Channel divide up the job that LiveData and RxJava used to do alone. It targets lifecycle 2.11.0, kotlinx-coroutines (the version TrailMate’s version catalog pins), androidx.hilt 1.4.0 and Android 17 (API level 37), building on Activities and the Activity Lifecycle and Fragments and the Fragment Lifecycle, which introduced viewModels() and activityViewModels() at the point each toolkit first needed them. Recommended App Architecture (UI, Domain and Data Layers) already introduced TrailListViewModel, TrailListUiState and stateIn(WhileSubscribed(5_000)) as the shape a production screen should take; this page explains why each of those pieces exists and how the framework underneath them works, rather than re-deriving the architecture from scratch.

Kotlin’s coroutine and Flow language features themselves — suspend functions, builders, structured concurrency, cancellation, Flow operators — are covered on Coroutines Basics, Coroutine Context, Cancellation and Exceptions and Flows, and are not repeated here. This page is about the Android-specific scaffolding built on top of them: the scopes that tie a coroutine’s lifetime to a UI component, and the lifecycle-aware collectors that stop that coroutine from doing work no view is around to see.

ViewModel Scopes: Activity, Fragment and Navigation Entry

A ViewModel is never new-ed up directly in production code. It is requested from a ViewModelProvider, keyed by both a class and a scope — an object implementing ViewModelStoreOwner that holds a ViewModelStore, a simple map from class to instance. The scope decides two things: how many callers share the same instance, and how long that instance lives. TrailMate uses three different scopes for three different needs:

Scope ViewModelStoreOwner When TrailMate uses it

Activity-scoped

The hosting ComponentActivity/AppCompatActivity

A ViewModel shared by every fragment inside one activity, such as TrailSelectionViewModel in the legacy Views build’s dual-pane layout (see Fragments and the Fragment Lifecycle)

Fragment/screen-scoped

The individual Fragment, or the ComponentActivity itself for a single-Activity Compose app

A ViewModel private to one screen, such as TrailListViewModel and TrailDetailViewModel

Navigation-entry-scoped

A NavBackStackEntry from Navigation Compose or Navigation 3

A ViewModel shared by a small group of destinations that make up one flow (a multi-step "record a hike" wizard), without leaking into the rest of the graph

Two ViewModel requests that resolve to the same ViewModelStoreOwner instance always get the same ViewModel instance back, which is what makes activity scoping a sharing mechanism rather than just a longer lifetime: TrailListFragment and TrailDetailFragment, hosted by the same MainActivity, both requesting TrailSelectionViewModel by activityViewModels(), get one shared object. See Android Developers — ViewModel Overview.

Creating ViewModels

Every creation function below ultimately calls through to a ViewModelProvider; they differ in which scope they resolve against and which toolkit they are written for.

viewModels() and activityViewModels() (Views, Fragment/ComponentActivity)

by viewModels() is a Kotlin property delegate that scopes a ViewModel to the Fragment or ComponentActivity it is called from; by activityViewModels() scopes it to the hosting activity instead, as shown on Fragments and the Fragment Lifecycle:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
class TrailListFragment : Fragment(R.layout.fragment_trail_list) {
    private val viewModel: TrailListViewModel by viewModels()
    private val sharedViewModel: TrailSelectionViewModel by activityViewModels()
}

Both delegates are lazy: the ViewModel is not actually created until the property is first read, and the resulting instance is cached in the owner’s ViewModelStore for every later read and for every recreation of the view that does not also recreate the owner. With Hilt, @HiltViewModel-annotated classes use the exact same viewModels()/activityViewModels() call sites — Hilt supplies the factory transparently through @AndroidEntryPoint, so nothing about the call site changes; see Dependency Injection with Hilt, Dagger and Koin. See Android Developers — Fragment.viewModels().

viewModel() and hiltViewModel() (Compose)

In Compose, viewModel() from androidx.lifecycle:lifecycle-viewmodel-compose resolves against the nearest ViewModelStoreOwner found through LocalViewModelStoreOwner — normally the hosting ComponentActivity, or a NavBackStackEntry when called inside a Navigation Compose destination:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListRoute(
    viewModel: TrailListViewModel = viewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    TrailListScreen(uiState = uiState, onTrailClick = viewModel::onTrailClicked)
}

hiltViewModel(), from androidx.hilt:hilt-lifecycle-viewmodel-compose (the package androidx.hilt.lifecycle.viewmodel.compose as of androidx.hilt 1.4.0; earlier releases shipped it under hilt-navigation-compose), is the Hilt-aware equivalent and is what TrailMate actually calls, since every TrailMate ViewModel is @HiltViewModel-annotated:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
@Composable
fun TrailDetailRoute(
    viewModel: TrailDetailViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    TrailDetailScreen(uiState = uiState, onFavoriteToggle = viewModel::onFavoriteToggled)
}

Passing an explicit NavBackStackEntry as hiltViewModel(backStackEntry) (Navigation Compose) or a route entry key (Navigation 3, via androidx.lifecycle:lifecycle-viewmodel-navigation3) is how a multi-destination flow gets a ViewModel scoped to that flow instead of to a single screen — the navigation-entry scope from the table above. See Android Developers — viewModel().

viewModelFactory { } and CreationExtras

A ViewModel with constructor parameters needs a ViewModelProvider.Factory to build it, since a ViewModelProvider never calls a constructor with arguments directly. The Kotlin DSL viewModelFactory { } builds one without a hand-written class, and CreationExtras is how it reaches the very state a plain constructor call could not have supplied — most importantly, the SavedStateHandle (covered in full on Saving UI State: Views vs. Compose) and, from a Compose viewModel()/hiltViewModel() call, an Application instance:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailViewModel.kt
class TrailDetailViewModel(
    savedStateHandle: SavedStateHandle,
    private val repository: TrailRepository,
) : ViewModel() {
    // Navigation Compose writes the route's arguments into this handle; Navigation 3 does not.
    private val trailId: String = savedStateHandle.toRoute<TrailDetail>().trailId
    // ...

    companion object {
        fun factory(repository: TrailRepository): ViewModelProvider.Factory = viewModelFactory {
            initializer {
                TrailDetailViewModel(
                    savedStateHandle = createSavedStateHandle(),
                    repository = repository,
                )
            }
        }
    }
}

createSavedStateHandle() is an extension available inside an initializer { } block precisely because that block runs with a CreationExtras in scope, and CreationExtras is what carries the SAVED_STATE_REGISTRY_OWNER_KEY and VIEW_MODEL_STORE_OWNER_KEY entries a plain factory interface has no way to receive. In a Hilt-based app like TrailMate, @HiltViewModel plus @Inject constructor generates an equivalent factory automatically — hiltViewModel() wires it in without any of this being written by hand — so viewModelFactory { } shows up mainly for a non-Hilt module, a library ViewModel, or a test that wants a factory without dragging in the DI graph. savedStateHandle.toRoute<TrailDetail>() above assumes Navigation Compose, which populates the SavedStateHandle from the destination’s route; Navigation 3 does not, so a Navigation 3 app passes the NavKey’s `trailId into the factory (or into a Hilt assisted factory) instead, as shown on Saving UI State: Views vs. Compose. See Android Developers — viewModelFactory() and Android Developers — Saved State module for ViewModel.

The ViewModel Lifecycle: Survives Configuration Changes, Not Process Death

A ViewModel instance is retained by its ViewModelStore, and that store itself is retained across a configuration change through a mechanism entirely separate from onSaveInstanceState(): the framework holds the same ViewModelStore object alive in memory and hands it to the new Activity/Fragment instance the system creates after the old one is destroyed. Because of that, a rotation destroys and recreates MainActivity itself, but every ViewModel it (or a fragment it hosts) requested keeps running in the background the entire time — an in-flight coroutine in viewModelScope is never cancelled by a rotation.

Two adjacent horizontal timelines labeled MainActivity instance A and MainActivity instance B, each showing onCreate, onStart, onResume and, for instance A, onPause/onStop/onDestroy, separated by a dashed vertical line labeled 'device rotated (configuration change)'. Below both timelines, a wide green band labeled 'One TrailListViewModel instance' spans the entire width from instance A’s onCreate to past instance B’s onResume, annotated 'held by the ViewModelStore, retained across instance A’s destruction and instance B’s creation' — illustrating that the ViewModel is created lazily by viewModels() once and reused rather than re-created. A second dashed vertical line at the right edge, labeled 'Back pressed / finish() (real destroy, not a config change)', points to a red box reading 'onCleared() runs once', which sits just past the end of the green band. Two boxes below list what a ViewModel survives (configuration changes, font scale, dark mode, folding, multi-window resize, a recreated Activity/Fragment view) and what it does not (process death, explicit finish()/Back out of the last owning screen, in-memory fields lost either way unless bridged by SavedStateHandle).

That retention has a hard boundary: it only spans the life of the process. If the system kills the app’s process to reclaim memory while it is stopped (see Activities and the Activity Lifecycle), every ViewModelStore and every ViewModel inside it disappears along with the rest of the process’s heap — there is no callback that runs in the dying process to save anything from a ViewModel’s ordinary fields. When the user navigates back to that task, the system starts a fresh process, and every `ViewModel involved is created again from nothing, just as if the app had never run. A ViewModel’s `SavedStateHandle is the one thing that bridges this gap, because it is backed by the same platform Bundle mechanism onSaveInstanceState() uses, not by the ViewModel instance itself — Saving UI State: Views vs. Compose covers SavedStateHandle in full.

Event What happens to a ViewModel

Rotation, dark/light mode toggle, font-scale change, fold/unfold, multi-window resize

The instance, its coroutines and all its ordinary fields survive untouched; only the recreated Activity/Fragment/composable re-observes it

Pressing Back out of the screen that owns it, or finish()

onCleared() runs once, then the instance and its ViewModelStore entry are discarded for good

System-initiated process death while stopped

The whole process disappears; a brand-new ViewModel instance is created the next time the screen is shown, restoring only whatever a SavedStateHandle had captured

onCleared() and addCloseable()

onCleared() is the one lifecycle callback a ViewModel gets, called exactly once, right before the instance is discarded for good (never for a configuration change). It is the place to release anything a ViewModel opened that nothing else will close on its behalf:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/RecordHikeViewModel.kt
class RecordHikeViewModel(
    private val locationClient: FusedLocationProviderClient,
) : ViewModel() {
    private val locationCallback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) { /* ... */ }
    }

    init {
        locationClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
    }

    override fun onCleared() {
        locationClient.removeLocationUpdates(locationCallback)
    }
}

viewModelScope (below) is cancelled automatically before onCleared() runs, so a coroutine launched in it never needs manual cancellation; onCleared() is only needed for resources viewModelScope does not already manage, such as the location callback above, or a Closeable resource. addCloseable(closeable) registers exactly that second case declaratively — the ViewModel base class calls close() on every registered Closeable automatically when it is cleared, without an onCleared() override at all:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailViewModel.kt
class TrailDetailViewModel(
    private val photoCache: TrailPhotoCache, // : Closeable
) : ViewModel() {
    init {
        addCloseable(photoCache)
    }
}

Lifecycle, DefaultLifecycleObserver and ProcessLifecycleOwner

Lifecycle is the observable state machine underneath every LifecycleOwner — an Activity, a Fragment (and separately its viewLifecycleOwner), a NavBackStackEntry. Its states (INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED) and how an Activity moves through them are covered in depth on Activities and the Activity Lifecycle. DefaultLifecycleObserver lets any object react to those transitions without inheriting from a lifecycle-owning class itself, as shown there with HikeLocationObserver.

ProcessLifecycleOwner applies the same idea one level up: a single Lifecycle for the whole application process, moving to STARTED/RESUMED when the first activity in the process comes to the foreground and to STOPPED only once every activity has gone to the background — unlike any individual activity’s own lifecycle, it is not affected by rotation or by navigating between TrailMate’s own screens:

// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
@HiltAndroidApp
class TrailMateApplication : Application(), DefaultLifecycleObserver {
    @Inject lateinit var analytics: AnalyticsClient

    override fun onCreate() {
        super<Application>.onCreate()
        ProcessLifecycleOwner.get().lifecycle.addObserver(this)
    }

    override fun onStart(owner: LifecycleOwner) = analytics.startSession()
    override fun onStop(owner: LifecycleOwner) = analytics.endSession()
}

This is the standard way to detect "the app moved to the foreground/background" as a single event — for an analytics session, or for pausing TrailSyncWorker’s foreground work — instead of trying to infer it by comparing `onStop/onStart calls across every activity the app might have. See Android Developers — Handling Lifecycles with Lifecycle-Aware Components and Android Developers — ProcessLifecycleOwner.

viewModelScope and lifecycleScope

Both are CoroutineScope properties that come pre-wired to cancel their coroutines automatically, so that launching a coroutine from a ViewModel or a LifecycleOwner never has to be paired with manual cancellation:

Scope Cancelled when Typical use

viewModelScope (on ViewModel)

onCleared() runs (real destruction, not a configuration change)

Loading data, calling a repository, anything that should keep running across a rotation

lifecycleScope (on any LifecycleOwner)

The owning Lifecycle reaches DESTROYED (an Activity’s `onDestroy, a fragment’s viewLifecycleOwner at onDestroyView)

One-off UI-only work genuinely tied to this view instance, not to the screen’s data

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
class TrailListViewModel(private val repository: TrailRepository) : ViewModel() {
    fun refresh() {
        viewModelScope.launch {
            repository.refresh()
        }
    }
}

Because viewModelScope survives a rotation and lifecycleScope on an activity does not, data-loading work belongs in the ViewModel, launched in viewModelScope, almost always — launching the same call directly from an Activity’s `lifecycleScope would restart it from scratch on every rotation. See Android Developers — Coroutines and Lifecycle-Aware Components and Android Developers — LifecycleOwner.lifecycleScope.

repeatOnLifecycle and flowWithLifecycle

A coroutine launched in lifecycleScope runs for the whole lifetime of the owner, which is too broad for collecting a Flow that drives the UI: collecting while the screen is stopped wastes work (and, for a Flow backed by a SharedFlow, can silently drop values a paused collector never asked to receive). repeatOnLifecycle solves this by restarting a coroutine block every time the lifecycle enters at least the given state, and cancelling it every time it drops below that state:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect { state -> adapter.submitList(state.trails) }
        }
    }
}

flowWithLifecycle(lifecycle, state) is the operator form of the same idea, useful when the collection needs to be chained with other Flow operators before a terminal collect:

viewModel.uiState
    .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
    .onEach { state -> adapter.submitList(state.trails) }
    .launchIn(viewLifecycleOwner.lifecycleScope)

Both stop collecting once the view (or activity) drops below STARTED — invisible, in the background — and resume from the beginning of the block when it comes back, which is exactly why STARTED (not RESUMED) is the state almost every UI collector should use: it keeps collecting while a dialog or another activity briefly covers the screen (RESUMED would stop there too, wastefully restarting collection on every partial cover). Lifecycle.State.RESUMED is reserved for work that genuinely must not run while anything else has focus, such as a camera preview. See Android Developers — Restarting a coroutine when the lifecycle is STARTED.

collectAsStateWithLifecycle

Compose’s own collectAsState() collects a Flow for as long as the composable is in the composition, with no regard for whether the screen is actually visible — a background composable (behind a NavHost entry that is merely paused, not disposed) keeps collecting regardless. collectAsStateWithLifecycle(), from androidx.lifecycle:lifecycle-runtime-compose, adds exactly the repeatOnLifecycle(STARTED) behavior above to a Compose collector, and is the version TrailMate uses everywhere a ViewModel’s `StateFlow reaches a composable:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListRoute(viewModel: TrailListViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    TrailListScreen(uiState = uiState, onTrailClick = viewModel::onTrailClicked)
}

There is essentially no reason to reach for plain collectAsState() on a ViewModel’s exposed `Flow in an app target; collectAsStateWithLifecycle() is the same one call and is always lifecycle-safe. See Android Developers — Lifecycle-aware Flow collection and State, Recomposition and Side Effects in Compose for the State/recomposition mechanics collectAsStateWithLifecycle() builds on.

Dispatchers and Main-Safety

Every coroutine launched in viewModelScope or lifecycleScope starts on Dispatchers.Main.immediate by default — deliberately, because it makes updating UI state the common case that needs no explicit dispatcher at all. A function is main-safe when it can be called from the main thread without blocking it, which for TrailMate’s TrailRepository means every suspend function switches internally to a background dispatcher for its own I/O rather than asking every caller to remember to:

// core/data/src/main/kotlin/com/example/trailmate/core/data/OfflineFirstTrailRepository.kt
class OfflineFirstTrailRepository(
    private val trailDao: TrailDao,
    private val trailApi: TrailApi,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : TrailRepository {
    override suspend fun refresh() = withContext(ioDispatcher) {
        val dtos = trailApi.getTrails()
        trailDao.upsertAll(dtos.map { it.toEntity() })
    }
}

Dispatchers.IO is for blocking I/O (network calls through Retrofit/OkHttp, Room’s own internally-dispatched queries, file access); Dispatchers.Default is for CPU-bound work (sorting or filtering a large list); plain Dispatchers.Main is for touching Views or other main-thread-only APIs directly, which a ViewModel almost never needs since its own scope is already on Main. Injecting the dispatcher as a constructor parameter, as above, rather than hardcoding Dispatchers.IO inline, is what lets a test substitute a deterministic test dispatcher — covered together with Dispatchers.setMain on Unit Testing with JUnit, Mockito, MockK and Robolectric. See Kotlin Documentation — Coroutines basics and Kotlin Documentation — Coroutines best practices for the dispatcher-injection convention in general.

StateFlow vs. SharedFlow vs. Channel

TrailMate’s `ViewModel`s expose state and events through three different Kotlin coroutines types, each suited to a different shape of data:

Type Has a current value? Replays to new collectors TrailMate use

StateFlow<T>

Yes — always holds exactly one value, read synchronously via .value

The current value, always (conflated: only the latest survives if no one is collecting)

TrailListUiState, TrailDetailUiState — anything a freshly-appearing screen should show immediately

SharedFlow<T> (replay = 0)

No

Nothing to a collector that starts after emission; a slow collector can miss values that arrive faster than it consumes them, unless buffered

Broadcasting an event to more than one collector, such as a global "session expired" signal

Channel<T>

No

Nothing — and, unlike SharedFlow, each value is delivered to exactly one receiver, never re-delivered

A one-off UI event meant to be handled exactly once, such as "navigate to trail detail" or "show a snackbar"

One-Off Events: Why Not Just Put Them in StateFlow?

Storing a one-off event (a snackbar message, a navigation request) directly as a field of a StateFlow-backed UI state seems simplest, but a StateFlow’s conflated, always-has-a-value nature works against it: the event replays to every new collector — including the very same collector reattaching after a configuration change — so a snackbar can reappear on rotation unless the `ViewModel remembers to clear the field back to null right after showing it, which is easy to forget and easy to race. TrailMate exposes one-off events through a Channel, consumed as a Flow via receiveAsFlow(), which delivers each event exactly once and never replays an already-consumed one:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailViewModel.kt
private val _events = Channel<TrailDetailEvent>(Channel.BUFFERED)
val events: Flow<TrailDetailEvent> = _events.receiveAsFlow()

sealed interface TrailDetailEvent {
    data object TrailDeleted : TrailDetailEvent
    data class ShowError(val message: String) : TrailDetailEvent
}

fun onDeleteConfirmed() {
    viewModelScope.launch {
        repository.delete(trailId)
        _events.send(TrailDetailEvent.TrailDeleted)
    }
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.events.collect { event ->
            when (event) {
                TrailDetailEvent.TrailDeleted -> findNavController().popBackStack()
                is TrailDetailEvent.ShowError -> showSnackbar(event.message)
            }
        }
    }
}

A SharedFlow with replay = 0 reaches for the same goal and is a legitimate alternative when more than one collector genuinely needs the same event (broadcasting to two independent parts of the UI); a Channel is the narrower, simpler tool when exactly one collector should ever see each event, which is the far more common case. See Android Developers — StateFlow and SharedFlow and Kotlin Documentation — Asynchronous Flow.

stateIn and shareIn

stateIn/shareIn convert a cold Flow (one that starts its work fresh for every collector, such as trailDao.observeTrails(), a Room Flow query) into a hot StateFlow/SharedFlow that all collectors share and that keeps running according to a chosen SharingStarted policy even between collectors:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
val uiState: StateFlow<TrailListUiState> = repository.observeTrails()
    .map { trails -> TrailListUiState(trails = trails, isLoading = false) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = TrailListUiState(isLoading = true),
    )

SharingStarted.WhileSubscribed(5_000) — the policy Recommended App Architecture (UI, Domain and Data Layers) already introduced — keeps the underlying Flow (and, transitively, the Room query or repository call behind it) running for five seconds after the last collector disappears before actually cancelling it, which absorbs the brief gap between onStop and the next onStart during a configuration change without restarting the whole query from scratch. SharingStarted.Eagerly starts collecting immediately and never stops on its own, appropriate for something that should always be warm regardless of collectors; SharingStarted.Lazily starts on the first collector and then never stops either, keeping the last value cached but not restarting eagerly. shareIn is the SharedFlow counterpart, used the same way when the converted flow should not conflate to a single "current value" the way stateIn does. See Android Developers — Use StateFlow in a ViewModel and Android Developers — Lifecycle-aware Flow collection.

LiveData as Legacy, with Interop

LiveData<T> predates StateFlow by several years and solves almost the same problem — an observable, lifecycle-aware holder of a current value — but as a purpose-built Android type rather than a general-purpose coroutines/Flow construct: it has no suspend-function equivalent, no operators beyond a handful (Transformations.map, .switchMap), and no way to express a SharedFlow-style event stream without the same replay problem StateFlow has. TrailMate’s Google-recommended architecture is fully Flow-based, and LiveData appears only where existing Views-era code has not migrated yet:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/legacy/LegacyTrailListViewModel.kt
class LegacyTrailListViewModel(repository: TrailRepository) : ViewModel() {
    val trails: LiveData<List<Trail>> = repository.observeTrails().asLiveData()
}

Flow.asLiveData() and LiveData<T>.asFlow(), both from androidx.lifecycle:lifecycle-livedata-ktx, convert in either direction, which is what lets a ViewModel be migrated to StateFlow internally while an not-yet-migrated Fragment keeps observing a LiveData at the call site, or vice versa, one layer at a time. LiveData is only ever observed with observe(viewLifecycleOwner, observer) — never observeForever outside a narrow, manually-managed case — for exactly the same lifecycle-safety reason repeatOnLifecycle exists for Flow. See Android Developers — LiveData Overview.

RxJava as Legacy, with a Migration Table

RxJava (Observable, Single, Flowable, Disposable) was, for years before Kotlin coroutines matured, the standard way to write asynchronous, composable Android code, and a large amount of production and open-source code — along with the books this section’s disclaimer refers to — still uses it. TrailMate’s own code is coroutines/Flow throughout; the table below exists to translate an RxJava-era codebase rather than to recommend adopting RxJava:

RxJava Coroutines / Flow equivalent

Single<T> / Maybe<T>

suspend fun returning T (or T? for Maybe)

Completable

suspend fun returning Unit

Observable<T> / Flowable<T>

Flow<T>

.subscribe { } / Disposable

.collect { } inside a coroutine; cancellation replaces manual dispose()

.map { }, .filter { }, .flatMap { }

Flow.map { }, Flow.filter { }, Flow.flatMapConcat { }/flatMapMerge { }/flatMapLatest { }

.debounce(), .distinctUntilChanged(), .throttleFirst()

Flow.debounce(), Flow.distinctUntilChanged(), Flow.sample() (closest equivalent, semantics differ slightly)

Schedulers.io() / .observeOn(AndroidSchedulers.mainThread())

withContext(Dispatchers.IO) / running on Dispatchers.Main (the default in viewModelScope)

BehaviorSubject<T>

MutableStateFlow<T>

PublishSubject<T>

MutableSharedFlow<T>(replay = 0) or a Channel<T>

CompositeDisposable

Structured concurrency: a CoroutineScope (viewModelScope, lifecycleScope) cancels everything launched in it, with nothing to add or clear by hand

The last row is the deepest difference: RxJava’s Disposable/CompositeDisposable pattern is a manual, opt-in discipline that a forgotten .addTo(compositeDisposable) silently defeats, while viewModelScope and `lifecycleScope’s structured concurrency cancels everything automatically by construction. RxJava remains a reasonable choice for a codebase already built on it, or for operators Flow’s standard library does not (yet) provide a direct equivalent for, but a new TrailMate feature is written in coroutines/Flow, not RxJava. See Kotlin Documentation — Asynchronous Flow and Android Developers — Kotlin coroutines on Android.

A Short History: AsyncTask and Loaders

Two older Android APIs solved a narrower version of the same "don’t block the main thread" problem that ViewModel plus coroutines now solves comprehensively, and both are deprecated:

  • AsyncTask (deprecated since API 30) ran a background computation and delivered its result on the main thread through onPostExecute(). It had no lifecycle awareness at all — a running AsyncTask that outlived its activity (a common bug, since it held an implicit reference to it) leaked the activity and could crash trying to update destroyed views; a suspend function launched from viewModelScope avoids both problems by construction.

  • Loader/LoaderManager (from the original Support Library, deprecated since Fragment 1.1.0 in favor of ViewModel plus LiveData) managed a background query’s lifecycle in step with an Activity/Fragment, reloading it automatically on a configuration change instead of restarting from scratch — the exact job stateIn(WhileSubscribed(…​)) on a ViewModel-scoped Flow now does far more flexibly.

Neither appears anywhere in current TrailMate code; they are mentioned here only because a reader coming from an older book or an existing legacy codebase will run into both, and the migration path from either one is the same ViewModel plus coroutines/Flow shape this page describes throughout.