| 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
rootProject.name = "app"
include(":app")
include(":core:common")
include(":core:model")
include(":core:database")
include(":core:network")
include(":core:preferences")
include(":ui:design")
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
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)
val effects: Flow<Effect> = _effects.receiveAsFlow()
protected val currentState: State get() = _state.value
fun dispatch(intent: Intent) {
viewModelScope.launch { handleIntent(intent) }
}
protected abstract suspend fun handleIntent(intent: Intent)
protected fun setState(reduce: State.() -> State) {
_state.update(reduce)
}
protected suspend fun sendEffect(effect: Effect) {
_effects.send(effect)
}
}
3.2 The *Contract holder
object ItemListContract {
data class State(
val items: List<ItemUiModel> = emptyList(),
val favorites: List<ItemUiModel> = emptyList(),
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val error: UiError? = null,
val searchQuery: String = "",
val deleteConfirmation: Long? = null,
)
sealed interface Intent {
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 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(
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,
private val analytics: Analytics,
private val crashReporter: CrashReporter,
) : MviViewModel<ItemListContract.State, ItemListContract.Intent, ItemListContract.Effect>(
initialState = ItemListContract.State(),
) {
init { observeItems() }
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) {
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)
setState { copy(error = UiError(messageRes = R.string.items_message_delete_failed)) }
}
}
}
3.4 The screen composable
@Composable
fun ItemListScreen(
onNavigateToDetail: (Long) -> Unit,
onNavigateToAddItem: (String?) -> Unit,
viewModel: ItemListViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
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,
onClick: () -> Unit,
isSelected: Boolean = false,
modifier: Modifier = Modifier,
) { ... }
4. DispatcherProvider
interface DispatcherProvider {
val main: CoroutineDispatcher
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
combine(searchQuery.debounce(300).distinctUntilChanged(), reachability) { q, reach ->
buildUiModel(q, reach)
}.onEach { setState { copy(models = it) } }.launchIn(viewModelScope)
queryFlow.flatMapLatest { q -> repository.search(q) }
val uiState: StateFlow<UiState> = repository.observe()
.map { it.toUiState() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)
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.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.
@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()) }
}
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().
@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 / @Immutable are mostly unnecessary. Keep only when the type has an expensive equals() or is in a dependency the compiler can't reason about.
- Collection fields in state: use
List<T> (immutable view) or ImmutableList<T>. A MutableList<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.
derivedStateOf for computed scalars, not bare remember.
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 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
NavigationSuiteScaffold(
navigationSuiteItems = { },
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()
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 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
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?"
feature/foo/FooContract.kt -- State, Intent, Effect.
FooViewModel : MviViewModel<...>(State()). Override handleIntent. Observers in init {}.
- Stateless
FooContent(state, onIntent, modifier) + stateful FooScreen(onNavigate..., viewModel = koinViewModel()).
- Register in
viewModelModule: viewModel { FooViewModel(get(), ...) }.
feature/foo/navigation/FooNavigation.kt -- @Serializable data object FooRoute + NavGraphBuilder.fooNavGraph(...).
- Wire into
:app's AppNavHost.
- 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 { } |