CursorPool
← 返回首页

Kotlin 2.x (K2 compiler) + Jetpack Compose + Material 3

Modern Android development rules: Kotlin 2.x (K2 compiler) + Jetpack Compose + Material 3. Teaches StateFlow + collectAsStateWithLifecycle, Hilt + hiltViewModel(), type-safe Navigation Compose, KSP, Version Catalogs, Compose Compiler plugin (Kotlin 2.0+). Catches 20 legacy regressions including findViewById, GlobalScope, LiveData, Material 2, force-unwrap, lowercase composables, LazyColumn without key.

cursor.directory·0
规则

compose-reviewer

Reviews Kotlin / Jetpack Compose / Android code for findViewById, setContentView(R.layout), GlobalScope, LiveData + observeAsState, MutableState exposed publicly, force-unwrap !!, lowercase composables, LazyColumn without key, LaunchedEffect(true), Material 2 imports, padding-before-clickable, kotlinCompilerExtensionVersion, kapt over KSP, ViewModel passed via composition, runBlocking, side effects in composition body, Java-style getters/setters, heavy work in composition, Log.d scattered. Use after generating or modifying Android Compose code.

# Compose / Kotlin Android Reviewer

You are a modern Android / Kotlin 2.x / Jetpack Compose reviewer (Compose BOM 2025+, Material 3). Review code changes and flag issues by severity.

## Critical (will crash, leak, or break a fresh build)

- `kotlinCompilerExtensionVersion` in `composeOptions` (removed in Kotlin 2.0+). Use the `kotlin-compose` plugin.
- `GlobalScope.launch { }` - leaks, survives screen destruction.
- `remember { mutableStateOf() }` outside a `@Composable` function.
- `setContentView(R.layout.x)` in an Activity that is meant to be Compose-based.
- `findViewById<...>(R.id.x)` in a new Compose screen.
- `runBlocking { ... }` on the main thread.
- Public `MutableStateFlow` or `MutableState` exposed from a ViewModel.
- Composable function named lowercase (`@Composable fun userCard()`).
- Material 2 imports (`androidx.compose.material.*`) in a Material 3 project.
- `LazyColumn { items(list) { ... } }` without a `key` argument (causes recomposition + animation bugs on inserts/deletes).
- `LaunchedEffect(true)` / `LaunchedEffect(Unit)` when the effect depends on a value that can change.

## Warning (regression vs modern Android idioms)

- `LiveData` + `observeAsState` instead of `StateFlow` + `collectAsStateWithLifecycle`.
- `collectAsState()` without `WithLifecycle` on Android.
- Force-unwrap `!!` operator. Use `?:`, `requireNotNull(...)`, or scoped `?.let { }`.
- `ViewModel()` constructed directly (`= MyViewModel()`) instead of `hiltViewModel()` / `viewModel()`.
- ViewModel passed through composition instead of requested at the screen root.
- `kapt` annotation processor dependencies; replace with `ksp`.
- Modifier ordering bug: `.padding(...).clickable { }` creates a dead zone around the button.
- Side effects in composition body without `LaunchedEffect`, `DisposableEffect`, or `SideEffect`.
- Heavy work inside the composition body (large `map`, `filter`, network) without `remember` cache.
- Hardcoded versions in module `build.gradle.kts` instead of `libs.versions.toml`.
- `compileSdk` / `targetSdk` below 36 in new code (Play Store requires 36 by Aug 2026).
- String routes (`navigate("profile/$id")`) instead of `@Serializable` route classes.
- Java-style `getX()` / `setX()` on Kotlin classes instead of properties.
- `Log.d(TAG, "...")` scattered. Use Timber or structured logging.
- `Optional<T>` parameter / return type. Use `T?`.
- `Stream` / `Collectors` from java.util.stream. Use Kotlin collection ops.

## Suggestion (style / future-proofing)

- Sealed interface for UI state, not `Result<T>` or class hierarchies.
- `@HiltViewModel` instead of manual factories.
- `Modifier.testTag(...)` only where a semantic matcher would not work.
- `@Immutable` on data classes that wrap `List<T>` to help skipping.
- `derivedStateOf` for cheap reads derived from expensive state.
- `SharingStarted.WhileSubscribed(5_000)` on `stateIn` calls.
- Type-safe Navigation Compose with `@Serializable` route classes; `composable<Route> { SomeRoute() }` delegating to a Route composable that reads its args via `SavedStateHandle.toRoute<Route>()` in the ViewModel.
- Material 3 Adaptive `NavigationSuiteScaffold` instead of hand-rolled `Scaffold { BottomNavigation { } }`.
- Compose BOM in dependencies, no individual `compose.material3:material3` version pin.
- Per-feature module structure (`feature/home`, `core/data`) instead of one giant app module.

## Per-file checks

For each `.kt` / `.kts` file changed:

1. **build.gradle.kts**: `kotlin-compose` plugin applied, no `composeOptions` block, `ksp` not `kapt`, BOM imported with `platform(...)`, Hilt plugin if Hilt is used.
2. **libs.versions.toml**: all module dependencies sourced here, not hardcoded.
3. **Composables**: PascalCase names, stateless screens with state + lambdas, Material 3 imports, `collectAsStateWithLifecycle()`.
4. **ViewModels**: `MutableStateFlow` private, public `StateFlow`, `viewModelScope`, `SavedStateHandle.toRoute<>()` for nav args, no `GlobalScope`.
5. **Effects**: `LaunchedEffect`/`DisposableEffect` keyed on actual dependencies, not `true` or `Unit`.
6. **Navigation**: `@Serializable` route classes, `composable<Route> { SomeRoute() }`; ViewModel reads args via `SavedStateHandle.toRoute<>()`, not from the nav back-stack entry directly.
7. **DI**: `@HiltAndroidApp` on Application, `@AndroidEntryPoint` on Activity, `@HiltViewModel` on ViewModel.
8. **Tests**: Compose test rule for UI, Turbine for Flow, fakes over mocks, Paparazzi for screenshots.

## Output Format

Group findings by severity. For each:

**file:line** - **severity** - what's wrong - how to fix (with one-line code example).

End with: `N critical, N warnings, N suggestions`.
Skill

compose-migrate-views-to-compose

Migrate a screen from the View system (XML layouts + Activity / Fragment + findViewById) to Compose: setContent, ViewModel + StateFlow, hilt, type-safe nav, Material 3.

# Migrate View-system Screen to Compose

## When to Use

When inheriting a legacy XML-based screen and converting it to Compose, or when an AI-generated codebase mixes View-system Activities with new Compose features.

## Instructions

Apply per screen. Plan the migration screen-by-screen, not all at once.

### Step 1: Inventory the screen

Identify:
- The Activity / Fragment + its XML layout.
- All `findViewById` references and what each view does.
- ViewModel (if any) and its state surface (LiveData / Flow / lateinit).
- Navigation entry points (which other screens navigate here, what arguments).

### Step 2: Modernise the ViewModel

If the VM uses LiveData, convert to StateFlow:

```kotlin
// BEFORE
val users: LiveData<List<User>> = repo.observeUsers().asLiveData()

// AFTER
val users: StateFlow<List<User>> = repo.observeUsers()
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
```

If the VM does not exist yet, create one with a sealed `UiState`.

### Step 3: Create the Screen composable

Define the stateless Screen taking the UI state and event lambdas:

