Android / KMP baseline
House defaults — apply them without reminders or re-derivation; where the project's actual conventions differ, follow the project:
- DI: Hilt + KSP (or
android-skills:koinwhen the project uses Koin). Async: Coroutines/Flow — noLiveDatain new code. JSON:kotlinx.serialization. Images: Coil. - Network/local: Android uses Retrofit/OkHttp + Room; KMP shared uses Ktor + Room or SQLDelight. Retrofit is Android-only — never in a shared module.
- Modules: feature-vertical packages and modules;
:core:modelhas zero Android deps;:feature:*modules never depend on each other. - Errors: mapped to a domain type at the repository boundary — platform exceptions never leak past it; UI state explicitly models loading / success / error (see
android-skills:android-data-layer).
Skill routing
Load the specific skill for the task, always with the fully-qualified android-skills: prefix — never the short name (compose, koin, …).
| For | Load |
|---|---|
Compose detail — stability, remember, modifiers, side effects, lists, animation, navigation |
android-skills:compose |
| M3 UX — touch targets, adaptive/foldable layouts, accessibility & M3-compliance audit | android-skills:android-ux |
Coroutines & Flow — operators, Channel vs SharedFlow, structured concurrency |
android-skills:kotlin-coroutines, android-skills:kotlin-flows |
| Repository / data layer + error model | android-skills:android-data-layer |
| Networking | android-skills:android-retrofit (Android) · android-skills:kmp-ktor (KMP) |
| Paging | android-skills:paging |
| Image loading | android-skills:coil-compose |
| Preferences / typed local storage | android-skills:datastore |
KMP expect/actual boundary design |
android-skills:kmp-boundaries |
| RxJava → Coroutines/Flow migration | android-skills:rxjava-migration |
| Testing | android-skills:android-testing |
| DI with Koin | android-skills:koin |
| Build logic / convention plugins | android-skills:android-gradle-logic |
| Build speed, kapt → KSP | android-skills:gradle-build-performance |
| Debugging — Logcat, crashes, ANRs, profiling | android-skills:android-debugging |
| AOSP / AndroidX source lookup | android-skills:android-source-search |
| Multi-module visibility & module boundaries | android-skills:modularization |
| Platform PDF annotation / page-object editing (API 36.1 / SDK ext 18) | android-skills:pdf-annotations |
New-project UI convention (greenfield)
For a new project or feature with no established convention. In existing code, match what's already there — see Reuse the project's existing mechanism below.
The UI layer is MVVM with an MVI-style state/effect split:
- One immutable
UiStateper screen, exposed asStateFlow<UiState>and structured with the four buckets below. The content composable renders it and emits callbacks — nothing else. - One effects stream for fire-once imperatives — navigate, snackbar/toast, scroll-to, share-sheet, haptics. Use
Channel(Channel.BUFFERED).receiveAsFlow(), notSharedFlow: an effect emitted while the screen is backgrounded buffers and replays on resume instead of being dropped. Collect it in aLaunchedEffect(lifecycle-scoped viarepeatOnLifecycle), nevercollectAsStateWithLifecycle. Channel-vs-SharedFlowrationale:android-skills:kotlin-flows. - State vs effect — "does it survive a config change?" Anything still true after rotation / process death is state (an error to show = a field in
UiState); anything the UI runs once and forgets is an effect. This is thedurable-state-over-eventsrule incompose/references/state-management.md: keep durable things in state; the effect stream is only for one-shot imperatives. - Promote callbacks to a
@Stable Actionsinterface at ~4–5+ (or when the same set is threaded through several composable layers). Below that, individual lambdas are simpler — don't abstract early. The ViewModel implements the interface; the content composable depends onFooActions, never the ViewModel, so it stays pure and previewable (a no-opobject : FooActions {}in previews).
data class FooUiState(/* the four buckets — see below */)
sealed interface FooEffect {
data class NavigateTo(val id: String) : FooEffect
data class ShowSnackbar(val message: String) : FooEffect
}
@Stable // promote here once lambdas pile up (~4-5+)
interface FooActions {
fun onItemClick(id: String)
fun onRefresh()
}
class FooViewModel(/* … */) : ViewModel(), FooActions {
private val _uiState = MutableStateFlow(FooUiState())
val uiState: StateFlow<FooUiState> = _uiState.asStateFlow()
private val _effects = Channel<FooEffect>(Channel.BUFFERED) // not SharedFlow — buffers while backgrounded
val effects = _effects.receiveAsFlow()
override fun onItemClick(id: String) { /* _uiState.update { … } */ _effects.trySend(FooEffect.NavigateTo(id)) }
override fun onRefresh() { /* … */ }
}
@Composable
fun FooScreen(viewModel: FooViewModel = hiltViewModel(), onNavigate: (String) -> Unit) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effects.collect { effect ->
when (effect) {
is FooEffect.NavigateTo -> onNavigate(effect.id)
is FooEffect.ShowSnackbar -> { /* show snackbar */ }
}
}
}
}
FooContent(uiState = uiState, actions = viewModel) // VM passed as FooActions — FooContent sees only the interface
}
Kotlin 2.4+: collapse the
_uiState/uiStatepair with explicit backing fields (val uiState: StateFlow<FooUiState>+field = MutableStateFlow(…)) —uiStateonly, never the effectsChannel. Full idiom + version gate:android-skills:kotlin-flows.
Four-bucket state modeling
Screens with rich interactions (forms, calculators, multi-step wizards) get unmanageable when state is one flat data class. Slice UiState into four explicit buckets, and derive computed values as class properties, not constructor parameters:
data class CheckoutUiState(
// 1. Editable input — what the user types
val email: String = "",
val cardNumber: String = "",
// 3. Persisted snapshot — last value read from the repository / stored cross-screen
val savedShippingAddress: Address? = null,
// 4. Transient UI-only — flags that must NOT survive the screen
val isSubmitting: Boolean = false,
val showCardScannerOverlay: Boolean = false,
) {
// 2. Derived — getters, NOT constructor params, so no caller can copy() into an
// inconsistent state (e.g. emailValid = false next to a valid email).
val emailValid: Boolean get() = email.isValidEmail()
val canSubmit: Boolean get() = emailValid && cardNumber.passesLuhn() && !isSubmitting
}
The bucket dictates lifecycle and persistence, not the field. Persisting isSubmitting keeps the spinner forever after process death; computing canSubmit outside the class lets it drift from the inputs; persisting cardNumber cross-screen leaks PII. Mixing the buckets produces bugs that look architectural.
UI state and UI models live in different files
UiState is the screen's contract with its ViewModel, not a UI model — it gets its own file (or the ViewModel's, if that's the project's layout). Never sweep UI models into it. "Each type has a single owner / it's one unit of change / the real seam is role-per-file" argues for exactly this mistake: the state and the models it holds are different roles.
Group the models by composition, never by screen:
- Models composing one bigger model share that model's file, named after the bigger model —
ChallengeDetailUi.ktholdsChallengeDetailUiplus theChallengeTaskUi/SponsorshipUiit is built from. - Independent models each get their own file.
FooModels.kt/FooUiModels.ktis never the answer — reaching for a grab-bag name proves no aggregate root was found, which means the types are independent and belong in their own files.
Kotlin's "related declarations may share a file named after the primary declaration" is the mechanism, not a licence to nominate the state as that primary declaration and sweep the models in behind it.
Reuse the project's existing mechanism
Before adding any new mechanism — an event dispatcher, an effects Channel/SharedFlow, a use-case layer, or a parallel state field — open a sibling ViewModel in the same feature and reuse what's already there. The easy miss here is duplicating an existing mechanism instead of widening it — adding a second shouldDisplayUndoX flag beside the existing one rather than generalizing the one that's there. If existing code contradicts a "best practice," follow the code and flag the inconsistency; never silently override the project's architecture.
Comments — earn every one
The test for every comment: could a reader quickly infer what it says from the code beside it? If yes, it's redundant — delete it. A comment survives only by carrying what the code cannot: a non-obvious why — a decision, constraint, workaround, or gotcha. Never narrate what the code does; clear names and small functions already say it. "What a well-known type or call does" is a what the reader can look up, not a why.
Write the fewest comments that pass that test — this holds even when a task says "make it readable" or "for juniors." Readability comes from naming and structure; a comment a newcomer needs in order to follow what the code does is a signal to rename or extract, not to annotate.
Keep a genuine why (// rethrow first — a broad catch would swallow CancellationException), a justifying comment at a surprising call site, KDoc on a public API that adds information beyond its signature, and TODO(owner-or-link). Honor an explicit request for documentation.
Delete on sight — each is trivially inferable from the code beside it:
// ---- domain model ---- // section-divider / banner (any width)
/** The user profile as the app cares about it. */ // KDoc restating the class name
val uiState = _uiState.asStateFlow() // private mutable, public read-only (restates the idiom)
} catch (e: IOException) { // no connectivity, timeout, DNS failure (restates what the type means)
KMP
Inject a CoroutineDispatcher everywhere rather than calling Dispatchers.Main / Dispatchers.IO directly: Dispatchers.Main isn't guaranteed on every KMP target without the -ktx artifacts, and injection is also what makes dispatcher-swapped tests possible. Use expect/actual for platform specifics (file I/O, push tokens, biometrics); on iOS prefer immutable shared state.