Views, Fragments and Activities vs. Compose: Architecture Comparison

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.

By 2026, Jetpack Compose is Google’s recommended toolkit for new Android UI, and the Views/Fragments/Activities stack that preceded it is now consistently described as the classic, still-supported alternative rather than the default. Both toolkits remain fully maintained — Compose is built on top of the same Activity, Lifecycle and ViewModel foundations Views uses, not a replacement for them — and most non-trivial apps, including large existing codebases, run a mix of both for years. This page puts the two stacks side by side, feature by feature, so a team can reason concretely about where they differ, where a mixed codebase is a reasonable long-term state rather than a waypoint, and where Compose is a clear win. It targets Android 17 (API level 37), Compose BOM 2026.09.00, Fragment 1.9.1, Lifecycle 2.11.0 and Navigation 2.10.2 / Navigation 3 1.2.0, and uses the same TrailMate hiking-trail app used throughout this section, built once in each stack.

This page assumes the individual building blocks of each stack are already familiar from earlier pages: Activities and the Activity Lifecycle, Fragments and the Fragment Lifecycle, Views, XML Layouts, View Binding and Material Components and RecyclerView and Lists for the classic stack; Jetpack Compose Fundamentals, Compose Layouts, Modifiers and Lazy Lists and State, Recomposition and Side Effects in Compose for Compose; and Recommended App Architecture (UI, Domain and Data Layers) and ViewModel, Lifecycle-Aware Components, Coroutines and Flow for the shared architecture underneath both. Two closely related pages complete the trio this page belongs to: Saving UI State: Views vs. Compose gives the full, dedicated comparison of what survives a configuration change, process death or user dismissal in each stack — this page only summarizes that topic — and Migrating from Views to Compose gives the phased, before/after migration path for a codebase that wants to move from one stack to the other.

How to Read This Comparison

Each section below states what a concept means in each stack, gives a short table, and calls out the one or two things teams most often get wrong when moving between them. TrailMate names are consistent with the rest of the section: Trail, Difficulty, TrailListUiState, TrailListViewModel, TrailRepository, and the two parallel UI entry points, MainActivity : AppCompatActivity (Views, hosting NavHostFragment) and MainActivity : ComponentActivity (Compose, hosting TrailMateApp()). Both versions of TrailMate share the same TrailListViewModel, exposing StateFlow<TrailListUiState> and an onToggleFavorite(id: String) method — the architecture layer underneath the UI does not change based on which toolkit renders it, which is exactly why a mixed-stack app is viable: the UI layer is the only layer that differs.

UI Definition

Views / Fragments / Activities Compose

Where the UI lives

XML layout resource (res/layout/fragment_trail_list.xml) parsed and inflated at runtime into a tree of View objects, plus Kotlin code that looks the tree up and mutates it.

A tree of @Composable Kotlin functions. There is no separate markup file; the function is the UI description, and Kotlin’s type system checks it at compile time.

Composition unit

A View/ViewGroup subclass, one instance per screen element, retained for the screen’s lifetime.

A composable function invocation, which may or may not correspond to a retained object — the Compose runtime decides whether to keep, move or discard the underlying LayoutNode.

Reuse

Custom <include>, <merge>, and custom View subclasses; styles and themes for shared attributes.

Ordinary function composition and default parameters — a shared row is just a function called from several screens, with no XML inheritance model to reason about.

Tooling to author it

The Layout Editor’s WYSIWYG canvas and Blueprint view, or hand-written XML.

@Preview composables rendered by Android Studio’s Compose Preview, or the interactive/live-edit modes; there is no drag-and-drop canvas.

TrailMate’s trail row illustrates the difference directly. The Views version is a resource plus a binding class generated from it:

<!-- feature/trails/src/main/res/layout/item_trail.xml -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/trailPhoto"
        android:layout_width="64dp"
        android:layout_height="64dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/trailName"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textAppearance="?attr/textAppearanceTitleMedium"
        app:layout_constraintStart_toEndOf="@id/trailPhoto"
        app:layout_constraintEnd_toStartOf="@id/favoriteButton"
        app:layout_constraintTop_toTopOf="@id/trailPhoto" />

    <ImageButton
        android:id="@+id/favoriteButton"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:background="?attr/selectableItemBackgroundBorderless"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