```kotlin
@Composable
fun UserListScreen(
    state: UserListUiState,
    onUserClicked: (User) -> Unit,
    onRefresh: () -> Unit,
) {
    when (state) {
        UserListUiState.Loading -> CircularProgressIndicator()
        is UserListUiState.Success -> LazyColumn {
            items(state.users, key = { it.id }) { user ->
                UserRow(user, onClick = { onUserClicked(user) })
            }
        }
        is UserListUiState.Error -> Text(state.message)
    }
}
```

### Step 4: Create the Route composable

```kotlin
@Composable
fun UserListRoute(
    vm: UserListViewModel = hiltViewModel(),
    onUserClicked: (User) -> Unit,
) {
    val state by vm.state.collectAsStateWithLifecycle()
    UserListScreen(state, onUserClicked, onRefresh = vm::refresh)
}
```

### Step 5: Replace setContentView with setContent

Make the Activity a `ComponentActivity` (not `AppCompatActivity` unless you genuinely need Material Components / AppCompat):

```kotlin
// BEFORE
class UserListActivity : AppCompatActivity() {
    private lateinit var binding: ActivityUserListBinding
    override fun onCreate(b: Bundle?) {
        super.onCreate(b)
        binding = ActivityUserListBinding.inflate(layoutInflater)
        setContentView(binding.root)
        // findViewById / view binding code...
    }
}

// AFTER
@AndroidEntryPoint
class UserListActivity : ComponentActivity() {
    override fun onCreate(b: Bundle?) {
        super.onCreate(b)
        setContent {
            AppTheme {
                UserListRoute(onUserClicked = { startProfileActivity(it.id) })
            }
        }
    }
}
```

### Step 6: Delete the XML layout

Once the Activity is purely `setContent`, delete `res/layout/activity_user_list.xml` and any `view binding` references.

### Step 7: Migrate the navigation

If the project uses Jetpack Navigation, convert the destination to Compose:

Define routes as `@Serializable` data classes / data objects:

```kotlin
@Serializable data object UserList
@Serializable data class Profile(val userId: String)

NavHost(navController, startDestination = UserList) {
    composable<UserList> {
        UserListRoute(onUserClicked = { navController.navigate(Profile(it.id)) })
    }
    composable<Profile> {
        // ProfileViewModel reads args via SavedStateHandle.toRoute<Profile>().
        ProfileRoute()
    }
}
```

### Step 8: Move dependencies to Compose equivalents

| Legacy | Compose equivalent |
|--------|--------------------|
| `RecyclerView` + `Adapter` | `LazyColumn` / `LazyRow` |
| `ViewPager2` | `HorizontalPager` |
| `BottomNavigationView` | `NavigationBar` (Material 3) or `NavigationSuiteScaffold` |
| `MaterialAlertDialogBuilder` | `AlertDialog` (Material 3) |
| `Snackbar` | `SnackbarHost` |
| `SwipeRefreshLayout` | `PullToRefreshContainer` (Material 3) |
| `Glide` / `Coil` ImageView | `coil-compose` `AsyncImage` |

### Step 9: Test the migration

Add Compose UI tests for the new Screen. Run the existing instrumentation tests against the migrated Activity - if they assert on specific view IDs, they will need updating to semantic matchers.

### Step 10: Repeat for the next screen

Migrate one screen at a time. Mixed View / Compose in the same Activity (via `ComposeView`) is supported but adds complexity - aim to convert whole screens.

## Anti-patterns to avoid

- Do not migrate every screen in one PR. Per-screen PRs are reviewable.
- Do not delete the XML before the new Composable works end-to-end.
- Do not keep `lateinit` view references "for safety". They cannot survive the migration.
- Do not introduce a new ViewModel architecture mid-migration. Modernise the existing VM first.
Skill

compose-modernize-build

Modernize an Android project's Gradle build: bump to Kotlin 2.x with kotlin-compose plugin (replace kotlinCompilerExtensionVersion), migrate kapt to KSP, introduce Version Catalogs (libs.versions.toml), use the Compose BOM, switch to Material 3.

# Modernize Android Build

## When to Use

When inheriting an Android project on Kotlin 1.9 / Compose Compiler 1.5.x with manual version pinning, and bringing it forward to Kotlin 2.x with `kotlin-compose` plugin + Version Catalogs.

## Instructions

Apply in order. Sync Gradle and build the project after each step.

### Step 1: Create `libs.versions.toml`

`gradle/libs.versions.toml`:

```toml
[versions]
kotlin = "2.3.20"
agp = "9.1.1"
composeBom = "2026.04.01"
hilt = "2.52"
hiltNavigationCompose = "1.2.0"
room = "2.7.0"
ksp = "2.3.20-1.0.28"
coroutines = "1.8.1"
serializationJson = "1.7.3"
lifecycle = "2.9.0"
navigation = "2.8.5"

[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
```

### Step 2: Update the root build.gradle.kts

```kotlin
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    alias(libs.plugins.kotlin.serialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.hilt) apply false
}
```

### Step 3: Module build.gradle.kts - replace composeOptions with kotlin-compose plugin

```kotlin
// BEFORE
android {
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.14"
    }
}

// AFTER - remove the composeOptions block entirely
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)    // applies Compose Compiler as a Kotlin plugin
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

android {
    compileSdk = 36
    defaultConfig {
        minSdk = 24
        targetSdk = 36
    }
    buildFeatures {
        compose = true
    }
    // no composeOptions block needed
}
```

### Step 4: kapt to KSP

```kotlin
// BEFORE
plugins {
    id("kotlin-kapt")
}
dependencies {
    kapt("com.google.dagger:hilt-android-compiler:2.48")
    kapt("androidx.room:room-compiler:2.6.1")
}

// AFTER
plugins {
    alias(libs.plugins.ksp)
}
dependencies {
    ksp(libs.hilt.compiler)
    ksp(libs.room.compiler)
}
```

Remove any `kapt { correctErrorTypes = true }` blocks and the `kotlin-kapt` plugin.

### Step 5: Compose BOM for version alignment

```kotlin
dependencies {
    val bom = platform(libs.androidx.compose.bom)
    implementation(bom)
    androidTestImplementation(bom)

    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)
}
```

Remove individual version pins on `androidx.compose.*` artifacts - the BOM provides them.

### Step 6: Material 2 to Material 3

```bash
find . -name '*.kt' -print0 | xargs -0 sed -i.bak 's/androidx\.compose\.material\./androidx.compose.material3./g'
find . -name '*.kt.bak' -delete
```

Verify the swap compiles. Some component names differ (`TopAppBar` API changed, `IconButton` parameters renamed). Adjust per the Material 3 docs.

In dependencies, swap:

```kotlin
// BEFORE
implementation("androidx.compose.material:material:...")

// AFTER
implementation(libs.androidx.compose.material3)
```

### Step 7: targetSdk / compileSdk bump

```kotlin
android {
    compileSdk = 36
    defaultConfig {
        minSdk = 24       // adjust to your floor
        targetSdk = 36
    }
}
```

Required by the Play Store from August 2026 onward.

### Step 8: Verify

```bash
./gradlew clean assembleDebug
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest    # if devices available
```

Sync the IDE, confirm builds, run tests.

## Anti-patterns to avoid

