android-expert
House rules and load-bearing patterns for production Android with Jetpack Compose + MVI. Covers the MviViewModel<State,Intent,Effect> contract pattern, DispatcherProvider, collectAsStateWithLifecycle,
SKILL.mdskills/android-expert/SKILL.md
GitHubAndroid 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*Contractholder. UDF only; no two-way binding.- State is a single immutable
data class; intents and effects aresealed interface. - Always
collectAsStateWithLifecycle(), nevercollectAsState(). - Type-safe Navigation Compose routes are
@Serializable data object/data class. String routes are banned. - Hoist
modifier: Modifier = Modifieras the last parameter with a default. - Koin for DI (
koin-compose-viewmodel), not Hilt. - One class per file. Full words in names (
connection, notconn). camelCase acronyms (updateMfa, notupdateMFA).
1. Year-to-year shifts -- get these right
- Compose Compiler is a Gradle plugin (
org.jetbrains.kotlin.plugin.compose). Do NOT addandroidx.compose.compiler:compileror setkotlinCompilerExtensionVersion. Both are obsolete. - Strong skipping is on by default.
@Stable/@Immutableare 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 intoonBackCancelled/onBackCompletedwithNavigationEventState.transitionState.- Edge-to-edge is mandatory at
targetSdk 36.enableEdgeToEdge()beforesetContent { }. 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 viaMaterialExpressiveTheme { ... }. - Room 3.0 is KMP-first, KSP-only, coroutines-required. The 2.x line is still production on pure Android.
@Serializabletyped routes are the only correct way to declare navigation destinations.
2. Module layout
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/utilorcore/helpers.core/commonholds framework primitives. Everything else is domain-grouped. :core:modelhas zero Android dependencies -- data classes + enums only. Liftable to KMP without changes.- No
:data/:domain/:presentationlayered split. Domain-driven modularisation: eachcore/<domain>owns entities, DAOs, repositories, and mappers end-to-end.
3. MVI -- the contract pattern
3.1 The MviViewModel base class
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
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
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
@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)
@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
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
// 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.
@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.ktper feature with routes +NavGraphBuilder.xxxNavGraph(...).:appwires them inAppNavHost. - Apply
org.jetbrains.kotlin.plugin.serializationat every module declaring routes.
6.2 Navigation3 (stable, 1.0)
Back stack is a plain SnapshotStateList<NavKey> -- add, removeLastOrNull, replaceAll directly.
@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.
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 injectingContext.- DO NOT pull from
GlobalContextinside classes. Constructor injection everywhere. The ONE exception: composables usekoinViewModel()/koinInject().
// 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:
- Skipping is per-parameter. Under strong skipping, unproven stability no longer blocks skipping.
@Stable/@Immutableare mostly unnecessary. Keep only when the type has an expensiveequals()or is in a dependency the compiler can't reason about.- Collection fields in state: use
List<T>(immutable view) orImmutableList<T>. AMutableList<T>field destabilises the enclosing data class. - Pass only the slice each composable needs, not whole state objects.
- Read state as late as possible --
Modifier.offset { offset.value }defers to layout phase, skipping composition. derivedStateOffor computed scalars, not bareremember.
Compiler reports for perf investigation:
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
@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
@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
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
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 classviastaticCompositionLocalOf, exposed asMaterialTheme.appColors. @ReadOnlyComposableon 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
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
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.
@TypeConverters live next to the entity, not in a globalConverters.kt.
DataStore
- Preferences DataStore for settings. Proto DataStore only for real schemas.
- Never
runBlockingto read -- exposeFlow<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:appand:baselineprofile. - Mark UI ready with
ReportDrawnWhen { uiIsReady }. - Add a Startup Profile (first-frame subset) for additional gains.
R8 full mode
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 statelessContent, not the statefulScreen. - Use
@PreviewLightDark,@PreviewScreenSizes, custom multi-preview annotation. ComposablePreviewScannerturns 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?"
feature/foo/FooContract.kt--State,Intent,Effect.FooViewModel : MviViewModel<...>(State()). OverridehandleIntent. Observers ininit {}.- Stateless
FooContent(state, onIntent, modifier)+ statefulFooScreen(onNavigate..., viewModel = koinViewModel()). - Register in
viewModelModule:viewModel { FooViewModel(get(), ...) }. feature/foo/navigation/FooNavigation.kt--@Serializable data object FooRoute+NavGraphBuilder.fooNavGraph(...).- Wire into
:app'sAppNavHost. - 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 { } |