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 rawcollectAsState()
Guardrails
Composable Conventions
- •Every composable accepts
modifier: Modifier = Modifieras its first optional parameter - •Keep composables small and focused (one responsibility per function)
- •Use
@Previewon every reusable component with representative data - •Never perform side effects (network, DB, analytics) directly inside a composable body
- •Use
LaunchedEffect,DisposableEffect, orSideEffectfor side-effect work - •Prefer stateless composables; hoist state to the caller or ViewModel
kotlin
// 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
StateFlowin ViewModel, collect withcollectAsStateWithLifecycle() - •Model UI state as a sealed interface (
Loading,Success,Error) - •Never expose
MutableStateFlowpublicly from a ViewModel - •Use
rememberfor local composable state;rememberSaveablewhen it must survive config changes - •Use
derivedStateOffor expensive computations that depend on other state
kotlin
// 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
popUpToandinclusivewhen navigating to auth screens - •Use
hiltViewModel()insidecomposable {}blocks for scoped ViewModels
kotlin
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
MaterialThemecomposable - •Support dynamic color on Android 12+ with
dynamicLightColorScheme/dynamicDarkColorScheme - •Define color tokens in a dedicated
Color.kt; typography inType.kt; theme inTheme.kt - •Use
MaterialTheme.colorScheme.*andMaterialTheme.typography.*instead of hardcoded values - •Support both light and dark themes from day one
kotlin
@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
Applicationwith@HiltAndroidApp,Activitywith@AndroidEntryPoint - •Annotate ViewModels with
@HiltViewModeland 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 |
kotlin
// 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
code
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
kotlin
// 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
StateFlowassertions - •Use MockK with
coEvery/coVerifyfor suspend function mocking - •Reset dispatcher in
@AfterwithDispatchers.resetMain()
kotlin
@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()
kotlin
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
bash
# 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
LaunchedEffectfor one-shot side effects,DisposableEffectfor cleanup - •Keep composables stateless; hoist state to ViewModel or caller
- •Use sealed interfaces for UI state with exhaustive
whenexpressions - •Provide
contentDescriptionon all interactive and decorative icons - •Use
Modifierchaining; acceptmodifierparameter in every public composable - •Preview every reusable component with
@Preview
Avoid
- •Performing I/O, network calls, or analytics tracking directly in composable bodies
- •Exposing
MutableStateFloworMutableStatefrom 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
rememberSaveablewhen 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