- Do not pin `composeCompiler` separately after applying `kotlin-compose` - the plugin owns the version.
- Do not mix `kapt` and `ksp` for the same annotation processor - pick one (ksp).
- Do not leave version literals in module `build.gradle.kts` after introducing the catalog. The whole point is single-source-of-truth.
- Do not migrate Material 2 to Material 3 module-by-module if multiple modules import the same screen - the runtime will pick one and components will look subtly broken.
Skill

compose-new-screen

Scaffold a new Jetpack Compose screen with stateless Screen + stateful Route, ViewModel + StateFlow + collectAsStateWithLifecycle, sealed UI state, type-safe Navigation Compose route, Hilt injection, Compose previews.

# Scaffold Jetpack Compose Screen

## When to Use

When creating a new screen (e.g. Profile, Settings, OrderDetail) in a Compose-based Android app.

## Instructions

1. Choose the route name and arguments. Define the route as a `@Serializable` data class or `data object`:

   ```kotlin
   @Serializable data class Profile(val userId: String)
   ```

2. Define the UI state as a sealed interface:

   ```kotlin
   sealed interface ProfileUiState {
       data object Loading : ProfileUiState
       data class Success(val user: User) : ProfileUiState
       data class Error(val message: String) : ProfileUiState
   }
   ```

3. Define intents (events from UI to ViewModel):

   ```kotlin
   sealed interface ProfileIntent {
       data object Refresh : ProfileIntent
   }
   ```

4. Create the ViewModel:

   ```kotlin
   @HiltViewModel
   class ProfileViewModel @Inject constructor(
       private val repo: ProfileRepository,
       savedState: SavedStateHandle,
   ) : ViewModel() {

       private val args = savedState.toRoute<Profile>()

       val state: StateFlow<ProfileUiState> = repo.observeUser(args.userId)
           .map { ProfileUiState.Success(it) as ProfileUiState }
           .catch { emit(ProfileUiState.Error(it.message ?: "Unknown")) }
           .stateIn(
               scope = viewModelScope,
               started = SharingStarted.WhileSubscribed(5_000),
               initialValue = ProfileUiState.Loading,
           )

       fun onIntent(intent: ProfileIntent) {
           when (intent) {
               ProfileIntent.Refresh -> viewModelScope.launch { repo.refresh(args.userId) }
           }
       }
   }
   ```

5. Create the stateful Route composable (requests the VM, collects state, delegates to Screen):

   ```kotlin
   @Composable
   fun ProfileRoute(vm: ProfileViewModel = hiltViewModel()) {
       val state by vm.state.collectAsStateWithLifecycle()
       ProfileScreen(state, onIntent = vm::onIntent)
   }
   ```

6. Create the stateless Screen composable:

   ```kotlin
   @Composable
   fun ProfileScreen(
       state: ProfileUiState,
       onIntent: (ProfileIntent) -> Unit,
   ) {
       when (state) {
           ProfileUiState.Loading -> CircularProgressIndicator()
           is ProfileUiState.Success -> Column(Modifier.padding(16.dp)) {
               Text(state.user.name, style = MaterialTheme.typography.titleLarge)
               Text(state.user.email, style = MaterialTheme.typography.bodyMedium)
               Button(onClick = { onIntent(ProfileIntent.Refresh) }) { Text("Refresh") }
           }
           is ProfileUiState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error)
       }
   }
   ```

7. Wire the route into the nav graph:

   ```kotlin
   composable<Profile> {
       // The ViewModel reads Profile args via SavedStateHandle.toRoute<Profile>().
       ProfileRoute()
   }
   ```

8. Add previews for each state:

   ```kotlin
   @ThemePreviews
   @Composable
   fun ProfileScreenPreview_Success() {
       AppTheme {
           ProfileScreen(
               ProfileUiState.Success(User("alice", "Alice", "alice@example.com")),
               onIntent = {}
           )
       }
   }

   @ThemePreviews
   @Composable
   fun ProfileScreenPreview_Loading() {
       AppTheme { ProfileScreen(ProfileUiState.Loading, onIntent = {}) }
   }
   ```

9. Add a Compose UI test for the stateless Screen:

   ```kotlin
   @get:Rule val rule = createComposeRule()

   @Test fun shows_user_name() {
       rule.setContent {
           AppTheme {
               ProfileScreen(ProfileUiState.Success(User("alice", "Alice", "a@x.com")), onIntent = {})
           }
       }
       rule.onNodeWithText("Alice").assertIsDisplayed()
   }
   ```

## Anti-patterns to avoid

- Never put state in the Screen composable; hoist to the ViewModel.
- Never use `LaunchedEffect(true)` to fetch data; key on the args that change.
- Never expose `MutableStateFlow` publicly; use a private backing field.
- Never `findViewById` or `setContentView(R.layout...)`.
- Never use Material 2 imports (`androidx.compose.material.*`).
- Never name composables lowercase.
Skill

compose-validate

Scan a Kotlin / Compose / Android codebase for anti-patterns: findViewById, GlobalScope, LiveData, Material 2 imports, lowercase composables, LazyColumn without key, kapt, kotlinCompilerExtensionVersion, force-unwrap !!, Log.d, string nav routes, hardcoded dependency versions.

# Validate Compose / Kotlin Codebase

## When to Use

When auditing AI-generated Android code, reviewing a migration to Compose, or preparing a codebase for shipping.

## Instructions

Run each grep against the project root. Each hit is a candidate; review case by case.

### View-system leak

```bash
# findViewById in a Compose-based module
grep -rn 'findViewById' --include='*.kt' .

# Activity using setContentView(R.layout
grep -rn 'setContentView(R\.layout' --include='*.kt' .

# Android Views imports leaking into Compose code
grep -rnE 'import android\.widget\.(TextView|Button|ImageView|EditText)' --include='*.kt' .

# XML layout files for new screens (audit each: is this in active use?)
find . -name '*.xml' -path '*/res/layout/*'
```

### Material 2 leak

```bash
# Material 2 imports in a Material 3 project
grep -rnE 'import androidx\.compose\.material\.[A-Z]' --include='*.kt' .

# Material 2 dependency in build files
grep -rn 'androidx.compose.material:material' --include='*.gradle*' .
```

### Coroutine misuse

```bash
grep -rn 'GlobalScope\.' --include='*.kt' .
grep -rnE 'runBlocking\s*\{' --include='*.kt' .
```

### Null safety

```bash
# Force-unwrap operator (audit every match)
grep -rn '!!' --include='*.kt' .
```

`!!` has legitimate uses but they are rare. Most matches should be `?:` / `requireNotNull` / scoped `?.let`.

### State / Compose hygiene

```bash
# LaunchedEffect with sentinel key
grep -rnE 'LaunchedEffect\(\s*(true|Unit)\s*\)' --include='*.kt' .

# Public MutableStateFlow (no leading _)
grep -rnE '(public\s+|^\s*)val\s+\w+\s*:\s*MutableStateFlow' --include='*.kt' .
grep -rnE '(public\s+|^\s*)val\s+\w+\s*=\s*MutableStateFlow' --include='*.kt' .

# LazyColumn / LazyRow without key
grep -rnE 'items\(\s*\w+\s*\)\s*\{' --include='*.kt' .

# collectAsState without WithLifecycle
grep -rn 'collectAsState(' --include='*.kt' . | grep -v 'WithLifecycle'

# Composables named lowercase
grep -rnE '@Composable\s*$' --include='*.kt' . -A 1 | grep -E 'fun\s+[a-z]'
```

