Android Jetpack Compose Guide
Applies to: Android SDK 24+, Jetpack Compose 1.5+, Kotlin 1.9+, Material Design 3
Core Principles
-
Declarative UI: Describe what the UI should look like, not how to build it step-by-step
-
Unidirectional Data Flow: State flows down, events flow up -- always
-
Composable Functions Are Cheap: The framework handles when to recompose; keep composables pure
-
State Hoisting: Lift state to the nearest common ancestor that needs it
-
Lifecycle Awareness: Collect flows with collectAsStateWithLifecycle() , never raw collectAsState()
Guardrails
Composable Conventions
-
Every composable accepts modifier: Modifier = Modifier as its first optional parameter
-
Keep composables small and focused (one responsibility per function)
-
Use @Preview on every reusable component with representative data
-
Never perform side effects (network, DB, analytics) directly inside a composable body
-
Use LaunchedEffect , DisposableEffect , or SideEffect for side-effect work
-
Prefer stateless composables; hoist state to the caller or ViewModel
// BAD: side effects in composition @Composable fun UserList(users: List<User>) { analytics.trackScreenView("user_list") // fires on every recomposition LazyColumn { ... } }
// GOOD: side effect in LaunchedEffect @Composable fun UserList(users: List<User>) { LaunchedEffect(Unit) { analytics.trackScreenView("user_list") } LazyColumn { ... } }
State Management
-
Use StateFlow in ViewModel, collect with collectAsStateWithLifecycle()
-
Model UI state as a sealed interface (Loading , Success , Error )
-
Never expose MutableStateFlow publicly from a ViewModel
-
Use remember for local composable state; rememberSaveable when it must survive config changes
-
Use derivedStateOf for expensive computations that depend on other state
// Sealed UI state pattern sealed interface HomeUiState { data object Loading : HomeUiState data class Success(val users: List<User>) : HomeUiState data class Error(val message: String) : HomeUiState }
// ViewModel exposes immutable StateFlow @HiltViewModel class HomeViewModel @Inject constructor( private val userRepository: UserRepository, ) : ViewModel() { private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading) val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
_uiState.value = HomeUiState.Loading
userRepository.getUsers()
.catch { e -> _uiState.value = HomeUiState.Error(e.message ?: "Unknown error") }
.collect { users -> _uiState.value = HomeUiState.Success(users) }
}
}
}
// Screen collects lifecycle-aware @Composable fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() when (val state = uiState) { is HomeUiState.Loading -> LoadingIndicator() is HomeUiState.Success -> UserList(state.users) is HomeUiState.Error -> ErrorMessage(state.message, onRetry = viewModel::loadUsers) } }
Navigation
-
Define routes as a sealed class or sealed interface for type safety
-
Never pass complex objects as navigation arguments; pass IDs and load from ViewModel
-
Clear back stack correctly with popUpTo and inclusive when navigating to auth screens
-
Use hiltViewModel() inside composable {} blocks for scoped ViewModels
sealed class Screen(val route: String) { data object Login : Screen("login") data object Home : Screen("home") data object Detail : Screen("detail/{userId}") { fun createRoute(userId: String) = "detail/$userId" } }
@Composable fun AppNavigation() { val navController = rememberNavController() NavHost(navController = navController, startDestination = Screen.Home.route) { composable(Screen.Home.route) { HomeScreen(onUserClick = { userId -> navController.navigate(Screen.Detail.createRoute(userId)) }) } composable( route = Screen.Detail.route, arguments = listOf(navArgument("userId") { type = NavType.StringType }), ) { backStackEntry -> val userId = backStackEntry.arguments?.getString("userId") ?: "" DetailScreen(userId = userId, onBack = { navController.popBackStack() }) } } }
Material 3 Theming
-
Always wrap the app root in your custom MaterialTheme composable
-
Support dynamic color on Android 12+ with dynamicLightColorScheme / dynamicDarkColorScheme
-
Define color tokens in a dedicated Color.kt ; typography in Type.kt ; theme in Theme.kt
-
Use MaterialTheme.colorScheme.* and MaterialTheme.typography.* instead of hardcoded values
-
Support both light and dark themes from day one
@Composable fun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } darkTheme -> DarkColorScheme else -> LightColorScheme } MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) }
Dependency Injection (Hilt)
-
Annotate Application with @HiltAndroidApp , Activity with @AndroidEntryPoint
-
Annotate ViewModels with @HiltViewModel and use @Inject constructor
-
Use hiltViewModel() in composables, never construct ViewModels manually
-
Organize DI modules by layer: AppModule , NetworkModule , DatabaseModule
Lifecycle & Effects
Effect Trigger Use for
LaunchedEffect(key)
On enter + when key changes One-shot loads, navigation events
DisposableEffect(key)
On enter + when key changes (with cleanup) Listeners, observers, callbacks
SideEffect
After every successful recomposition Syncing compose state to non-compose
rememberCoroutineScope()
Manual launch from callbacks Button clicks launching coroutines
// One-shot load on screen enter LaunchedEffect(Unit) { viewModel.loadData() }
// Clean up a listener when leaving composition DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> ... } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } }
Project Structure
app/src/main/java/com/example/myapp/ ├── MyApplication.kt # @HiltAndroidApp ├── MainActivity.kt # @AndroidEntryPoint, setContent {} ├── navigation/ │ └── AppNavigation.kt # NavHost, route definitions ├── ui/ │ ├── theme/ # Color.kt, Type.kt, Theme.kt │ ├── screens/ # Feature screens │ │ ├── home/ │ │ │ ├── HomeScreen.kt # Composable │ │ │ └── HomeViewModel.kt # ViewModel + UiState │ │ └── detail/ │ │ ├── DetailScreen.kt │ │ └── DetailViewModel.kt │ └── components/ # Shared composables │ ├── LoadingIndicator.kt │ └── ErrorMessage.kt ├── data/ │ ├── model/ # Domain models (@Serializable) │ ├── remote/ # Retrofit service, DTOs │ ├── local/ # Room database, DAOs │ └── repository/ # Repository implementations └── di/ # Hilt modules ├── AppModule.kt └── NetworkModule.kt
- ui/screens/: One sub-package per feature, each with Screen.kt
- ViewModel.kt
-
ui/components/: Reusable composables shared across screens
-
data/: Models, repositories, API services; no Compose dependencies here
-
di/: Hilt modules providing singletons and scoped dependencies
Key Dependencies
// build.gradle.kts (app) val composeBom = platform("androidx.compose:compose-bom:2024.01.00") implementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("com.google.dagger:hilt-android:2.50") ksp("com.google.dagger:hilt-compiler:2.50") implementation("io.coil-kt:coil-compose:2.5.0")
// Testing testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.9") testImplementation("app.cash.turbine:turbine:1.0.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") androidTestImplementation(composeBom) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest")
Testing
ViewModel Unit Tests
- Use StandardTestDispatcher
- Dispatchers.setMain() for coroutine control
-
Use Turbine for StateFlow assertions
-
Use MockK with coEvery / coVerify for suspend function mocking
-
Reset dispatcher in @After with Dispatchers.resetMain()
@OptIn(ExperimentalCoroutinesApi::class) class HomeViewModelTest { private val testDispatcher = StandardTestDispatcher() private val userRepository = mockk<UserRepository>()
@Before fun setup() { Dispatchers.setMain(testDispatcher) }
@After fun tearDown() { Dispatchers.resetMain() }
@Test
fun `loadUsers emits Success state on valid data`() = runTest {
val users = listOf(User(id = "1", email = "a@b.com", name = "Alice"))
coEvery { userRepository.getUsers() } returns flowOf(users)
val viewModel = HomeViewModel(userRepository)
advanceUntilIdle()
viewModel.uiState.test {
val state = awaitItem()
assertTrue(state is HomeUiState.Success)
assertEquals(users, (state as HomeUiState.Success).users)
}
}
}
Compose UI Tests
-
Use createComposeRule() for isolated composable tests
-
Query nodes with onNodeWithText , onNodeWithTag , onNodeWithContentDescription
-
Assert with assertExists() , assertIsDisplayed() , assertIsEnabled()
-
Perform actions with performClick() , performTextInput()
class HomeScreenTest { @get:Rule val composeTestRule = createComposeRule()
@Test
fun displays_user_name_when_loaded() {
composeTestRule.setContent {
MyAppTheme {
UserCard(user = User("1", "a@b.com", "Alice"), onClick = {}, onDeleteClick = {})
}
}
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("a@b.com").assertIsDisplayed()
}
}
Commands
Build
./gradlew assembleDebug # Debug APK ./gradlew assembleRelease # Release APK ./gradlew bundleRelease # AAB for Play Store
Test
./gradlew test # Unit tests ./gradlew connectedAndroidTest # Instrumented tests on device/emulator
Quality
./gradlew lint # Android Lint ./gradlew ktlintCheck # Code style check ./gradlew ktlintFormat # Auto-fix formatting ./gradlew detekt # Static analysis
Install & Run
./gradlew installDebug # Install on connected device ./gradlew clean # Clean build artifacts
Best Practices Summary
Do
-
Use collectAsStateWithLifecycle() for all Flow collection in composables
-
Use remember {} for expensive local computations
-
Use LaunchedEffect for one-shot side effects, DisposableEffect for cleanup
-
Keep composables stateless; hoist state to ViewModel or caller
-
Use sealed interfaces for UI state with exhaustive when expressions
-
Provide contentDescription on all interactive and decorative icons
-
Use Modifier chaining; accept modifier parameter in every public composable
-
Preview every reusable component with @Preview
Avoid
-
Performing I/O, network calls, or analytics tracking directly in composable bodies
-
Exposing MutableStateFlow or MutableState from ViewModels
-
Using !! operator in production code
-
Hardcoding colors, dimensions, or strings (use theme tokens and resources)
-
Creating ViewModels manually (use hiltViewModel() )
-
Passing complex objects through navigation arguments (pass IDs instead)
-
Ignoring configuration changes (use rememberSaveable when needed)
References
For detailed UI patterns, animation, testing strategies, performance, and accessibility guidance, see:
- references/patterns.md -- UI composition patterns, animation, performance, accessibility, testing recipes
External References
-
Jetpack Compose Documentation
-
Compose API Guidelines
-
Material Design 3 for Compose
-
Navigation in Compose
-
Side-effects in Compose
-
Compose Testing
-
Hilt with Compose
-
Coil Image Loading