jetpack-compose

v2026.09.24

Jetpack Compose for native Android UI. Covers @Composable functions, state, side effects, ViewModel + Compose, Hilt DI, Navigation Compose, Material 3 with Dynamic Color (Material You), Compose for Wear OS, Compose previews, and Android lifecycle integration (Activity, Fragment interop). USE WHEN: user mentions "Jetpack Compose", "@Composable" in Android-only context, "ViewModel", "Hilt", "Navigation Compose", "Material You", "Dynamic Color", "Compose preview", "AndroidView", "rememberLauncherForActivityResult", "Compose Wear OS" DO NOT USE FOR: Cross-platform Compose - use `frontend-frameworks/compose-multiplatform` DO NOT USE FOR: Kotlin language fundamentals - use `languages/kotlin` DO NOT USE FOR: Android non-UI APIs (Keystore, NFC, etc) - use `mobile/android-native`

GitHub
安装命令
npx skhub add claude-dev-suite/jetpack-compose
Markdown
SKILL.md

Jetpack Compose (Android)

References: state-effects.md for ViewModel + Compose, side-effect APIs, snapshot system. navigation.md for Navigation Compose 2.8+ with type-safe routes, deep links, multi-stack. interop.md for AndroidView/ComposeView interop, Activity Result Contracts, Fragment integration.

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: jetpack-compose.

Setup

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android") version "2.2.0"
    id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"   // required for Kotlin 2.x
    id("com.google.devtools.ksp")
    id("dagger.hilt.android.plugin")
}

android {
    buildFeatures { compose = true }
    composeOptions {
        // Compose Compiler is now part of Kotlin 2.x — no kotlinCompilerExtensionVersion needed
    }
}

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2025.01.00")
    implementation(composeBom)
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.material:material-icons-extended")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")

    implementation("androidx.activity:activity-compose:1.10.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
    implementation("androidx.navigation:navigation-compose:2.8.5")
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
    implementation("com.google.dagger:hilt-android:2.55")
    ksp("com.google.dagger:hilt-compiler:2.55")
}

Activity Entry Point

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()                         // draws under status/nav bars
        setContent {
            BhodlTheme {
                AppNavHost()
            }
        }
    }
}

enableEdgeToEdge() (Activity 1.8+) is the modern way to configure edge-to-edge — replaces WindowCompat.setDecorFitsSystemWindows(window, false).

ViewModel + StateFlow + Compose

@HiltViewModel
class WalletViewModel @Inject constructor(
    private val repo: WalletRepository,
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val walletId: String = savedStateHandle["walletId"] ?: error("missing walletId")

    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()

    init { load() }

    fun load() {
        viewModelScope.launch {
            _state.value = UiState.Loading
            runCatching { repo.getWallet(walletId) }
                .onSuccess { _state.value = UiState.Success(it) }
                .onFailure { _state.value = UiState.Error(it.message ?: "unknown") }
        }
    }
}

@Composable
fun WalletScreen(
    viewModel: WalletViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when (val s = state) {
        UiState.Loading -> CircularProgressIndicator()
        is UiState.Success -> WalletContent(s.wallet, onRefresh = viewModel::load)
        is UiState.Error -> ErrorView(s.message, onRetry = viewModel::load)
    }
}

collectAsStateWithLifecycle() (from lifecycle-runtime-compose) is preferred over collectAsState — automatically pauses collection when screen is in background, prevents wasted work and battery drain.

Material 3 + Dynamic Color (Material You)

@Composable
fun BhodlTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,                  // Material You on Android 12+
    content: @Composable () -> Unit,
) {
    val context = LocalContext.current
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        darkTheme -> DarkColors
        else -> LightColors
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = bhodlTypography,
        shapes = bhodlShapes,
        content = content,
    )
}

For status/nav bar tinting in edge-to-edge:

val view = LocalView.current
if (!view.isInEditMode) {
    SideEffect {
        val window = (view.context as Activity).window
        WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
    }
}

Navigation Compose (Type-Safe Routes — 2.8+)

@Serializable
data object HomeRoute

@Serializable
data class WalletDetailRoute(val walletId: String)

@Composable
fun AppNavHost() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = HomeRoute,
    ) {
        composable<HomeRoute> {
            HomeScreen(
                onWalletClick = { id -> navController.navigate(WalletDetailRoute(id)) },
            )
        }
        composable<WalletDetailRoute> { backStackEntry ->
            val route: WalletDetailRoute = backStackEntry.toRoute()
            WalletDetailScreen(
                walletId = route.walletId,
                onBack = { navController.popBackStack() },
            )
        }
    }
}

Type-safe routes (since Navigation Compose 2.8) replace string-based routes — no more typo-driven crashes.

For nested graphs:

@Serializable data object SettingsGraph
@Serializable data object ProfileSettingsRoute