### Legacy reactive

```bash
grep -rnE 'LiveData|MutableLiveData|observeAsState' --include='*.kt' .
grep -rnE 'observe\(this\)\s*\{' --include='*.kt' .
```

### DI / architecture

```bash
# Hand-rolled singleton
grep -rnE 'object\s+\w*(Singleton|Locator|Manager)' --include='*.kt' .

# ViewModel constructed directly instead of injected
grep -rnE '=\s*[A-Z]\w*ViewModel\(' --include='*.kt' .

# kapt instead of ksp
grep -rn '\bkapt(' --include='*.kts' --include='*.gradle' .
```

### Build config

```bash
# Obsolete composeOptions block
grep -rn 'kotlinCompilerExtensionVersion' .

# compileSdk / targetSdk lower than 36 (Play Store requires 36 by Aug 2026)
grep -rnE '(compileSdk|targetSdk)\s*=\s*([0-9]|[12][0-9]|3[0-5])\b' --include='*.kts' .

# Hardcoded versions in module gradle files
grep -rnE '"\w+:.+:[0-9]+\.[0-9]+\.[0-9]+"' --include='*.kts' . | grep -v 'libs.versions.toml\|version.ref'
```

### Navigation

```bash
# String routes (should be @Serializable)
grep -rn 'composable("' --include='*.kt' .

# navigate with string format
grep -rnE 'navigate\("\w+/\$' --include='*.kt' .
```

### Logging

```bash
grep -rnE 'Log\.[dvwie]\(' --include='*.kt' .
```

### Java idioms in Kotlin

```bash
# Optional<T>
grep -rn 'Optional<' --include='*.kt' .

# Java streams
grep -rn 'java.util.stream\|\.stream()' --include='*.kt' .

# getX() / setX() on Kotlin classes
grep -rnE 'fun (get|set)[A-Z]\w*\s*\(' --include='*.kt' .
```

### Modifier order bug

```bash
# padding before clickable creates a dead zone
grep -rnE 'Modifier\.padding\([^)]*\)\.clickable' --include='*.kt' .
```

## Output Format

```
=== Critical ===
app/build.gradle.kts:18 - kotlinCompilerExtensionVersion (removed in Kotlin 2.0); apply kotlin-compose plugin
feature/profile/.../ProfileViewModel.kt:34 - GlobalScope.launch leaks
feature/home/.../HomeScreen.kt:45 - LaunchedEffect(true) - key on the changing input

=== Warnings ===
core/data/.../UserRepository.kt:12 - LiveData<List<User>>; convert to StateFlow
feature/home/.../HomeScreen.kt:67 - LazyColumn items() without key; pass key = { it.id }
build.gradle.kts:18 - kapt("...") instead of ksp("...")

=== Suggestions ===
core/ui/.../UserCard.kt:23 - data class wrapping List<Tag>; consider @Immutable
```
规则

Android architecture patterns: feature modularization, repository + data source pattern, sealed interfaces for UI state, ViewModel + StateFlow, Ktor / Retrofit + Kotlin Serialization, Room with Flow returns + KSP, coroutines structured concurrency.

Android architecture patterns: feature modularization, repository + data source pattern, sealed interfaces for UI state, ViewModel + StateFlow, Ktor / Retrofit + Kotlin Serialization, Room with Flow returns + KSP, coroutines structured concurrency.

# Android Architecture

## Modularization

```
app/                            // app entry, nav graph
build-logic/                    // convention plugins (optional)
core/
  designsystem/                 // theme, tokens, shared composables
  domain/                       // pure Kotlin business types, no Android deps
  data/                         // repositories, data sources
  network/                      // Ktor / Retrofit setup
  database/                     // Room entities, DAOs
feature/
  home/                         // self-contained screen modules
  profile/
  settings/
```

Each feature module exposes a `Route` composable and a nav declaration. The app module wires them into the nav graph.

## Repository pattern

```kotlin
// feature/profile/data/ProfileRepository.kt
interface ProfileRepository {
    suspend fun getUser(id: String): User
    fun observeUser(id: String): Flow<User>
}

class DefaultProfileRepository @Inject constructor(
    private val remote: ProfileRemoteDataSource,
    private val local: ProfileLocalDataSource,
) : ProfileRepository {

    override suspend fun getUser(id: String): User {
        val cached = local.get(id)
        if (cached != null) return cached
        val fresh = remote.fetch(id)
        local.put(fresh)
        return fresh
    }

    override fun observeUser(id: String): Flow<User> = local.observe(id)
}
```

Rules:
- Repository is an interface; bind the default via Hilt.
- Data sources are package-private inside the data module - the repo is the only public contract.
- Suspend functions for one-shot reads, `Flow<T>` for observable streams.

## ViewModel patterns

```kotlin
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val repo: ProfileRepository,
    savedState: SavedStateHandle,
) : ViewModel() {

    private val args = savedState.toRoute<Profile>()

    val state: StateFlow<ProfileUiState> = repo.observeUser(args.userId)
        .map { ProfileUiState.Success(it) as ProfileUiState }
        .catch { emit(ProfileUiState.Error(it.message ?: "Unknown")) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = ProfileUiState.Loading,
        )

    fun onIntent(intent: ProfileIntent) {
        when (intent) {
            ProfileIntent.Refresh -> viewModelScope.launch { repo.refresh(args.userId) }
        }
    }
}
```

`SharingStarted.WhileSubscribed(5_000)` keeps the upstream flow alive for 5 seconds after the last collector goes away, which survives configuration changes without leaking when the screen genuinely closes.

## Sealed interfaces for UI state and intents

```kotlin
sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Success(val user: User) : ProfileUiState
    data class Error(val message: String) : ProfileUiState
}

sealed interface ProfileIntent {
    data object Refresh : ProfileIntent
    data class UpdateName(val name: String) : ProfileIntent
}
```

`when` over a sealed interface is exhaustive at compile time. Adding a new variant forces every `when` to handle it.

## Networking: Ktor client + Kotlin Serialization

```kotlin
@Module @InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun httpClient(): HttpClient = HttpClient(OkHttp) {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                isLenient = true
                explicitNulls = false
            })
        }
        install(Logging) { level = LogLevel.HEADERS }
        defaultRequest { url("https://api.example.com/") }
    }
}

@Serializable data class UserDto(val id: String, val name: String, val email: String)

class ProfileRemoteDataSource @Inject constructor(private val http: HttpClient) {
    suspend fun fetch(id: String): UserDto = http.get("/users/$id").body()
}
```

Ktor is preferred for new Kotlin Multiplatform work. Retrofit 2.11+ with `kotlinx.serialization` is fine for Android-only.

## Room with Flow + KSP

```kotlin
@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey val id: String,
    val name: String,
    val email: String,
    val updatedAt: Long,
)

@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    fun observe(id: String): Flow<UserEntity?>

    @Query("SELECT * FROM users WHERE id = :id LIMIT 1")
    suspend fun get(id: String): UserEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsert(user: UserEntity)
}

@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}
```

In Gradle:

```kotlin
plugins { alias(libs.plugins.ksp) }
dependencies {
    implementation(libs.androidx.room.runtime)
    ksp(libs.androidx.room.compiler)
}
```

## Coroutines: structured concurrency, never `GlobalScope`