The Compose version is a function, with no separate resource file at all:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailCard.kt
@Composable
fun TrailCard(
    trail: Trail,
    onToggleFavorite: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        AsyncImage(
            model = trail.photoUrl,
            contentDescription = null,
            modifier = Modifier.size(64.dp),
        )
        Text(
            text = trail.name,
            style = MaterialTheme.typography.titleMedium,
            modifier = Modifier.weight(1f).padding(start = 16.dp),
        )
        IconButton(onClick = { onToggleFavorite(trail.id) }) {
            Icon(
                imageVector = if (trail.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
                contentDescription = if (trail.isFavorite) "Remove from favorites" else "Add to favorites",
            )
        }
    }
}

Update Model: Imperative Mutation vs. Recomposition

Views / Fragments / Activities Compose

How the screen changes

Code imperatively finds a View (findViewById, or a generated binding property) and mutates one of its properties: textView.text = newName, imageView.setImageDrawable(…​).

A composable re-reads the state it depends on and re-executes ("recomposes"); the runtime diffs what changed and only touches the underlying layout nodes that actually differ.

Who tracks "what changed"

The programmer, by hand, one property at a time. Forgetting to update a View after its backing state changes is a common, silent bug.

The Compose runtime’s snapshot system, via State<T> reads recorded during composition. A composable that never reads a piece of state simply never recomposes when it changes.

Granularity

Whatever the programmer chose to touch: a single property, or an entire subtree via notifyDataSetChanged().

Individual composables, addressed by position and, for lists, by key. TrailCard for one trail recomposes independently of the other rows in the list.

Failure mode when done carelessly