NavHost(navController = nav, startDestination = HomeRoute) {
    navigation<SettingsGraph>(startDestination = ProfileSettingsRoute) {
        composable<ProfileSettingsRoute> { ProfileSettings() }
        composable<NotificationSettingsRoute> { NotificationSettings() }
    }
}

See navigation.md for deep linking, multi-stack bottom nav, dialog/bottom-sheet destinations.

Side Effects (Android-specific)

APIUse case
LaunchedEffect(key)Suspending work, cancels on key change
DisposableEffect(key)Setup with cleanup (lifecycle observer, BroadcastReceiver)
LifecycleEventEffect(event)React to specific Lifecycle events (ON_RESUME, ON_PAUSE)
LifecycleResumeEffect / LifecycleStartEffectLifecycle-scoped effect with auto-pause
rememberLauncherForActivityResultPermissions, file picker, take photo
BackHandler { }Intercept system back button
@Composable
fun CameraScreen() {
    val context = LocalContext.current

    val cameraPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
        onResult = { granted -> /* ... */ },
    )

    LaunchedEffect(Unit) {
        cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
    }
}

@Composable
fun ProcessLifecycleObserver(onResume: () -> Unit) {
    LifecycleResumeEffect(Unit) {
        onResume()
        onPauseOrDispose { /* cleanup */ }
    }
}

@Composable
fun ConfirmExit(onExit: () -> Unit) {
    BackHandler { onExit() }
}

Hilt + Compose

// Module
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides @Singleton
    fun provideWalletRepository(api: WalletApi, db: AppDatabase): WalletRepository =
        WalletRepositoryImpl(api, db)
}

// ViewModel
@HiltViewModel
class WalletViewModel @Inject constructor(
    private val repo: WalletRepository,
) : ViewModel() { /* ... */ }

// Composable injection
@Composable
fun WalletScreen(viewModel: WalletViewModel = hiltViewModel()) { /* ... */ }

For nested NavHost with Hilt-scoped ViewModel:

@Composable
fun NestedScreen(navBackStackEntry: NavBackStackEntry) {
    val viewModel: NestedViewModel = hiltViewModel(navBackStackEntry)
    /* ViewModel scoped to this nav destination */
}

Common UI Patterns

Scaffold with TopAppBar

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletDetailScreen(walletId: String, onBack: () -> Unit) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Wallet") },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
                    }
                },
                actions = {
                    IconButton(onClick = { /* share */ }) {
                        Icon(Icons.Default.Share, "Share")
                    }
                },
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { /* send */ }) {
                Icon(Icons.Default.Send, "Send")
            }
        },
    ) { padding ->
        Column(Modifier.padding(padding)) { /* content */ }
    }
}

LazyColumn with key

@Composable
fun TransactionList(transactions: List<Transaction>, onClick: (String) -> Unit) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        items(
            items = transactions,
            key = { it.id },                          // stable identity
            contentType = { it.type },                // recycle composables by type
        ) { tx ->
            TransactionItem(tx, onClick = { onClick(tx.id) })
        }
    }
}

Pull to Refresh

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletList(viewModel: WalletViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val isRefreshing = state is UiState.Loading

    PullToRefreshBox(
        isRefreshing = isRefreshing,
        onRefresh = viewModel::refresh,
    ) {
        LazyColumn { /* ... */ }
    }
}

Compose Previews

@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Phone", device = Devices.PIXEL_7, showSystemUi = true)
@Composable
fun WalletItemPreview() {
    BhodlTheme {
        WalletItem(
            wallet = Wallet(id = "1", name = "Main", balance = 100_000),
            onClick = {},
        )
    }
}

// Multi-preview annotation
annotation class ThemeAndFontScalePreviews
@ThemeAndFontScalePreviews
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large Font", fontScale = 1.5f)
@Preview(name = "Small Font", fontScale = 0.8f)
@Composable
fun MyPreviews() { /* ... */ }

For interactive previews: tap "Interactive Mode" in Android Studio. For animation previews: tap "Animation Inspector".

Dialogs and Bottom Sheets

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SendBottomSheet(onDismiss: () -> Unit, onSend: (String, Long) -> Unit) {
    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
    val scope = rememberCoroutineScope()

    ModalBottomSheet(
        onDismissRequest = onDismiss,
        sheetState = sheetState,
    ) {
        SendForm(onSend = { addr, amt ->
            scope.launch { sheetState.hide() }
                .invokeOnCompletion {
                    if (!sheetState.isVisible) onDismiss()
                    onSend(addr, amt)
                }
        })
    }
}