- `viewModelScope` for VM-tied work.
- `lifecycleScope` for Activity / Fragment-tied work.
- `rememberCoroutineScope()` for one-shot launches from a composable.
- `supervisorScope { }` when sibling failures should not cancel each other (parallel fetches).
- Custom `CoroutineExceptionHandler` for uncaught exceptions at scope creation, not in individual launches.

```kotlin
// Parallel independent fetches
suspend fun loadDashboard(): Dashboard = supervisorScope {
    val users = async { userRepo.getAll() }
    val orders = async { orderRepo.getAll() }
    val news = async { newsRepo.getAll() }
    Dashboard(
        users = users.await(),
        orders = orders.await(),
        news = runCatching { news.await() }.getOrElse { emptyList() },
    )
}
```

## Result vs sealed class

For one-shot remote calls, `kotlin.Result<T>` (or `runCatching { }`) is fine. For screen state, prefer a sealed `UiState` interface - more variants than success/failure (Loading, Empty, Refreshing).

## Hilt modules

```kotlin
@Module @InstallIn(SingletonComponent::class)
abstract class ProfileBindings {
    @Binds
    abstract fun bindRepo(impl: DefaultProfileRepository): ProfileRepository
}
```

Bind interfaces to implementations in a single module per feature. Use `@Singleton` for stateless services, `@ActivityRetainedScoped` for VM-shared state, `@ViewModelScoped` for per-VM caches.
规则

Compose / Kotlin Android anti-pattern detector. Catches findViewById, setContentView(R.layout), GlobalScope, LiveData + observeAsState, MutableState exposed publicly, force-unwrap !!, lowercase composables, LazyColumn without key, LaunchedEffect(true), Material 2 imports, padding-before-clickable, kotlinCompilerExtensionVersion (obsolete), kapt over KSP, ViewModel passed via composition, runBlocking, side effects in composition body.

Compose / Kotlin Android anti-pattern detector. Catches findViewById, setContentView(R.layout), GlobalScope, LiveData + observeAsState, MutableState exposed publicly, force-unwrap !!, lowercase composables, LazyColumn without key, LaunchedEffect(true), Material 2 imports, padding-before-clickable, kotlinCompilerExtensionVersion (obsolete), kapt over KSP, ViewModel passed via composition, runBlocking, side effects in composition body.

# Compose / Kotlin Android Anti-Patterns

Reject these in generated code. Each entry has a BAD example and the CORRECT replacement.

## 1. View-system leak: `findViewById` in a Compose project

```kotlin
// BAD
val btn = findViewById<Button>(R.id.submit)
btn.setOnClickListener { ... }

// CORRECT
Button(onClick = { ... }) { Text("Submit") }
```

Mixing XML layouts and Compose in the same screen forces both engines to run. New screens should be pure Compose.

## 2. `setContentView(R.layout.x)` instead of `setContent { ... }`

```kotlin
// BAD
class MainActivity : AppCompatActivity() {
    override fun onCreate(b: Bundle?) {
        super.onCreate(b)
        setContentView(R.layout.activity_main)
    }
}

// CORRECT
class MainActivity : ComponentActivity() {
    override fun onCreate(b: Bundle?) {
        super.onCreate(b)
        setContent { AppTheme { AppNavHost() } }
    }
}
```

## 3. `GlobalScope.launch { }`

```kotlin
// BAD - leaks, no lifecycle
GlobalScope.launch { repo.sync() }

// CORRECT - viewModelScope / lifecycleScope / rememberCoroutineScope
viewModelScope.launch { repo.sync() }
```

`GlobalScope` survives configuration changes, screen destruction, and process death. Anything started in it leaks until the process dies.

## 4. LiveData when StateFlow is the default

```kotlin
// BAD
val name: LiveData<String> = MutableLiveData("")
val displayName by vm.name.observeAsState("")

// CORRECT
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()

// in composable
val displayName by vm.name.collectAsStateWithLifecycle()
```

`LiveData` works but is legacy. New code uses `StateFlow` for state, `SharedFlow` for events.

## 5. Public `MutableStateFlow` / `MutableState`

```kotlin
// BAD
val count = MutableStateFlow(0)   // anyone can mutate

// CORRECT
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
```

Same rule as Java: expose the read-only contract, keep the mutator private.

## 6. Force-unwrap `!!`

```kotlin
// BAD
val id = intent.extras!!.getString("id")!!
val user = users.first { it.id == id }!!.name

// CORRECT
val id = intent.extras?.getString("id") ?: return
val user = users.firstOrNull { it.id == id }?.name ?: ""
```

`!!` says "this is impossible, crash if I'm wrong". Most uses are wrong. `requireNotNull` or `?:` make the intent explicit.

## 7. Lowercase composable function names

```kotlin
// BAD - lint flags this, @Preview tooling breaks
@Composable
fun userCard() { ... }

// CORRECT - PascalCase, noun-like
@Composable
fun UserCard() { ... }
```

Compose convention: composables are PascalCase and look like the UI noun they produce.

## 8. `LazyColumn` / `LazyRow` items without `key`

```kotlin
// BAD - recomposes every item on insert/delete, animations break
LazyColumn {
    items(users) { UserRow(it) }
}

// CORRECT
LazyColumn {
    items(users, key = { it.id }) { UserRow(it) }
}
```

The key tells Compose which slot in the slot table an item maps to. Without it, inserts and deletes shuffle every visible row.

## 9. `LaunchedEffect(true)` / `LaunchedEffect(Unit)` as a catch-all

```kotlin
// BAD - re-runs only on first composition, ignores changing inputs
@Composable
fun UserScreen(userId: String, vm: UserViewModel) {
    LaunchedEffect(true) { vm.load(userId) }  // does not re-run when userId changes
}

// CORRECT - key on what actually changes
LaunchedEffect(userId) { vm.load(userId) }
```

The key list is what triggers re-execution. Use the actual dependencies.

## 10. ViewModel passed down through composition

```kotlin
// BAD - drilled through every layer
@Composable fun App() { val vm: HomeViewModel = viewModel(); Home(vm) }
@Composable fun Home(vm: HomeViewModel) { Profile(vm) }

// CORRECT - request at the screen root via hiltViewModel()
@Composable
fun HomeRoute(vm: HomeViewModel = hiltViewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    HomeScreen(state, vm::onIntent)
}
```

Drilling the ViewModel makes nested composables hard to preview and test. Pass state and lambdas instead.

## 11. Material 2 imports in a Material 3 project

```kotlin
// BAD
import androidx.compose.material.Button
import androidx.compose.material.Text

// CORRECT
import androidx.compose.material3.Button
import androidx.compose.material3.Text
```

## 12. `Modifier.padding(...).clickable { }` (dead zone)

```kotlin
// BAD - padding is OUTSIDE the click area
Modifier.padding(16.dp).clickable { onClick() }

// CORRECT - clickable first, padding inside the hit area
Modifier.clickable { onClick() }.padding(16.dp)
```

Modifier order changes layout, hit areas, and visual decoration.

## 13. `kotlinCompilerExtensionVersion` in `composeOptions` (obsolete since Kotlin 2.0)

```kotlin
// BAD - removed in Kotlin 2.0+
android {
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.14"
    }
}

// CORRECT - Compose Compiler ships as a Kotlin plugin
plugins {
    alias(libs.plugins.kotlin.compose)
}
```

## 14. `kapt` instead of KSP