Stale UI (state changed, view was not touched) or excessive redraws (notifyDataSetChanged() blowing away `DiffUtil’s targeted updates).

Excessive recomposition from unstable parameter types, unnecessarily broad state reads, or lambdas that are recreated every recomposition; see State, Recomposition and Side Effects in Compose for the stability rules that prevent this.

The favorite toggle makes the contrast concrete. In the Views version, toggling a favorite means calling notifyItemChanged(position) (or, with ListAdapter, submitting a new list so DiffUtil computes a payload) so TrailViewHolder.bind() re-runs and imperatively flips the icon:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailViewHolder.kt
class TrailViewHolder(private val binding: ItemTrailBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(trail: Trail, onToggleFavorite: (String) -> Unit) {
        binding.trailName.text = trail.name
        binding.favoriteButton.setImageResource(
            if (trail.isFavorite) R.drawable.ic_favorite_filled else R.drawable.ic_favorite_border,
        )
        binding.favoriteButton.setOnClickListener { onToggleFavorite(trail.id) }
    }
}

In Compose, TrailCard above already reads trail.isFavorite directly; there is no separate "update" step to write, because the whole function is re-run with the new Trail value and the icon swap follows automatically from the if expression. See Android Developers — Thinking in Compose.

Lifecycles

Views / Fragments / Activities Compose

Lifecycle owners in play

Three separate, nested lifecycles: the Activity’s, each hosted `Fragment’s, and each fragment’s view lifecycle (`viewLifecycleOwner), which is shorter-lived than the fragment itself.

Only the Activity (or NavBackStackEntry) lifecycle exists as a framework-level LifecycleOwner; composables do not have their own Lifecycle object. Compose has its own, additional notion of composition lifetime (entering/leaving composition) that is orthogonal to Lifecycle.

What "leaves the screen" means

A Fragment’s view can be destroyed and recreated (`onDestroyView/onCreateView) while the fragment instance and its ViewModel survive — the single most common source of Fragment memory leaks when a listener or binding reference outlives the view.

A composable "leaves composition" when its call site is no longer reached (e.g. because a condition became false, or a list item scrolled out of a lazy list without being kept alive), which triggers DisposableEffect cleanup. There is no separate "view" object to null out.

Common lifecycle bug

Setting binding = FragmentTrailListBinding.bind(…​) in onCreateView and forgetting to set it to null in onDestroyView, leaking the fragment view through the binding field.

Collecting a Flow with lifecycleScope.launch instead of repeatOnLifecycle/collectAsStateWithLifecycle, which keeps collecting (and doing work) while the screen is stopped.

Where it is covered in depth

Activities and the Activity Lifecycle, Fragments and the Fragment Lifecycle

Jetpack Compose Fundamentals (composition/recomposition), ViewModel, Lifecycle-Aware Components, Coroutines and Flow (collectAsStateWithLifecycle)

State Persistence (Summary)

Both stacks lose in-memory state on the same three events — a configuration change, system-initiated process death, and the user dismissing the screen — and both rely on the same underlying mechanisms (ViewModel for configuration changes, a Bundle for the other two) underneath a different API surface. The table below is a short pointer, not the full comparison:

State kind Views mechanism Compose mechanism

Scroll position

RecyclerView’s automatic `onSaveInstanceState() (via View.onSaveInstanceState, requires android:id)

rememberSaveable wrapping LazyListState via its built-in Saver

Text input

EditText.freezesText / automatic View state saving

rememberSaveable around TextFieldState or remember { mutableStateOf("") } promoted to rememberSaveable

Screen UI state (filters, search query)

ViewModel field, or onSaveInstanceState Bundle entries restored into the ViewModel

SavedStateHandle inside the ViewModel, exposed as part of TrailListUiState

Navigation back stack

The `FragmentManager’s own saved state, restored automatically with the activity

Navigation Compose/NavController saves its back stack in a Bundle automatically; Navigation 3 uses an entry decorator (rememberSaveableStateHolderNavEntryDecorator)

Business data (trail list contents)

Persistent storage (Room), not instance state, since a Bundle is too small and too slow for this

Persistent storage (Room), for the same reason — identical to Views

The full comparison — the state-loss events table, BaseSavedState for custom Views, Saver/listSaver/ mapSaver, Bundle size limits, testing process death, and worked TrailMate examples in both stacks — lives on Saving UI State: Views vs. Compose; this page does not repeat it.

Navigation and the Back Stack

Views (Navigation Component) Compose (Navigation Compose / Navigation 3)

Graph definition

nav_graph.xml, a declarative resource, edited visually or by hand, processed by the Safe Args Gradle plugin.

Kotlin code: NavHost { composable<TrailDetail> { …​ } } (Navigation Compose) or an app-owned MutableList<NavKey> rendered by NavDisplay (Navigation 3). No XML resource.

Destinations

Fragment instances, hosted inside a single NavHostFragment.

Composable functions, hosted inside the same Activity; there may be no Fragment in the app at all.

Type safety of arguments

Safe Args generates typed Directions/Args classes from the XML graph.

@Serializable route classes are the argument type directly; there is no code generation step.

Who owns the back stack

The FragmentManager, coordinated by NavController.

Navigation Compose: still NavController, backed by FragmentManager-free internal state. Navigation 3: the app itself, as a plain, observable list the app can inspect and mutate (rememberNavBackStack).

Cross-links

Navigation with Fragments (Navigation Component, Drawer, Bottom Nav, Tabs)

Navigation in Compose (Navigation Compose and Navigation 3)

Lists

RecyclerView LazyColumn / LazyRow / LazyVerticalGrid

Core pieces

Adapter, ViewHolder, LayoutManager, ItemDecoration, ItemAnimator — five collaborating classes.

A single composable call with a lambda content block; layout, recycling and item animation are built in.

Diffing changed items

DiffUtil.ItemCallback compares old/new lists explicitly, driven through ListAdapter.submitList().

Structural equality plus a key lambda; animateItem() opts an item into automatic move/appear/disappear animations without a separate ItemAnimator.

View recycling

Explicit ViewHolder recycling, one View subtree reused across many data items.

Recycling of the underlying `LayoutNode`s happens automatically inside the lazy layout; the programmer never manages it directly.

Mixed item types

Multiple viewType values from getItemViewType(), or ConcatAdapter to compose independent adapters.

A when branch inside the items lambda, or several separate item/items calls in the same LazyColumn, each with its own contentType.

Paging

PagingDataAdapter from Paging 3.

LazyPagingItems via collectAsLazyPagingItems(), from the same Paging 3 library.

Cross-links

RecyclerView and Lists

Compose Layouts, Modifiers and Lazy Lists

Theming and Animation

Views Compose

Theme definition

XML style resources (styles.xml, themes.xml), inherited through parent="…​" chains, applied per Activity/View via android:theme or the manifest.

A Kotlin MaterialTheme(colorScheme, typography, shapes) { content() } wrapper composable (TrailMateTheme), read anywhere below it via MaterialTheme.colorScheme etc.

Design system

Material Components for Android (MDC): MaterialButton, MaterialCardView, and matching XML attributes.

Material 3 for Compose: Button, Card, Scaffold, NavigationBar, driven by the same MaterialTheme.

Dynamic/dark theme

DynamicColors.applyToActivitiesIfAvailable(), -night resource qualifiers.

dynamicLightColorScheme()/dynamicDarkColorScheme() plus isSystemInDarkTheme().

Animation APIs

ViewPropertyAnimator, ObjectAnimator/ValueAnimator/AnimatorSet, AnimatedVectorDrawable, MotionLayout, shared-element transitions via the Transitions framework.

animate*AsState, AnimatedVisibility, AnimatedContent, updateTransition, Animatable, Compose shared elements — see Compose Animation and Gestures.

Cross-links

Views, XML Layouts, View Binding and Material Components, Custom Views, Touch, Drawing and View Animation

Material 3 and Theming in Compose, Compose Animation and Gestures

Dependency on Context

Views Compose

How pervasive is Context

Nearly every constructor — every View subclass, LayoutInflater, resource lookups, system services — takes a Context as its first argument.

Composables generally do not take a Context parameter; resources are read through composable helper functions (stringResource, painterResource) that resolve Context internally.

Getting a Context when one is genuinely needed

Already in scope: this in an Activity, requireContext() in a Fragment.

LocalContext.current, a CompositionLocal, used sparingly — e.g. to start a system Intent, show a Toast, or construct a platform object like MediaPlayer.

Common mistake

Holding an Activity Context in a long-lived field (a singleton, a static holder), leaking the entire Activity and its View tree.

Reading LocalContext.current inside a remember { } block and holding onto it across recompositions/screen rotations without re-reading it, which reintroduces the same leak Compose otherwise avoids by default.

Testing

Views Compose

Instrumented UI tests

Espresso: onView(withId(…​)).perform(click()).check(matches(…​)), matching Views by id, text or content description.

Compose testing APIs: composeTestRule.onNodeWithText("…​").performClick().assertIsDisplayed(), matching by semantics (text, content description, test tags) rather than a View hierarchy.

JVM-only (no device/emulator) UI tests

Robolectric, running real Activity/Fragment/View code against a simulated Android framework.

createComposeRule() inside a plain JVM/Robolectric test, since Compose’s test APIs do not themselves require an instrumented device for most assertions.

Fragment-scoped testing

FragmentScenario.launchInContainer<TrailListFragment>(), isolating one fragment.

Not applicable in a fragment-free Compose screen; composeTestRule.setContent { TrailListScreen(…​) } plays the equivalent role.

ViewModel/unit tests

Identical in both stacks: TrailListViewModelTest exercises TrailListViewModel directly with Turbine/ runTest, independent of any UI framework.

Identical in both stacks (see previous cell).

Cross-links

Unit Testing with JUnit, Mockito, MockK and Robolectric, Instrumented and UI Testing (Espresso, Compose Testing, UI Automator)

Same two pages

Tooling

Views Compose

Visual editing

The Layout Editor: a WYSIWYG canvas plus a Blueprint/constraint view, driving the same XML a text editor would.

@Preview composables rendered statically in the editor gutter; interactive mode and Live Edit let a composable be poked and iterated on without a full rebuild, but there is no drag-and-drop canvas.

Runtime inspection

Layout Inspector shows the live View hierarchy, attributes, and (with GPU rendering) an approximate 3D exploded view.

The same Layout Inspector understands the Compose semantics tree too, showing composable names, parameters and recomposition counts alongside any Views in a mixed screen.

Compile-time checking of the UI itself

None — a typo in an XML attribute or a dangling @id/ reference fails at build or inflation time, not before.

Full Kotlin compiler checking, since the UI description is itself Kotlin code.

Multi-configuration preview

Manually switching the Layout Editor’s device/orientation/locale/night-mode dropdowns one at a time.

@PreviewParameterProvider and multipreview annotations render many configurations (devices, font scales, locales, dark mode) from one annotated function, in one build.

Performance: Inflation, Recomposition and Baseline Profiles

Views Compose

Cost of creating a screen the first time

XML inflation: parsing and reflectively instantiating a View tree from a compiled binary XML resource. Deep or heavily nested ConstraintLayout/LinearLayout hierarchies inflate and measure/layout more slowly.

No inflation step; the first composition builds `LayoutNode`s directly from already-compiled Kotlin bytecode. Deep composable nesting is generally cheaper than the equivalent View nesting, but very large single composables can still be slow to skip if they are unstable.

Cost of updating the screen

Whatever the programmer chose to touch, which can be as cheap as one property write or as expensive as a full notifyDataSetChanged().

Recomposition of just the composables whose state inputs changed, provided those composables are skippable (stable parameter types) — see State, Recomposition and Side Effects in Compose for what breaks skippability.

Startup optimization

Baseline Profiles pre-compile hot XML-inflation and layout code paths ahead of time via Macrobenchmark-recorded profiles.

The same Baseline Profiles mechanism applies to Compose’s composition/layout/draw code paths; Compose benefits at least as much from a profile as Views does, since more of its work happens through generic runtime code paths that the JIT would otherwise need to warm up on every fresh install.

Common performance pitfall

Overdraw from deeply nested ViewGroup`s with overlapping backgrounds; unnecessary `requestLayout() calls.

Reading unstable state too high in the tree, causing large subtrees to recompose; unremembered lambdas recreated on every recomposition, defeating skipping in child composables.

How to measure

Layout Inspector, GPU rendering profile, Choreographer frame timing, Macrobenchmark.

Layout Inspector’s recomposition counts, the Compose compiler’s stability reports, Macrobenchmark — the same benchmarking tool works for both stacks.

Startup Time and APK Size

Views Compose

App startup contribution

No extra runtime library beyond AppCompat/AndroidX; startup cost is dominated by Application/Activity initialization and the first layout’s inflation.

Adds the Compose runtime and UI libraries to the classpath and to cold-start class loading; in practice the difference is small next to app-specific initialization (DI graph construction, first network/DB calls) and is further reduced by R8/Baseline Profiles.

APK/AAB size contribution

Smaller Compose-related footprint (none), but XML layout resources and any Data Binding-generated classes add their own size.

The Compose runtime, UI, foundation and Material 3 artifacts add several megabytes before R8 shrinking; R8 with resource shrinking (default in AGP 9) removes the large majority of unused code from either stack.

Practical takeaway

Neither difference is normally the dominant factor in a real app’s startup time or install size; both are measured with Macrobenchmark and the Play Console’s app size report rather than assumed from first principles.

Interop

Views and Compose can render inside one another in the same screen, which is what makes gradual, screen-by-screen migration realistic rather than an all-or-nothing rewrite:

Direction Mechanism

Compose inside a View hierarchy

ComposeView, added to an XML layout or created programmatically, with a setViewCompositionStrategy chosen to match its host’s lifetime (typically DisposeOnViewTreeLifecycleDestroyed inside a Fragment).

A View inside a composable

AndroidView(factory = { context → …​ }), or AndroidViewBinding when a whole ViewBinding-backed layout should be embedded as-is.

Shared ViewModel across the boundary

The same activityViewModels()/hiltViewModel() call resolves to the same activity-scoped ViewModel instance whether the caller is a Fragment or a composable, so a screen mid-migration can share state safely.

See Android Developers — Compose and View interoperability APIs. The full phased path, including exact ViewCompositionStrategy choices and worked TrailMate examples, is on Migrating from Views to Compose.

Learning Curve and Maturity

Views Compose

Age and API stability

Present since Android 1.0; the API surface is large, accreted over 15+ years, and includes many now-legacy layers (see below) alongside current best practice.

First stable release in 2021; the core APIs (layout, state, navigation) are stable, while some areas —  Material 3 Expressive, some adaptive-layout APIs — are still marked experimental at this baseline.

What a newcomer must learn first

The View/ViewGroup measure-layout-draw model, XML resource system, Fragment lifecycle nuances, and an imperative mental model for "what changes when".

A declarative, functional mental model (state in, UI out), composition/recomposition, and enough Kotlin to be comfortable with trailing lambdas and property delegates — see Kotlin for Android.

Ecosystem maturity (third-party libraries, Stack Overflow answers, existing production code)

Very mature; almost every Android app older than a few years is built this way, at least in part.

Rapidly maturing; the great majority of new Google guidance, samples and codelabs assume Compose, and most actively developed libraries now ship a Compose-first API (Coil, Navigation, CameraX previews, Paging).

Where legacy techniques from older material fit in

Books and tutorials predating roughly 2020 teach AsyncTask, Loaders, IntentService, Kotlin synthetics, Data Binding, and ListView as current practice; this section documents each only as legacy, with its modern replacement, per ViewModel, Lifecycle-Aware Components, Coroutines and Flow and RecyclerView and Lists.

Not applicable — everything Compose-related in this section postdates its 2021 stable release.

When Views Are Still the Right Choice

Compose being the recommended default for new UI does not make Views the wrong choice in every situation. Two cases come up repeatedly in practice:

Large existing codebases. A mature app with millions of lines of View-based UI, an established design system built on MDC, and a team fluent in that stack gains little from a wholesale rewrite, and a rewrite carries real risk: regressions in screens that already work, a long stretch where two UI systems must be kept visually consistent, and a large QA surface to re-cover. The pragmatic default for such a codebase is exactly what Migrating from Views to Compose describes: build new screens and new features in Compose, embed them into the existing View hierarchy with ComposeView, and migrate existing screens only when there is a concrete reason to touch them anyway (a redesign, a bug that is easier to fix after conversion, a screen that is about to gain complex animation). There is no deadline by which an app "must" finish migrating; Views remain a first-class, fully supported toolkit indefinitely.

Surface-based and SDK-provided View`s — check for a Compose wrapper first. Some Android APIs hand the caller an `android.view.Surface or a SurfaceHolder to draw into directly, rather than expecting onDraw(Canvas) calls, and some SDKs ship only a View. Several of the common cases now have first-party Compose APIs, so an AndroidView wrapper is only needed where none exists:

View type Compose option

com.google.android.gms.maps.MapView

Google’s maps-compose library (com.google.maps.android:maps-compose) provides a GoogleMap composable, with markers, polylines and camera state as composables and state holders. It wraps MapView internally, so a Compose screen no longer needs its own AndroidView for a map.

SurfaceView

AndroidExternalSurface (in androidx.compose.foundation) is a composable backed by a SurfaceView: it hands the app a Surface in an onSurface callback for low-latency, off-main-thread rendering (a custom renderer, or a video decoder’s output).

TextureView

AndroidEmbeddedExternalSurface is the TextureView-backed counterpart: the surface behaves like ordinary content for transforms, clipping, animation and blending, at a small composition cost.

GLTextureView and other custom surface `View`s

A custom View subclass that owns its own rendering machinery — such as the author’s real com.irurueta:irurueta-android-glutils library’s GLTextureView (an OpenGL ES-capable TextureView with its own EGL context and GL thread; see Graphics: Canvas, OpenGL ES and GLTextureView) — has no Compose wrapper, and stays a View embedded with AndroidView.

A View with no Compose wrapper is embedded with AndroidView, with lifecycle callbacks forwarded manually since Compose has no lifecycle hooks of its own that map onto Surface creation/destruction:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/HikeGlPreview.kt
@Composable
fun HikeGlPreview(modifier: Modifier = Modifier) {
    AndroidView(
        modifier = modifier.fillMaxWidth().height(240.dp),
        factory = { context ->
            GLTextureView(context).apply {
                setEGLContextClientVersion(2)
                setRenderer(HikeTrackRenderer())
            }
        },
        onRelease = { view -> view.onPause() },
    )
}

See Android Developers — Compose and View interoperability APIs and GitHub — irurueta-android-glutils. For a map or a plain SurfaceView/TextureView, prefer maps-compose and AndroidExternalSurface/ AndroidEmbeddedExternalSurface; for a View like GLTextureView that has no wrapper, AndroidView is the documented approach — not a workaround to be migrated away from later. See Google for Developers — Maps Compose library and Android Developers — androidx.compose.foundation (AndroidExternalSurface, AndroidEmbeddedExternalSurface).

Side-by-Side: The Same Feature in Both Stacks

To make every row of the tables above concrete at once, this section implements one complete feature — a trail list where tapping a heart icon toggles that trail’s favorite status — fully in both stacks. Both versions share the same TrailListViewModel and TrailRepository; only the UI layer differs, which is the point.

Two parallel stacks implementing the same TrailMate trail-list-with-favorite feature. Left column, labeled Views/Fragments/Activities: MainActivity (AppCompatActivity) hosts TrailListFragment, which holds a RecyclerView with a TrailAdapter, whose ItemTrailBinding (XML plus ViewBinding) imperatively binds each Trail; a click listener calls viewModel.onToggleFavorite(id), and the result is applied by calling notifyItemChanged on one row. Right column, labeled Jetpack Compose: MainActivity (ComponentActivity) hosts TrailListScreen, which renders a LazyColumn of TrailCard composables; an IconButton’s onClick calls the same onToggleFavorite(id), and the result is applied by recomposing only the affected TrailCard from a new TrailListUiState. Both columns converge at the bottom on one shared TrailListViewModel exposing StateFlow of TrailListUiState and onToggleFavorite(), which calls TrailRepository.setFavorite(trailId, isFavorite), which updates Room and re-emits a Flow that both UIs observe identically.

Shared: TrailListViewModel and the Repository Call

Neither UI layer talks to Room or the network directly; both call the same ViewModel method, which delegates to the same repository, exactly as Recommended App Architecture (UI, Domain and Data Layers) describes:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
@HiltViewModel
class TrailListViewModel @Inject constructor(
    private val repository: TrailRepository,
) : ViewModel() {

    // TrailListUiState is the sealed interface defined on the App Architecture page.
    val uiState: StateFlow<TrailListUiState> = repository.trails
        .map<List<Trail>, TrailListUiState> { TrailListUiState.Success(trails = it, onlyFavorites = false) }
        .catch { emit(TrailListUiState.Error(it.message.orEmpty())) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TrailListUiState.Loading)

    fun onToggleFavorite(trailId: String) {
        val trail = (uiState.value as? TrailListUiState.Success)?.trails?.firstOrNull { it.id == trailId } ?: return
        viewModelScope.launch { repository.setFavorite(trailId, isFavorite = !trail.isFavorite) }
    }
}

Views Version: TrailListFragment + RecyclerView

<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/trailRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:listitem="@layout/item_trail" />
</FrameLayout>
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailAdapter.kt
class TrailAdapter(
    private val onToggleFavorite: (String) -> Unit,
) : ListAdapter<Trail, TrailViewHolder>(TrailDiffCallback) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrailViewHolder {
        val binding = ItemTrailBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return TrailViewHolder(binding)
    }

    override fun onBindViewHolder(holder: TrailViewHolder, position: Int) {
        holder.bind(getItem(position), onToggleFavorite)
    }
}

