notifyDataSetChanged()` and start declaring state transformations.
Core Solution
Jetpack Compose operates as a state-driven rendering engine. The implementation strategy must align with unidirectional data flow (UDF), compile-time stability, and lifecycle-aware state management.
Step 1: Architecture Foundation
Compose does not replace ViewModel or StateFlow. It consumes them. The UI layer becomes a pure function of state:
UI = f(State)
State flows downward. Events flow upward. Side effects are isolated in LaunchedEffect or DisposableEffect. This eliminates bidirectional binding and ensures predictable recomposition.
Step 2: State Hoisting & Stability
Never store UI state inside a composable that doesn't own it. Hoist state to the nearest common ancestor or ViewModel. Use remember for composables that own transient UI state, and rememberSaveable for configuration changes.
Mark parameters as stable when possible. The Compose compiler skips recomposition for parameters annotated with @Stable or implementing @Immutable. Unstable parameters force recomposition on every parent call.
Step 3: Implementation Example
Below is a production-grade pattern for a screen displaying a paginated list with loading state and error handling.
ViewModel Layer:
class ProductViewModel @Inject constructor(
private val repository: ProductRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductUiState())
val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
init { loadProducts() }
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val products = repository.fetchProducts(page = 1)
_uiState.update { it.copy(products = products, isLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}
data class ProductUiState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
Composable Layer:
@Composable
fun ProductScreen(
viewModel: ProductViewModel = hiltViewModel(),
onProductClick: (Product) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> LoadingIndicator()
uiState.error != null -> ErrorView(message = uiState.error, onRetry = { viewModel.loadProducts() })
else -> ProductList(
products = uiState.products,
onProductClick = onProductClick
)
}
}
@Composable
fun ProductList(
products: List<Product>,
onProductClick: (Product) -> Unit
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(products, key = { it.id }) { product ->
ProductCard(
product = product,
onClick = { onProductClick(product) }
)
}
}
}
Step 4: Navigation & Lifecycle Integration
Use androidx.navigation:navigation-compose. Compose navigation respects the back stack and integrates with ViewModel scoping. Scope ViewModel to the navigation graph to preserve state across destination transitions without leaking.
NavHost(navController, startDestination = "products") {
composable("products") {
ProductScreen(
viewModel = hiltViewModel(), // Scoped to back stack entry
onProductClick = { navController.navigate("detail/${it.id}") }
)
}
composable("detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id") ?: return@composable
ProductDetailScreen(productId = id)
}
}
Architecture Rationale
- UDF Enforcement: Compose's declarative nature breaks when state mutates outside the render cycle.
StateFlow + collectAsStateWithLifecycle ensures recomposition only occurs when the UI is visible, preventing background recomposition storms.
- Stability Contracts:
@Stable on data classes and key parameters in LazyColumn/LazyRow enable compiler-level optimization. Without them, every parent recomposition cascades down.
- Side Effect Isolation:
LaunchedEffect runs only when keys change. DisposableEffect handles cleanup. This prevents memory leaks and duplicate network calls during configuration changes.
Pitfall Guide
1. Recomposition Storms from Unstable Parameters
Passing List<T> or custom objects without @Stable forces the compiler to assume parameters changed on every call. Result: full subtree recomposition.
Fix: Annotate data classes with @Stable or @Immutable. Use structural equality or explicit keys in lazy lists.
2. Ignoring State Hoisting
Storing scroll position, text input, or toggle state inside deeply nested composables breaks predictability and breaks state restoration.
Fix: Hoist to the nearest owner. Pass state down, pass events up. Use rememberSaveable only for UI-owned transient state.
3. Misusing remember Without Keys
remember { heavyObject() } caches across recompositions but not across configuration changes or key changes. If the object depends on parameters, it becomes stale.
Fix: Provide keys: remember(param1, param2) { ... }. Use derivedStateOf for computed values that depend on frequently changing state.
4. Blocking the Main Thread in Composables
Calling runBlocking or synchronous network/disk I/O inside a @Composable function freezes the UI thread. Compose functions must be side-effect free.
Fix: Move I/O to ViewModel or use LaunchedEffect(Unit) { asyncLoad() }. Never perform synchronous work in the composition phase.
5. Overriding Dynamic Color/Theme Incorrectly
Manually setting Color values instead of using MaterialTheme.colorScheme breaks dark mode, dynamic color (Android 12+), and accessibility contrast.
Fix: Always reference MaterialTheme.colorScheme.*. Use dynamicColorTheme for Android 12+ devices. Test with high contrast and font scaling.
6. Treating @Composable Like Regular Functions
Assuming @Composable functions run once, maintain local variables across calls, or support traditional lifecycle callbacks. They don't. They can be called multiple times per frame, skipped, or reordered.
Fix: Never use var for state. Use remember or StateFlow. Use LaunchedEffect/DisposableEffect for side effects. Assume idempotency.
7. Navigation Scope Misalignment
Scoping ViewModel to the Activity instead of the navigation graph causes state loss when navigating between destinations, or state leakage when reusing destinations.
Fix: Use hiltViewModel() or viewModel() without explicit scope to bind to the back stack entry. Use hiltViewModel(navBackStackEntry) when sharing state across a graph.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Greenfield mobile app | Full Compose + Hilt + Navigation Compose | Zero legacy debt, maximizes compiler optimization, reduces UI LOC by ~40% | Lower long-term maintenance, faster feature velocity |
| Legacy XML app with 50+ screens | Incremental migration via ComposeView in Fragments | Preserves existing navigation/lifecycle, reduces risk, allows team training | Moderate initial overhead, high risk mitigation |
| High-frequency animation/game UI | Compose + Canvas + Animatable with derivedStateOf | Compose's animation APIs outperform ViewPropertyAnimator for complex state-driven motion | Slightly higher CPU usage, but 30% smoother frame pacing |
| Team with limited Kotlin experience | Compose + ViewModel + StateFlow + strict lint rules | Enforces UDF, prevents common recomposition bugs, aligns with modern Android standards | Requires 2-3 week ramp-up, reduces long-term bug density |
Configuration Template
build.gradle.kts (app module):
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("androidx.baselineprofile")
}
android {
compileSdk = 34
defaultConfig {
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-opt-in=kotlin.RequiresOptIn",
"-Xskip-prerelease-check"
)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
}
proguard-rules.pro (Compose-specific):
-keep class androidx.compose.runtime.** { *; }
-keep class androidx.compose.ui.** { *; }
-keep class androidx.compose.material3.** { *; }
-keepattributes *Annotation*, InnerClasses, Synthetic
-keep class **/*Composable* { *; }
Quick Start Guide
- Create Project: Open Android Studio Iguana+, select "Empty Compose Activity", set minimum SDK to 24, and enable Kotlin 1.9+.
- Sync & Verify: Run
./gradlew assembleDebug. Confirm the default Greeting composable renders without errors.
- Add State Management: Replace
Greeting with a StateFlow-backed screen. Inject ViewModel via Hilt or viewModel(). Collect state using collectAsStateWithLifecycle().
- Enable Compiler Metrics: Add
-Pandroidx.compose.compiler.metrics=VERBOSE to gradle.properties. Run ./gradlew assembleDebug and inspect build/compose_metrics/ for stability reports.
- Deploy: Run on physical device or emulator. Verify dark mode, orientation change, and back navigation. Profile first-frame render using Macrobenchmark.
Jetpack Compose is not a UI toolkit upgrade. It is an architectural mandate. Teams that align state management, stability contracts, and side-effect isolation with its runtime model eliminate entire categories of UI bugs. Teams that treat it as XML replacement will fight the compiler. The difference is measurable in lines of code, frame pacing, and developer velocity. Implement it as a state-driven system, and the platform delivers what the View system never could: predictable, scalable, declarative UI.