```kotlin
// BAD
kapt("com.google.dagger:hilt-android-compiler:2.52")

// CORRECT
ksp(libs.hilt.compiler)   // libs.hilt.compiler -> com.google.dagger:hilt-android-compiler
```

KSP is K2-native, multiple-times faster, and the future. All major annotation processors support it.

## 15. `remember { mutableStateOf() }` outside a `@Composable`

```kotlin
// BAD - compile error or undefined behavior
class MyViewModel {
    val state = remember { mutableStateOf("") }
}

// CORRECT - StateFlow in ViewModel, mutableStateOf only inside composables
class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow("")
    val state: StateFlow<String> = _state.asStateFlow()
}

@Composable
fun Field() { var text by remember { mutableStateOf("") } }
```

## 16. `runBlocking` on the main thread

```kotlin
// BAD
val user = runBlocking { repo.fetchUser() }

// CORRECT - keep it suspending, launch from a scope
viewModelScope.launch {
    val user = repo.fetchUser()
}
```

`runBlocking` defeats coroutines entirely and blocks the UI thread. Only acceptable in `main()` of CLI tools or in tests (and there `runTest` is better).

## 17. Side effects in composition body without LaunchedEffect / SideEffect

```kotlin
// BAD - re-fires on every recomposition
@Composable
fun UserScreen(userId: String, vm: UserViewModel) {
    vm.track("user_opened", userId)
    UserContent(...)
}

// CORRECT
@Composable
fun UserScreen(userId: String, vm: UserViewModel) {
    LaunchedEffect(userId) { vm.track("user_opened", userId) }
    UserContent(...)
}
```

Composition runs whenever inputs change. Side effects must be scoped via `LaunchedEffect`, `DisposableEffect`, or `SideEffect`.

## 18. Java-style `getX()` / `setX()` in Kotlin

```kotlin
// BAD - reads like translated Java
class User {
    private var name: String = ""
    fun getName(): String = name
    fun setName(v: String) { name = v }
}

// CORRECT - Kotlin property
class User {
    var name: String = ""
}
```

`var name` already gives you a getter and setter through property syntax.

## 19. Heavy work in composition body

```kotlin
// BAD - runs on every recomposition
@Composable
fun BigList(items: List<Item>) {
    val processed = items.map { expensive(it) }
    LazyColumn { items(processed) { ... } }
}

// CORRECT - cache with remember, or move to ViewModel
@Composable
fun BigList(items: List<Item>) {
    val processed = remember(items) { items.map { expensive(it) } }
    LazyColumn { items(processed) { ... } }
}
```

`remember(input) { ... }` re-runs the computation only when the input key changes.

## 20. `Log.d` scattered through codebase

```kotlin
// BAD
Log.d("MyApp", "user $id logged in")

// CORRECT - Timber or structured logging
Timber.tag("Login").d("user %s logged in", id)
```

`android.util.Log` calls always evaluate the string regardless of log level and ship to logcat unconditionally. Timber lets you plant production trees that strip debug output and route to crash reporters.
规则

Modern Android development rules: Kotlin 2.x with K2, Jetpack Compose, Material 3, state hoisting + StateFlow + collectAsStateWithLifecycle, Hilt with hiltViewModel(), type-safe Navigation Compose, KSP, Version Catalogs, Compose Compiler as Kotlin plugin.

Modern Android development rules: Kotlin 2.x with K2, Jetpack Compose, Material 3, state hoisting + StateFlow + collectAsStateWithLifecycle, Hilt with hiltViewModel(), type-safe Navigation Compose, KSP, Version Catalogs, Compose Compiler as Kotlin plugin.

# Modern Android: Kotlin 2.x + Jetpack Compose

You are writing Android UI with **Kotlin 2.x** and **Jetpack Compose** (Material 3, Compose BOM 2025+). Your training data likely contains View-system / XML / Material 2 / LiveData patterns. Follow these rules.

## Compose Compiler is a Kotlin plugin (Kotlin 2.0+)

The old `composeOptions.kotlinCompilerExtensionVersion` block is dead. Apply the Kotlin Compose plugin instead.

```kotlin
// build.gradle.kts (module)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)      // NEW - replaces composeOptions block
    alias(libs.plugins.kotlin.serialization)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

android {
    compileSdk = 36
    defaultConfig { minSdk = 24; targetSdk = 36 }
    buildFeatures { compose = true }
}
```

Compose Compiler now ships with Kotlin. No version pinning the extension separately.

## Material 3, not Material 2

```kotlin
// CORRECT
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.material3.MaterialTheme

// WRONG
import androidx.compose.material.Button
import androidx.compose.material.Text
```

The package is `androidx.compose.material3` (with the `3`). The `androidx.compose.material` package is the older Material 2 surface - mixing them produces visual and behavioural inconsistency.

## State hoisting + unidirectional data flow

State flows down (`UiState`), events flow up (`(Intent) -> Unit`). Stateful composables wrap stateless ones.

```kotlin
@Composable
fun ProfileRoute(vm: ProfileViewModel = hiltViewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    ProfileScreen(state, vm::onIntent)
}

@Composable
fun ProfileScreen(state: ProfileUiState, onIntent: (ProfileIntent) -> Unit) {
    when (state) {
        ProfileUiState.Loading -> CircularProgressIndicator()
        is ProfileUiState.Success -> Text(state.user.name)
        is ProfileUiState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error)
    }
}
```

The stateful "Route" composable owns the ViewModel. The stateless "Screen" composable is pure and easy to preview, test, and reuse.

## UI state as a sealed interface

```kotlin
sealed interface ProfileUiState {
    data object Loading : ProfileUiState
    data class Success(val user: User) : ProfileUiState
    data class Error(val message: String) : ProfileUiState
}
```

Not `Result<T>` for screen state - sealed interface gives compile-time exhaustive `when` and clearer intent.

## ViewModel + StateFlow + collectAsStateWithLifecycle

```kotlin
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val repo: ProfileRepository,
    savedState: SavedStateHandle,
) : ViewModel() {

    private val args = savedState.toRoute<Profile>()

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

    init {
        viewModelScope.launch {
            _state.value = runCatching { repo.getUser(args.userId) }
                .fold(
                    { ProfileUiState.Success(it) },
                    { ProfileUiState.Error(it.message ?: "Unknown") }
                )
        }
    }
}
```

Rules:
- `MutableStateFlow` private, expose `StateFlow` read-only.
- `viewModelScope` for coroutines tied to the VM lifecycle.
- `collectAsStateWithLifecycle()` in composables (not `collectAsState()`) - it stops collecting when the lifecycle is stopped.

## Hilt for DI + hiltViewModel() in composables

```kotlin
@HiltAndroidApp
class App : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(b: Bundle?) {
        super.onCreate(b)
        setContent { AppTheme { AppNavHost() } }
    }
}

@Composable
fun HomeRoute(vm: HomeViewModel = hiltViewModel()) { ... }
```

Request the ViewModel at the screen root with `hiltViewModel()`. Do not drill it down through composables - drill state and lambdas instead.

## Type-safe Navigation Compose

```kotlin
@Serializable data object Home
@Serializable data class Profile(val userId: String)

NavHost(navController, startDestination = Home) {
    composable<Home> {
        HomeRoute(onUser = { id -> navController.navigate(Profile(id)) })
    }
    composable<Profile> {
        // Route reads its own args via SavedStateHandle.toRoute<Profile>() inside the VM.
        ProfileRoute()
    }
}
```