object TrailDiffCallback : DiffUtil.ItemCallback<Trail>() {
    override fun areItemsTheSame(oldItem: Trail, newItem: Trail) = oldItem.id == newItem.id
    override fun areContentsTheSame(oldItem: Trail, newItem: Trail) = oldItem == newItem
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
@AndroidEntryPoint
class TrailListFragment : Fragment(R.layout.fragment_trail_list) {
    private val viewModel: TrailListViewModel by viewModels()
    private var binding: FragmentTrailListBinding? = null
    private lateinit var adapter: TrailAdapter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        binding = FragmentTrailListBinding.bind(view)
        adapter = TrailAdapter(onToggleFavorite = viewModel::onToggleFavorite)
        binding?.trailRecyclerView?.apply {
            layoutManager = LinearLayoutManager(requireContext())
            adapter = this@TrailListFragment.adapter
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    if (state is TrailListUiState.Success) {
                        adapter.submitList(state.trails)
                    }
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        binding = null
    }
}

Ten collaborating pieces are visible here: an XML layout, an item XML layout, a generated ItemTrailBinding, a TrailViewHolder, a DiffUtil.ItemCallback, a ListAdapter, a LinearLayoutManager, a Fragment, its view lifecycle handling, and the nullable binding field’s teardown in onDestroyView. None of them is optional — skipping the binding = null step leaks the fragment’s view; skipping viewLifecycleOwner in favor of this collects the Flow for longer than the view exists. The @AndroidEntryPoint annotation is not optional either: TrailListViewModel is a @HiltViewModel, and by viewModels() only receives Hilt’s ViewModel factory from a fragment annotated with @AndroidEntryPoint (hosted by an activity that is also @AndroidEntryPoint). Without it the default factory tries to call a no-argument constructor that does not exist, and the fragment crashes at runtime the first time viewModel is read. See Android Developers — Hilt and Jetpack, ViewModel with Hilt.

Compose Version: TrailListScreen + LazyColumn

// 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, onToggleFavorite = viewModel::onToggleFavorite)
}

