Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsBlogPro
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Authors
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges
  • Chrome Extension
  • Skill Manager

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

ProTermsPrivacyRefunds
Back to skills

Android Expert

ASecurity

House rules and load-bearing patterns for production Android with Jetpack Compose + MVI. Covers the MviViewModel<State,Intent,Effect> contract pattern, DispatcherProvider, collectAsStateWithLifecycle, type-safe Navigation Compose (@Serializable routes) + Navigation3 forward path, Koin DI (not Hilt), strong skipping defaults, edge-to-edge mandate, Material 3 / Expressive + adaptive layouts, Room + DataStore, Turbine + MockK + Robolectric testing scaffold, Baseline Profiles, R8 full mode, compo...

2 stars
0 votes
0 copies
0 views
Added 9/29/2026
developmentgojavakotlinreactnodeexpresstestingapidatabaseperformance

Works with

cliapi

Security Analysis

A100/100

Scanned 9/29/2026

$npx -y skills add abnegate/claudes --skill android-expert --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Android Expert?

Add the live security badge to your README — it updates automatically with every re-scan.

Security grade badge for Android Expert
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/abnegate-android-expert/badge)](https://www.skillsdirectory.com/skills/abnegate-android-expert)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
Files
SKILL.md
---
name: android-expert
description: House rules and load-bearing patterns for production Android with Jetpack Compose + MVI. Covers the MviViewModel<State,Intent,Effect> contract pattern, DispatcherProvider, collectAsStateWithLifecycle, type-safe Navigation Compose (@Serializable routes) + Navigation3 forward path, Koin DI (not Hilt), strong skipping defaults, edge-to-edge mandate, Material 3 / Expressive + adaptive layouts, Room + DataStore, Turbine + MockK + Robolectric testing scaffold, Baseline Profiles, R8 full mode, composable parameter ordering, module layout, and anti-pattern catalog. **Does not** re-teach elementary Compose (recomposition, remember, LaunchedEffect basics), standard Kotlin/coroutine patterns, or basic Material 3 usage -- assume that baseline. Use when writing or reviewing any Android/Compose code.
---

# Android Expert

Opinion and house-rule reference for **Jetpack Compose + MVI** on modern Android (Kotlin 2.x / K2, Compose BOM, `targetSdk 36+`). **Assumes baseline Compose/Kotlin knowledge** -- this file only covers what's house-specific, load-bearing, or shifts year-to-year.

Non-negotiable defaults:
- **`MviViewModel<State, Intent, Effect>`** with a `*Contract` holder. UDF only; no two-way binding.
- State is a single immutable `data class`; intents and effects are `sealed interface`.
- Always **`collectAsStateWithLifecycle()`**, never `collectAsState()`.
- Type-safe Navigation Compose routes are **`@Serializable data object` / `data class`**. String routes are banned.
- Hoist `modifier: Modifier = Modifier` as the **last** parameter with a default.
- Koin for DI (`koin-compose-viewmodel`), not Hilt.
- One class per file. Full words in names (`connection`, not `conn`). camelCase acronyms (`updateMfa`, not `updateMFA`).

---

## 1. Year-to-year shifts -- get these right

- **Compose Compiler is a Gradle plugin** (`org.jetbrains.kotlin.plugin.compose`). Do NOT add `androidx.compose.compiler:compiler` or set `kotlinCompilerExtensionVersion`. Both are obsolete.
- **Strong skipping is on by default.** `@Stable` / `@Immutable` are almost never needed (see section 8).
- **KSP2 is the default**; KAPT is being phased out.
- **`collectAsState()` -> `collectAsStateWithLifecycle()`.** Plain version keeps collecting while backgrounded.
- **`PredictiveBackHandler` -> `NavigationBackHandler` / Navigation Event API.** Deprecated API splits into `onBackCancelled` / `onBackCompleted` with `NavigationEventState.transitionState`.
- **Edge-to-edge is mandatory** at `targetSdk 36`. `enableEdgeToEdge()` before `setContent { }`. The opt-out manifest flag is ignored.
- **Navigation 3 has shipped** alongside existing `navigation-compose`. Nav2 still maintained -- adopt Nav3 in greenfield only.
- **Material 3 Expressive** (wavy progress, FAB menus, split buttons) ships through `material3`; opt in via `MaterialExpressiveTheme { ... }`.
- **Room 3.0** is KMP-first, KSP-only, coroutines-required. The 2.x line is still production on pure Android.
- **`@Serializable` typed routes** are the only correct way to declare navigation destinations.

---

## 2. Module layout

```kotlin
rootProject.name = "app"
include(":app")
include(":core:common")        // MviViewModel, DispatcherProvider, Analytics, CrashReporter
include(":core:model")         // Domain types: data classes only, no Android deps
include(":core:database")      // Room DAOs + Repositories + Mappers
include(":core:network")       // HTTP client, DTOs
include(":core:preferences")   // DataStore
include(":ui:design")          // Theme, colors, reusable components
include(":feature:home")
include(":feature:detail")
```

Dependency graph: `:app -> :feature:* -> :core:* -> (external libs)`. `:feature:*` may also depend on `:ui:design`.

Rules:
- **Singular nouns** for modules and packages: `:feature:home`, not `:feature:homeFeature`.
- **No `core/util` or `core/helpers`**. `core/common` holds framework primitives. Everything else is domain-grouped.
- **`:core:model` has zero Android dependencies** -- data classes + enums only. Liftable to KMP without changes.
- **No `:data`/`:domain`/`:presentation` layered split.** Domain-driven modularisation: each `core/<domain>` owns entities, DAOs, repositories, and mappers end-to-end.

---

## 3. MVI -- the contract pattern

### 3.1 The `MviViewModel` base class

```kotlin
package com.example.app.core.common

abstract class MviViewModel<State, Intent, Effect>(
    initialState: State,
) : ViewModel() {
    private val _state = MutableStateFlow(initialState)
    val state: StateFlow<State> = _state.asStateFlow()

    private val _effects = Channel<Effect>(Channel.BUFFERED)   // NOT SharedFlow -- buffers while UI rebuilds
    val effects: Flow<Effect> = _effects.receiveAsFlow()

    protected val currentState: State get() = _state.value     // don't call _state.value inside update{}

    fun dispatch(intent: Intent) {
        viewModelScope.launch { handleIntent(intent) }
    }

    protected abstract suspend fun handleIntent(intent: Intent)

    protected fun setState(reduce: State.() -> State) {
        _state.update(reduce)                                  // lock-free, retries on conflict
    }

    protected suspend fun sendEffect(effect: Effect) {
        _effects.send(effect)
    }
}
```

### 3.2 The `*Contract` holder

```kotlin
object ItemListContract {
    data class State(
        val items: List<ItemUiModel> = emptyList(),         // all fields have defaults
        val favorites: List<ItemUiModel> = emptyList(),
        val isLoading: Boolean = true,
        val isRefreshing: Boolean = false,
        val error: UiError? = null,                         // persistent errors as state field
        val searchQuery: String = "",
        val deleteConfirmation: Long? = null,
    )

    sealed interface Intent {                               // sealed interface, not sealed class
        data class Search(val query: String) : Intent
        data class SelectItem(val itemId: Long) : Intent
        data class ConfirmDelete(val itemId: Long) : Intent
        data object DismissDelete : Intent                  // data object, never bare object
        data class ToggleFavorite(val itemId: Long, val isFavorite: Boolean) : Intent
        data object AddItem : Intent
        data object Refresh : Intent
    }

    sealed interface Effect {
        data class NavigateToDetail(val itemId: Long) : Effect
        data class NavigateToAddItem(val prefillLabel: String? = null) : Effect
        data class ShowError(                               // transient messages as effects
            override val message: String? = null,
            @param:StringRes override val messageRes: Int? = null,
            override val messageArgs: List<Any> = emptyList(),
        ) : Effect, LocalizedMessage
    }
}
```

Localised messages use a `LocalizedMessage` interface (`messageRes: Int?` + `messageArgs: List<Any>`) so effects carry a `@StringRes` that resolves at the UI layer. Never stringify at the VM layer.

### 3.3 The ViewModel

```kotlin
class ItemListViewModel(
    private val repository: ItemRepository,
    private val dispatchers: DispatcherProvider,             // injected, not Dispatchers.IO directly
    private val analytics: Analytics,
    private val crashReporter: CrashReporter,
) : MviViewModel<ItemListContract.State, ItemListContract.Intent, ItemListContract.Effect>(
    initialState = ItemListContract.State(),
) {
    init { observeItems() }                                 // long-lived observers start at construction

    private fun observeItems() {
        combine(
            searchQuery.debounce(300).distinctUntilChanged()
                .flatMapLatest { q ->
                    if (q.isBlank()) repository.observeAll() else repository.search(q)
                },
            repository.observeFavorites(),
        ) { items, favorites ->
            items.map { it.toUiModel() } to favorites.map { it.toUiModel() }
        }.onEach { (models, favs) ->
            setState { copy(items = models, favorites = favs, isLoading = false, error = null) }
        }.launchIn(viewModelScope)
    }

    override suspend fun handleIntent(intent: ItemListContract.Intent) {
        when (intent) {                                     // exhaustive when on sealed interface
            is Intent.Search        -> searchQuery.value = intent.query
            is Intent.SelectItem    -> sendEffect(Effect.NavigateToDetail(intent.itemId))
            is Intent.ConfirmDelete -> handleConfirmDelete(intent.itemId)
            // ...
        }
    }

    private suspend fun handleConfirmDelete(itemId: Long) {
        setState { copy(deleteConfirmation = null) }
        try {
            withContext(dispatchers.io) { repository.delete(itemId) }
            sendEffect(Effect.ShowInfo(messageRes = R.string.items_message_deleted))
        } catch (exception: Exception) {
            crashReporter.record(exception)                 // dashboard sees it
            setState { copy(error = UiError(messageRes = R.string.items_message_delete_failed)) }
        }
    }
}
```

### 3.4 The screen composable

```kotlin
@Composable
fun ItemListScreen(
    onNavigateToDetail: (Long) -> Unit,                     // nav callbacks hoisted, no NavController
    onNavigateToAddItem: (String?) -> Unit,
    viewModel: ItemListViewModel = koinViewModel(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {                                  // canonical effect collector
        viewModel.effects.collect { effect ->
            when (effect) {
                is Effect.NavigateToDetail -> onNavigateToDetail(effect.itemId)
                is Effect.ShowError -> bannerState.show(effect.resolve(context))
            }
        }
    }

    ItemListContent(state = state, onIntent = viewModel::dispatch, modifier = Modifier.fillMaxSize())
}
```

Split stateful `Screen` from stateless `Content`. `Content(state, onIntent, modifier)` is what previews and tests drive.

### 3.5 Composable parameter ordering (non-negotiable)

```kotlin
@Composable
fun ChoiceButton(
    text: String,                       // 1. required data
    onClick: () -> Unit,                // 2. required callbacks
    isSelected: Boolean = false,        // 3. optional data with defaults
    modifier: Modifier = Modifier,      // 4. modifier ALWAYS last
) { ... }
// content: @Composable () -> Unit comes AFTER modifier as trailing lambda
```

---

## 4. DispatcherProvider

```kotlin
interface DispatcherProvider {
    val main: CoroutineDispatcher       // always Dispatchers.Main.immediate, not .Main
    val io: CoroutineDispatcher
    val default: CoroutineDispatcher
}

class DefaultDispatcherProvider : DispatcherProvider {
    override val main = Dispatchers.Main.immediate
    override val io = Dispatchers.IO
    override val default = Dispatchers.Default
}
```

In tests, supply an object returning `StandardTestDispatcher` / `UnconfinedTestDispatcher` for all three.

---

## 5. Flow patterns -- the idioms you need

```kotlin
// Merge sources into UI state:
combine(searchQuery.debounce(300).distinctUntilChanged(), reachability) { q, reach ->
    buildUiModel(q, reach)
}.onEach { setState { copy(models = it) } }.launchIn(viewModelScope)

// Switch flow on new input (cancels prior):
queryFlow.flatMapLatest { q -> repository.search(q) }

// Read-only derived StateFlow (no VM mutation):
val uiState: StateFlow<UiState> = repository.observe()
    .map { it.toUiState() }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)

// Debounce without dropping first emission:
val debouncedKey = merge(keyFlow.take(1), keyFlow.drop(1).debounce(400))
```

`WhileSubscribed(5_000)` is the canonical started strategy -- 5 seconds covers config changes without keeping upstream hot forever. `flowOn` applies **upstream only**.

---

## 6. Navigation

### 6.1 Today: Navigation Compose + type-safe routes

Routes are **always** `@Serializable data object` / `data class`, never strings.

```kotlin
@Serializable data object ItemListRoute
@Serializable data class ItemDetailRoute(val itemId: Long)

fun NavGraphBuilder.itemsNavGraph(navController: NavController) {
    composable<ItemListRoute> {
        ItemListScreen(
            onNavigateToDetail = { navController.navigate(ItemDetailRoute(it)) },
        )
    }
    composable<ItemDetailRoute> { entry ->
        val route = entry.toRoute<ItemDetailRoute>()
        ItemDetailScreen(itemId = route.itemId, onNavigateBack = { navController.popBackStack() })
    }
}
```

- One `XxxNavigation.kt` per feature with routes + `NavGraphBuilder.xxxNavGraph(...)`. `:app` wires them in `AppNavHost`.
- Apply `org.jetbrains.kotlin.plugin.serialization` at every module declaring routes.

### 6.2 Navigation3 (stable, 1.0)

Back stack is a plain `SnapshotStateList<NavKey>` -- `add`, `removeLastOrNull`, `replaceAll` directly.

```kotlin
@Serializable sealed interface HomeRoute : NavKey {
    @Serializable data object Feed : HomeRoute
    @Serializable data class Detail(val id: String) : HomeRoute
}

@Composable
fun AppNavHost() {
    val backStack = rememberNavBackStack(HomeRoute.Feed)
    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider {
            entry<HomeRoute.Feed> { FeedScreen(onItemClick = { backStack.add(HomeRoute.Detail(it)) }) }
            entry<HomeRoute.Detail> { key -> DetailScreen(id = key.id) }
        },
    )
}
```

Dependencies: `navigation3-runtime`, `navigation3-ui`, `lifecycle-viewmodel-navigation3` (NavEntry-scoped VMs).

**Decision rule**: greenfield -> Nav3 (verify its `minSdk` floor). Existing Nav2 -> do NOT migrate reactively.

---

## 7. DI -- Koin

Koin, not Hilt. Better KMP ergonomics, readable error messages, no codegen overhead.

```kotlin
val commonModule = module {
    single<DispatcherProvider> { DefaultDispatcherProvider() }
    single<Analytics> { FirebaseAnalyticsTracker(FirebaseAnalytics.getInstance(androidContext())) }
}

val databaseModule = module {
    single { Room.databaseBuilder(androidContext(), AppDatabase::class.java, "app.db")
        .fallbackToDestructiveMigration(true).build() }
    single { get<AppDatabase>().itemDao() }
    single { ItemRepository(get()) }
}

val viewModelModule = module {
    viewModel { ItemListViewModel(get(), get(), get(), get()) }
    viewModel { params -> ItemDetailViewModel(params.get(), get(), get()) }  // params.get() is positional
}
```

- `single<Interface> { Impl(get()) }` -- expose interface, not concrete class.
- `androidContext()` instead of injecting `Context`.
- DO NOT pull from `GlobalContext` inside classes. Constructor injection everywhere. The ONE exception: composables use `koinViewModel()` / `koinInject()`.

```kotlin
// Composable injection
@Composable
fun ItemDetailScreen(itemId: Long) {
    val viewModel: ItemDetailViewModel = koinViewModel { parametersOf(itemId) }
}
```

---

## 8. Strong skipping and stability

Strong skipping is on by default. Rules:

1. Skipping is per-parameter. Under strong skipping, unproven stability no longer blocks skipping.
2. `@Stable` / `@Immutable` are mostly unnecessary. Keep only when the type has an expensive `equals()` or is in a dependency the compiler can't reason about.
3. Collection fields in state: use `List<T>` (immutable view) or `ImmutableList<T>`. A `MutableList<T>` field destabilises the enclosing data class.
4. Pass only the slice each composable needs, not whole state objects.
5. Read state as late as possible -- `Modifier.offset { offset.value }` defers to layout phase, skipping composition.
6. `derivedStateOf` for computed scalars, not bare `remember`.

Compiler reports for perf investigation:
```kotlin
composeCompiler {
    reportsDestination  = layout.buildDirectory.dir("compose_compiler")
    metricsDestination  = layout.buildDirectory.dir("compose_compiler")
}
```

---

## 9. Testing -- Turbine + MockK + Robolectric

JUnit 4 + Robolectric + MockK + Turbine + `kotlinx-coroutines-test`. No JUnit 5 on the Android side.

### 9.1 ViewModel test scaffold

```kotlin
@OptIn(ExperimentalCoroutinesApi::class)
class ItemListViewModelTest {
    private lateinit var testDispatcher: TestDispatcher
    private lateinit var dispatchers: DispatcherProvider

    @Before fun setup() {
        testDispatcher = StandardTestDispatcher()
        dispatchers = object : DispatcherProvider {
            override val main = testDispatcher
            override val io = testDispatcher
            override val default = testDispatcher
        }
        Dispatchers.setMain(testDispatcher)
    }

    @After fun tearDown() {
        Dispatchers.resetMain()
        clearAllMocks(); unmockkAll()
    }

    @Test fun `load emits items then clears loading`() = runTest(testDispatcher) {
        val vm = ItemListViewModel(mockk(relaxed = true), dispatchers, mockk(relaxed = true))
        vm.state.test {
            assertEquals(ItemListContract.State(isLoading = true), awaitItem())
            val loaded = awaitItem()
            assertFalse(loaded.isLoading)
            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test fun `delete emits ShowInfo effect`() = runTest(testDispatcher) {
        val vm = ItemListViewModel(repository, dispatchers, analytics)
        vm.effects.test {
            vm.dispatch(ItemListContract.Intent.ConfirmDelete(itemId = 1L))
            advanceUntilIdle()
            assertTrue(awaitItem() is ItemListContract.Effect.ShowInfo)
            cancelAndIgnoreRemainingEvents()
        }
    }
}
```

Key points: `StandardTestDispatcher()` + `Dispatchers.setMain(testDispatcher)` gives virtual time. Pass the same dispatcher to `runTest`. Fakes over mocks when the collaborator has behaviour.

### 9.2 Compose UI tests

```kotlin
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ItemListScreenTest {
    @get:Rule val composeRule = createComposeRule()

    @Test fun `empty state shows CTA`() {
        composeRule.setContent { AppTheme { ItemListContent(state = State(isLoading = false), onIntent = {}) } }
        composeRule.onNodeWithTag("add_item_fab").performClick()
    }
}
```

Drive the stateless `Content`, not the stateful `Screen`. `onNodeWithTag` over `onNodeWithText`. Apply `Modifier.testTag("...")` on every interactive element.

### 9.3 Turbine idioms

```kotlin
flow.test { assertEquals(Loading, awaitItem()); assertEquals(Success(data), awaitItem()); cancelAndIgnoreRemainingEvents() }

turbineScope {
    val a = flowA.testIn(this); val b = flowB.testIn(this)
    assertEquals(1, a.awaitItem()); assertEquals("x", b.awaitItem())
    a.cancel(); b.cancel()
}
```

### 9.4 Screenshot tests

Roborazzi is the current choice. Combine with `ComposablePreviewScanner` to turn every `@Preview` into a snapshot test automatically.

---

## 10. Theming -- Material 3 + extended tokens

```kotlin
data class AppExtendedColors(val cardBorder: Color, val subtleText: Color, val activeElement: Color)
val LocalAppExtendedColors = staticCompositionLocalOf { DarkExtendedColors }

val MaterialTheme.appColors: AppExtendedColors
    @Composable @ReadOnlyComposable get() = LocalAppExtendedColors.current

@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    CompositionLocalProvider(LocalAppExtendedColors provides if (darkTheme) DarkExtendedColors else LightExtendedColors) {
        MaterialTheme(colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content)
    }
}
```

- Custom tokens in a `data class` via `staticCompositionLocalOf`, exposed as `MaterialTheme.appColors`.
- `@ReadOnlyComposable` on the getter skips composer check.
- Use M3 role map exhaustively. Dynamic color optional per project.
- Expressive: `MaterialExpressiveTheme { ... }` for interior surfaces. Many components still `@ExperimentalMaterial3ExpressiveApi`.

### Adaptive layouts

```kotlin
NavigationSuiteScaffold(
    navigationSuiteItems = { /* items */ },
    layoutType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(currentWindowAdaptiveInfo()),
) { content() }
```

Swaps between `NavigationBar` / `NavigationRail` / `PermanentDrawer` by `WindowSizeClass`. For list/detail: `ListDetailPaneScaffold` from `adaptive-layout`.

### Edge-to-edge

```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
    enableEdgeToEdge()  // BEFORE setContent
    super.onCreate(savedInstanceState)
    setContent { AppTheme { App() } }
}
```

Apply `Modifier.safeDrawingPadding()` on root scaffold. Cannot opt out on Android 16.

---

## 11. Data layer

### Room
- `@Entity` + `@Dao` + `Repository` + Mapper per table. Domain types have no Room annotations; entities have no business logic.
- KSP only: `ksp("androidx.room:room-compiler")`.
- Suspend / Flow DAOs only. Blocking DAOs are banned.
- `@TypeConverter`s live next to the entity, not in a global `Converters.kt`.

### DataStore
- Preferences DataStore for settings. Proto DataStore only for real schemas.
- Never `runBlocking` to read -- expose `Flow<Prefs>`.
- Encrypted values use `EncryptedSharedPreferences`, not DataStore.

### Networking
- OkHttp 5 baseline. Ktor 3.4 for KMP.
- Certificate pinning: pin SPKI hashes of **intermediate** certificates (not leaf), plus root CA as backup.

---

## 12. Performance

### Baseline Profiles
- Apply `id("androidx.baselineprofile")` to `:app` and `:baselineprofile`.
- Mark UI ready with `ReportDrawnWhen { uiIsReady }`.
- Add a Startup Profile (first-frame subset) for additional gains.

### R8 full mode
```kotlin
release {
    isMinifyEnabled = true; isShrinkResources = true
    proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
```
Remove any legacy `android.enableR8.fullMode=false` from `gradle.properties`.

### Previews
- Always wrap in `AppTheme`. Render the stateless `Content`, not the stateful `Screen`.
- Use `@PreviewLightDark`, `@PreviewScreenSizes`, custom multi-preview annotation.
- `ComposablePreviewScanner` turns previews into screenshot tests.

---

## 13. Gradle -- the `kotlin.compose` plugin is NOT a compiler artifact

In Kotlin 2.x, `alias(libs.plugins.kotlin.compose)` IS the Compose Compiler. Do NOT add `androidx.compose.compiler:compiler` as a dependency or set `kotlinCompilerExtensionVersion`.

Convention plugins go in `build-logic/` included build. `buildSrc` is no longer recommended -- invalidates build cache on every change.

Formatting: `spotless` with ktlint. `./gradlew spotlessApply` before every commit.

---

## 14. Anti-pattern catalog

| # | Anti-pattern | Correct approach |
|---|---|---|
| 1 | `collectAsState()` on anything hot | `collectAsStateWithLifecycle()` always |
| 2 | Suspend function in composable body | `LaunchedEffect` or hoist to VM |
| 3 | `MutableList` in State data class | `List<T>` or `ImmutableList<T>` |
| 4 | `object Foo : Intent` (bare object) | `data object Foo : Intent` |
| 5 | `SharedFlow(replay=0)` for one-shots | `Channel(BUFFERED).receiveAsFlow()` |
| 6 | Hard-coded `Dispatchers.IO` in repo | Inject `DispatcherProvider` |
| 7 | `NavController` inside screen composable | Hoist navigation callbacks to nav graph |
| 8 | Passing `NavBackStackEntry` down | Pull args with `toRoute<Route>()` at entry |
| 9 | `remember {}` for persistence | `rememberSaveable` or hoist to VM |
| 10 | Prophylactic `@Stable`/`@Immutable` | Only after compiler report shows unskippable restarts |
| 11 | Chained `flowOn` calls | One `flowOn` at end of upstream-heavy section |
| 12 | `.value` on StateFlow inside `update {}` | Use the reducer receiver |
| 13 | `ViewModelFactory` | Koin `viewModel { }` IS the factory |
| 14 | `LiveData<Event<T>>` / `SingleLiveEvent` | VM `Channel<Effect>` |
| 15 | `stopKoin()` in production | Koin is app-scoped; never tear down |
| 16 | Strings in code | `stringResource(R.string.foo)`. Effects carry `@StringRes Int` |
| 17 | `core/util` / `core/helpers` | Real domain module or inline next to caller |
| 18 | `@Serializable` route without serialization plugin | Apply `kotlin-serialization` plugin to the module |
| 19 | `material-icons-extended` in release | Pulls 10 MB. Use individual icons from `material-icons-core` |
| 20 | `LazyColumn` items without stable key | Always `key = { it.id }` |

---

## 15. Quick decision tree

> "How do I add a new screen?"

1. `feature/foo/FooContract.kt` -- `State`, `Intent`, `Effect`.
2. `FooViewModel : MviViewModel<...>(State())`. Override `handleIntent`. Observers in `init {}`.
3. Stateless `FooContent(state, onIntent, modifier)` + stateful `FooScreen(onNavigate..., viewModel = koinViewModel())`.
4. Register in `viewModelModule`: `viewModel { FooViewModel(get(), ...) }`.
5. `feature/foo/navigation/FooNavigation.kt` -- `@Serializable data object FooRoute` + `NavGraphBuilder.fooNavGraph(...)`.
6. Wire into `:app`'s `AppNavHost`.
7. Tests: `FooViewModelTest` (MockK + Turbine), `FooScreenTest` (Robolectric + `FooContent`).

> "Where does this code go?"

| Kind | Module |
|---|---|
| Domain type (data class, enum) | `:core:model` |
| Repository + Room DAO + entity | `:core:<domain>` |
| Reusable composable | `:ui:design` |
| Screen, contract, VM | `:feature:<name>` |
| MVI base, dispatchers, analytics | `:core:common` |
| Nav host, DI modules, Application | `:app` |

> "Which effect API?"

| Need | API |
|---|---|
| Collecting a flow | `collectAsStateWithLifecycle()` |
| Suspend work on entry | `LaunchedEffect(key) { }` |
| Non-suspend setup/teardown | `DisposableEffect(key) { onDispose { } }` |
| Computed value from hot state | `derivedStateOf { }` |
| VM effects Channel | `LaunchedEffect(Unit) { vm.effects.collect { } }` |
| Compose state as Flow (rare) | `snapshotFlow { }` |

Attribution

abnegateabnegate
View sourceSee grades on GitHubMore from abnegate →
SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Ship a skill? Prove it's safe.

Free 120-pattern security scan, letter grade, and an embeddable README badge.

Submit a skill

Related Skills

Clean Code

Pragmatic coding standards - concise, direct, no over-engineering, no unnecessary comments

304955 votes

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

286712 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2222 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Writing Plans

Use when you have a spec or requirements for a multi-step task, before touching code

2927051 votes
View all in development →