`@Serializable` data classes are the route definition. No string routes, no manual `Bundle` parsing.

For ViewModels:

```kotlin
class ProfileViewModel @Inject constructor(saved: SavedStateHandle) : ViewModel() {
    private val args: Profile = saved.toRoute()
    val userId: String = args.userId
}
```

## KSP over kapt

```kotlin
// build.gradle.kts
plugins {
    alias(libs.plugins.ksp)        // not kapt
}

dependencies {
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    ksp(libs.androidx.room.compiler)
}
```

KSP is K2-native, faster, and the future. Hilt, Room, Moshi, Glide, Dagger all support it.

## Version Catalogs (libs.versions.toml)

Single source of truth for versions across modules.

```toml
[versions]
kotlin = "2.3.20"
agp = "9.1.1"
composeBom = "2026.04.01"
hilt = "2.52"
room = "2.7.0"
ksp = "2.3.20-1.0.28"
lifecycle = "2.9.0"
navigation = "2.8.5"

[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
```

Hardcoded versions in module `build.gradle.kts` are a smell.

## Compose BOM for version alignment

```kotlin
dependencies {
    val bom = platform(libs.androidx.compose.bom)
    implementation(bom)
    androidTestImplementation(bom)
    implementation(libs.androidx.compose.material3)            // no version - BOM picks it
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)
}
```

The BOM keeps `compose.material3`, `compose.ui`, `compose.foundation` versions in sync. Each catalog entry above omits `version` (the BOM resolves it), so the only version pinned in `libs.versions.toml` is `composeBom` itself.

## Adaptive layouts with NavigationSuiteScaffold

For phone + tablet + foldable in one layout:

```kotlin
@Serializable data object Home
@Serializable data object Library  // parameterless destinations work as top-level nav targets

val current = navController.currentBackStackEntryAsState().value?.destination

NavigationSuiteScaffold(
    navigationSuiteItems = {
        item(
            selected = current?.hasRoute<Home>() == true,
            onClick = { navController.navigate(Home) },
            icon = { Icon(Icons.Default.Home, null) },
            label = { Text("Home") },
        )
        item(
            selected = current?.hasRoute<Library>() == true,
            onClick = { navController.navigate(Library) },
            icon = { Icon(Icons.Default.Person, null) },
            label = { Text("Library") },
        )
    }
) {
    AppNavHost()
}
```

Picks `NavigationBar` / `NavigationRail` / `NavigationDrawer` based on `WindowSizeClass`. Replaces hand-rolled `Scaffold { BottomNavigation { } }`.

## Modifier order matters

```kotlin
// CORRECT - clickable first, padding inside the hit area
Modifier.clickable { onClick() }.padding(16.dp)

// WRONG - padding is OUTSIDE the click area, dead zone
Modifier.padding(16.dp).clickable { onClick() }
```

The order of modifiers determines layout, hit areas, and decoration. Same modifier list in a different order is a different layout.

## Previews

```kotlin
@Preview(name = "Light", uiMode = UI_MODE_NIGHT_NO)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES)
annotation class ThemePreviews

@ThemePreviews
@Composable
fun ProfileScreenPreview() {
    AppTheme {
        ProfileScreen(ProfileUiState.Success(User("alice", "Alice", "alice@example.com")), onIntent = {})
    }
}
```

Stateless composables preview cleanly because they take state as a parameter.
规则

Compose performance: stability annotations (@Stable, @Immutable), strong-skipping in Kotlin 2.x, key-based recomposition, derivedStateOf, remember{} keys, deferred reads, Compose previews + Paparazzi for screenshot tests.

Compose performance: stability annotations (@Stable, @Immutable), strong-skipping in Kotlin 2.x, key-based recomposition, derivedStateOf, remember{} keys, deferred reads, Compose previews + Paparazzi for screenshot tests.

# Compose Performance

## Strong skipping (Kotlin 2.x default)

Since Compose Compiler ships with Kotlin 2.0+, strong-skipping is on by default. Most composables skip recomposition for unstable parameters automatically. Stability annotations matter less than they did, but still help in two cases:

- Data classes the compiler cannot prove immutable (because a parameter is from another module).
- Collections (`List<T>`, `Map<K, V>`) which are unstable even when contents are immutable - mark wrappers with `@Immutable`.

```kotlin
@Immutable
data class UserCard(
    val id: String,
    val name: String,
    val tags: List<String>,
)
```

`@Stable` says: "I will notify Compose when I mutate." `@Immutable` says: "I never mutate."

## Recomposition keys

The key list of `LaunchedEffect`, `remember`, `produceState`, `DisposableEffect` controls when the block re-runs. Always pass the actual dependencies; never `true` or `Unit` as a shortcut.

```kotlin
// BAD - never re-runs even when userId changes
LaunchedEffect(true) { vm.load(userId) }

// CORRECT
LaunchedEffect(userId) { vm.load(userId) }
```

## remember with multiple keys

```kotlin
// Re-compute only when input or filter changes
val filtered = remember(items, filter) {
    items.filter { it.matches(filter) }
}
```

## derivedStateOf for cheap reads on expensive state

```kotlin
val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
```

`derivedStateOf` reduces recomposition triggers by deriving a smaller surface from a larger state. Use when many composables read a coarse state but most do not care about every change.

## Deferred reads with lambda parameters

```kotlin
// BAD - reads state at the calling composable's recomposition scope
@Composable
fun BadList(items: List<Item>) {
    items.forEach { Row(item = it) }
}

// CORRECT - lambda defers the read into Row's scope
@Composable
fun GoodList(itemsProvider: () -> List<Item>) {
    val items = itemsProvider()
    LazyColumn { items(items, key = { it.id }) { Row(item = it) } }
}
```

In tight performance code, accepting a lambda instead of the value pushes the state read down the tree, scoping recomposition more tightly.

## LazyColumn / LazyRow performance

Always provide a `key`:

```kotlin
LazyColumn {
    items(users, key = { it.id }) { user -> UserRow(user) }
}
```

Use `contentType` when items vary structurally:

```kotlin
LazyColumn {
    items(feedItems, key = { it.id }, contentType = { it::class }) { item ->
        when (item) {
            is FeedItem.Post -> PostRow(item)
            is FeedItem.Ad -> AdRow(item)
        }
    }
}
```

`contentType` lets Compose reuse view holders across item types more efficiently.

## Stable lambdas

Inline lambdas in composables capture the call site - each recomposition creates a new lambda instance, which can defeat skipping in children. For hot paths, use `remember`:

```kotlin
@Composable
fun Parent(onAction: (String) -> Unit) {
    val callback = remember(onAction) { { id: String -> onAction(id) } }
    ItemList(onItemClicked = callback)
}
```

For most code this is over-optimization; reach for it only when the profiler shows a problem.

## Compose UI tests

```kotlin
@get:Rule
val rule = createComposeRule()

@Test
fun showsName() {
    rule.setContent {
        AppTheme {
            ProfileScreen(ProfileUiState.Success(User("alice", "Alice", "a@example.com")), onIntent = {})
        }
    }
    rule.onNodeWithText("Alice").assertIsDisplayed()
}
```

Semantic matchers (`onNodeWithText`, `onNodeWithTag`, `onNodeWithContentDescription`) are stable across UI restructuring. Prefer them over coordinate-based assertions.

## Paparazzi for screenshot tests (no emulator)