@Composable
fun TrailListScreen(
    uiState: TrailListUiState,
    onToggleFavorite: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    when (uiState) {
        is TrailListUiState.Loading -> CircularProgressIndicator(modifier = modifier)
        is TrailListUiState.Error -> Text(uiState.message, modifier = modifier)
        is TrailListUiState.Success -> {
            LazyColumn(modifier = modifier.fillMaxSize()) {
                items(items = uiState.trails, key = { it.id }) { trail ->
                    TrailCard(
                        trail = trail,
                        onToggleFavorite = onToggleFavorite,
                        modifier = Modifier.animateItem(),
                    )
                }
            }
        }
    }
}

The same feature is three collaborating pieces here: one ViewModel (shared with the Views version, unchanged), TrailCard (shown under UI Definition above), and TrailListScreen itself. There is no generated binding class, no separate item layout resource, no ViewHolder, no DiffUtil.ItemCallback to write by hand (structural equality of the Trail data class plus the key lambda does the equivalent job), and no fragment lifecycle to manage — collectAsStateWithLifecycle() already stops collecting when the screen is stopped, the Compose equivalent of the Views version’s explicit repeatOnLifecycle block.

What Stayed the Same, and What Did Not

Both versions: use the same Trail domain type, the same TrailListViewModel and TrailRepository, the same onToggleFavorite(id: String) call, the same Room-backed persistence underneath, and the same Hilt injection (@HiltViewModel/hiltViewModel() vs. @HiltViewModel/by viewModels()). Both versions differ in: how many files and classes exist (six across two resource files and four Kotlin files for Views, versus two Kotlin files for Compose, not counting the shared TrailCard), how updates propagate (imperative notifyItemChanged vs. automatic recomposition), and how much of the correctness burden falls on hand-written code (DiffUtil, nulling binding, wiring a LayoutManager) versus the framework (structural equality, automatic recycling, automatic lifecycle-aware collection). Neither version is "wrong" — an app already built the first way loses nothing by staying that way for existing screens, which is exactly the argument made under [When Views Are Still the Right Choice] above.

References