@Composable
fun ConfirmDialog(title: String, text: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = { Text(text) },
        confirmButton = { TextButton(onClick = onConfirm) { Text("Confirm") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
    )
}

Permissions

Use Accompanist Permissions (or roll your own with rememberLauncherForActivityResult):

implementation("com.google.accompanist:accompanist-permissions:0.36.0")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraPermissionGate(content: @Composable () -> Unit) {
    val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)

    when {
        cameraPermission.status.isGranted -> content()
        cameraPermission.status.shouldShowRationale -> RationaleScreen(
            onRequest = { cameraPermission.launchPermissionRequest() }
        )
        else -> RequestScreen(
            onRequest = { cameraPermission.launchPermissionRequest() }
        )
    }
}

Performance

Stability Annotations

@Immutable
data class WalletUiState(
    val wallets: List<Wallet>,
    val balance: Long,
)

@Stable
interface WalletActions {
    fun onSend()
    fun onReceive()
}

@Immutable → all fields immutable, treat as stable. @Stable → mutable but predictable equality. Both help Compose skip recomposition.

Compose Compiler Metrics

// app/build.gradle.kts
kotlinOptions {
    freeCompilerArgs += listOf(
        "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
              project.layout.buildDirectory.dir("compose-reports").get().asFile.absolutePath,
        "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
              project.layout.buildDirectory.dir("compose-reports").get().asFile.absolutePath,
    )
}

Generates classes/composables stability reports. Identify which classes are unstable and fix.

Baseline Profiles

Compose apps benefit massively from baseline profiles. Generate with androidx.benchmark:

./gradlew :baselineprofile:generateBaselineProfile

Drops cold start time 20-40%.

Anti-Patterns (Android-specific)

Anti-patternWhy it's badCorrect approach
collectAsState() instead of collectAsStateWithLifecycle()Wastes battery in backgroundAlways use WithLifecycle variant
LaunchedEffect(true) { } or LaunchedEffect(Unit) { } everywhereRuns once but easy to misuseUse proper key (id, viewModel ref)
var viewModel: ViewModel field in composableLost on recompviewModels() in Activity, hiltViewModel() in composable
Heavy work in init { } of ViewModelBlocks UI on creationDefer to viewModelScope.launch
Forgetting enableEdgeToEdge() + Modifier.systemBarsPadding()UI clipped by system barsUse enableEdgeToEdge() + WindowInsets.safeDrawing
String-based nav routesTypo crashesUse @Serializable route classes (2.8+)
mutableStateOf<MyClass>(...) for unstable typesCompose can't skip recompMark @Immutable or use derivedStateOf
AndroidView everywhere instead of pure ComposeBypasses optimizationsOnly for missing widgets (MapView, WebView)
remember { mutableStateOf(...) } for ViewModel stateLost on rotationHoist to ViewModel + collectAsState
LocalContext.current.startActivity(intent)Hard to testUse rememberLauncherForActivityResult
BroadcastReceiver registered in composable without DisposableEffectLeakAlways register/unregister via DisposableEffect

Testing

// androidTest
class WalletScreenTest {
    @get:Rule val rule = createAndroidComposeRule<MainActivity>()

    @Test fun showsWalletsWhenLoaded() {
        rule.setContent {
            BhodlTheme {
                WalletScreen(viewModel = fakeLoadedViewModel())
            }
        }
        rule.onNodeWithText("Main wallet").assertExists()
        rule.onNodeWithContentDescription("Send").performClick()
    }

    @Test fun navigatesToDetail() {
        // setup with TestNavHostController
    }
}

For unit tests of Composables: use Robolectric + createComposeRule(). For visual regression: Paparazzi (Square) — JVM-based, fast, no emulator needed. For Android instrumented snapshot: Roborazzi — runs on emulator.

Compose for Wear OS

implementation("androidx.wear.compose:compose-material3:1.5.0")
implementation("androidx.wear.compose:compose-foundation:1.5.0")
implementation("androidx.wear.compose:compose-navigation:1.5.0")
@Composable
fun WearApp() {
    MaterialTheme {
        ScalingLazyColumn(
            modifier = Modifier.fillMaxSize(),
        ) {
            item { Chip(label = { Text("Send") }, onClick = { /* ... */ }) }
            item { Chip(label = { Text("Receive") }, onClick = { /* ... */ }) }
        }
    }
}

Wear UI uses different components (Chip, ScalingLazyColumn, TimeText) but same Compose paradigm.

When NOT to Use This Skill

ScenarioUse Instead
Cross-platform UI (Android + iOS + Desktop)frontend-frameworks/compose-multiplatform
Pure Kotlin language patternslanguages/kotlin
Android Keystore, Biometric, NFC, sensorsmobile/android-native
KMP module setupmobile/kotlin-multiplatform
iOS UI workmobile/ios-native
Detailed nav patternsnavigation.md
Activity Result Contracts, AndroidView interopinterop.md
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/mobile/jetpack-compose

默认分支

main

最新提交

9496306

Tree SHA

fe4e2f1