```kotlin
@Test
fun lightTheme() {
    paparazzi.snapshot {
        AppTheme(darkTheme = false) {
            ProfileScreen(ProfileUiState.Success(user), onIntent = {})
        }
    }
}
```

Paparazzi renders Compose to PNG without an emulator. Fast, deterministic, runnable in CI. Pair with `@Preview` annotations - the same composable that drives previews also drives screenshot tests.

## Measure before you optimise

Compose ships a layout inspector with recomposition counts. Run it before adding `@Stable`, `remember`, or `derivedStateOf` "for performance" - the strong-skipping default usually handles it. Premature optimisation in Compose has the same cost as everywhere else.
规则

Compose testing: createComposeRule, semantic matchers, Paparazzi screenshot tests, Hilt + Compose test setup, runTest for coroutines, Turbine for Flow assertions, fake repositories over mocks. Agent-requested.

Compose testing: createComposeRule, semantic matchers, Paparazzi screenshot tests, Hilt + Compose test setup, runTest for coroutines, Turbine for Flow assertions, fake repositories over mocks. Agent-requested.

# Compose / Kotlin Android Testing

## Compose UI tests

```kotlin
class ProfileScreenTest {

    @get:Rule
    val rule = createComposeRule()

    @Test
    fun showsLoading() {
        rule.setContent {
            AppTheme { ProfileScreen(ProfileUiState.Loading, onIntent = {}) }
        }
        rule.onNodeWithContentDescription("Loading").assertIsDisplayed()
    }

    @Test
    fun showsUserName_onSuccess() {
        val user = User("alice", "Alice", "alice@example.com")
        rule.setContent {
            AppTheme { ProfileScreen(ProfileUiState.Success(user), onIntent = {}) }
        }
        rule.onNodeWithText("Alice").assertIsDisplayed()
        rule.onNodeWithText("alice@example.com").assertIsDisplayed()
    }

    @Test
    fun invokesOnIntent_whenButtonClicked() {
        val user = User("alice", "Alice", "alice@example.com")
        var intent: ProfileIntent? = null
        rule.setContent {
            AppTheme {
                ProfileScreen(ProfileUiState.Success(user), onIntent = { intent = it })
            }
        }
        rule.onNodeWithText("Refresh").performClick()
        assertEquals(ProfileIntent.Refresh, intent)
    }
}
```

Test the stateless `Screen` composable. It takes state and lambdas, so the test never needs a ViewModel.

## Semantic matchers

Prefer semantic queries over node-tree introspection:

- `onNodeWithText("Alice")` - visible text content.
- `onNodeWithContentDescription("Profile avatar")` - for icons/images.
- `onNodeWithTag("submit-button")` - via `Modifier.testTag(...)` (use sparingly; tags are not visible to users).
- `onAllNodesWithText(...)` - when multiple nodes match.

Custom matchers:

```kotlin
rule.onNode(hasText("Save") and hasClickAction()).performClick()
```

## ViewModel tests with runTest + Turbine

```kotlin
class ProfileViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()  // sets Dispatchers.Main to a test dispatcher

    @Test
    fun emitsSuccess_whenRepoReturnsUser() = runTest {
        val user = User("alice", "Alice", "alice@example.com")
        val repo = FakeProfileRepository(user = user)
        // Build the route's SavedStateHandle via navigation-testing so toRoute<Profile>() works.
        val savedState = Profile("alice").let {
            SavedStateHandle().apply { set("userId", "alice") }  // see note below
        }
        val vm = ProfileViewModel(repo, savedState)

        vm.state.test {
            assertEquals(ProfileUiState.Loading, awaitItem())
            val item = awaitItem()
            assertTrue(item is ProfileUiState.Success)
            assertEquals("Alice", (item as ProfileUiState.Success).user.name)
            cancelAndIgnoreRemainingEvents()
        }
    }
}
```

Note: `savedState.toRoute<Profile>()` deserializes from a Navigation-specific bundle key, not a plain `userId` string. For ViewModel tests, either:

- Have the production VM accept the args object directly via a factory and bypass `toRoute()` in tests, OR
- Test the VM with a fake repository and the args injected as a constructor parameter, OR
- Use `androidx.navigation.testing` `TestNavHostController` for an integration-level test.

The bundle-key approach via `SavedStateHandle("userId" to "alice")` works only if the production code reads `savedState["userId"]` directly. If the production code uses `savedState.toRoute<Profile>()`, prefer the factory pattern in tests.

Turbine's `test { }` extension on `Flow` lets you assert emissions sequentially without writing your own collector.

## Fake repositories over mocks

```kotlin
class FakeProfileRepository(private val user: User?) : ProfileRepository {
    override suspend fun getUser(id: String): User =
        user ?: throw NoSuchElementException("not found")

    override fun observeUser(id: String): Flow<User> =
        flowOf(user ?: throw NoSuchElementException("not found"))
}
```

A fake implementation is easier to read and maintain than a `mockk { every { ... } returns ... }` block. Mocks become brittle as the interface evolves.

## Hilt + Compose tests

```kotlin
@HiltAndroidTest
@UninstallModules(NetworkModule::class)  // swap a real module for a fake
class HomeScreenTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeRule = createAndroidComposeRule<HiltTestActivity>()

    private val user = User("alice", "Alice", "alice@example.com")

    @BindValue
    val fakeRepo: ProfileRepository = FakeProfileRepository(user)

    @Before fun setUp() { hiltRule.inject() }

    @Test
    fun rendersUser() {
        composeRule.setContent { AppTheme { HomeRoute() } }
        composeRule.onNodeWithText("Alice").assertIsDisplayed()
    }
}
```

`@BindValue` is the most ergonomic way to swap a single binding in a test. `@UninstallModules` removes an entire module when you need full replacement.

## Paparazzi for screenshot tests

```kotlin
class ProfileScreenshotTest {

    @get:Rule
    val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5)

    private val user = User("alice", "Alice", "alice@example.com")

    @Test
    fun loading() = paparazzi.snapshot {
        AppTheme { ProfileScreen(ProfileUiState.Loading, onIntent = {}) }
    }

    @Test
    fun success_lightTheme() = paparazzi.snapshot {
        AppTheme(darkTheme = false) {
            ProfileScreen(ProfileUiState.Success(user), onIntent = {})
        }
    }
}
```

Paparazzi runs on the JVM (no emulator), produces deterministic PNG output, and integrates with `assert-snapshot`-style review. Pair with the `@ThemePreviews` multipreview annotation so the same composable drives previews and screenshot tests.

## MainDispatcherRule

```kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) { Dispatchers.setMain(dispatcher) }
    override fun finished(description: Description) { Dispatchers.resetMain() }
}
```

ViewModels using `viewModelScope` collect on `Dispatchers.Main`. Without this rule, tests crash because `Dispatchers.Main` requires an Android looper.

## What NOT to do

- Do not test composables by spinning up the full app. Test the stateless `Screen` in isolation.
- Do not mock `Flow<T>`. Use `flowOf(...)` or a manual `MutableSharedFlow`.
- Do not assert on `mockk` `verify { ... }` for control flow. Assert on observed state instead.
- Do not write tests that rely on `Thread.sleep`. Use `runTest` and virtual time.
- Do not write Espresso tests for Compose - use `ComposeTestRule`.

来源:https://github.com/RoninForge/roninforge-kotlin-compose