santimattius/performance-compose-skills/compose-architecture

Claude Code and agent skill by santimattius/performance-compose-skills

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

Jetpack Compose architecture patterns: Clean Architecture layering, MVVM/MVI with UiState/UiAction, Screen/Content split, collectAsStateWithLifecycle, stable UI models, and ViewModel scoping rules. Trigger: when structuring a Compose screen, wiring a ViewModel to UI, choosing MVVM vs MVI, handling UiState/UiAction patterns, or reviewing Compose architecture anti-patterns.

Install

npx vskill@latest install santimattius/performance-compose-skills/compose-architecture

SKILL.md

Performance First

Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.

PhaseWhat RunsRestart CostTrigger
CompositionComposable functions, state readsHIGH (whole scope)State read in composable body
LayoutMeasure + placeMEDIUM (subtree)State read in measure lambda
DrawingCanvas commands, graphicsLayerLOW (single node)State read in draw/graphicsLayer lambda
Defer State Reads (decision tree)

Do you need the value during composition?

  • YES → read as T (Composition phase) — accept full restart cost
  • NO → can you defer to layout/draw?
  • Layout-only? → pass () -> T into Modifier.layout/offset { } lambda
  • Draw/transform? → pass () -> T into Modifier.graphicsLayer { } / drawBehind { } lambda

Rule: prefer Modifier.offset { lambda } over Modifier.offset(state.value.dp). Lambda variant defers the read to the layout phase, skipping recomposition entirely.

Architecture Impact on Phases — collectAsStateWithLifecycle vs collectAsState
kotlin
// CORRECT — lifecycle-aware + stable UiState → composables can skip
@Composable
fun CoursesScreen(viewModel: CoursesViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle: stops collection when lifecycle is STOPPED
// (app backgrounded, screen off) — saves CPU and battery
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CoursesContent(state = uiState, onAction = viewModel::onAction)
}
// Stable UiState: all val properties + ImmutableList → composables are skippable
@Immutable
data class CoursesUiState(
val isLoading: Boolean = false,
val courses: ImmutableList<CourseUi> = persistentListOf(), // kotlinx.collections.immutable
val error: String? = null,
)
// WRONG — collectAsState collects forever AND mutable List makes composables non-skippable
@Composable
fun CoursesScreen(viewModel: CoursesViewModel = hiltViewModel()) {
// collectAsState: collects even when app is backgrounded → wasted CPU/battery
val uiState by viewModel.uiState.collectAsState()
CoursesContent(state = uiState, onAction = viewModel::onAction)
}
// Unstable UiState: var property → whole class inferred unstable → composables never skip
data class CoursesUiState(
var isLoading: Boolean = false, // var = UNSTABLE
val courses: List<CourseUi> = emptyList(), // mutable List = UNSTABLE
)

Performance Toolchain

WARNING: NEVER profile in debug builds. Debug disables R8, inlining, and Strong Skipping — numbers are meaningless. Always profile a release build with profileable enabled or a benchmark build type.
ToolUse ForWhen
Baseline ProfilesAOT-compile critical paths (startup, scroll)Ship in release; regenerate per release
Compose Compiler ReportsDetect unstable params, restartable/skippable statusEvery PR; fail CI on new unstable types
Layout Inspector (recomposition counts)See which composables recompose and whyWhen debugging excess recomposition
Composition TracingFrame-level composition timing in Android StudioWhen Layout Inspector is not enough
MacrobenchmarkMeasure startup, frame timing, jank in releasePer-release regression gate

When to Use

Load this skill when any of the following applies:

  • Structuring a new Compose screen (Screen/Content split, state holder choice)
  • Wiring a ViewModel to UI (collectAsStateWithLifecycle, stateIn, UiState shape)
  • Choosing MVVM vs MVI for a feature
  • Designing UiState or UiAction types (stability, sealed hierarchies)
  • Reviewing a screen for architecture anti-patterns (ViewModel in child, one-shot events, unstable state)
  • Scoping ViewModels to navigation destinations
  • Deciding where to hoist state and how low to read it

CMP Applicability

Canonical CMP rules: `../_shared/cmp-platform.md`
Source setStatusNotes
commonMain⚠️MVI/MVVM patterns ✅; Hilt ❌; ViewModel requires lifecycle-viewmodel-compose 2.10.0
androidMain✅Full skill content applies; Hilt available here
iosMain⚠️ViewModel + StateFlow ✅ (lifecycle 2.10+); use Koin or expect/actual factory for DI
desktopMain⚠️Same; also requires kotlinx-coroutines-swing in jvmMain
wasmJsMain⚠️Same lifecycle version gate

Status legend: ✅ fully supported · ⚠️ partial / version-gated · ❌ Android-only.

If using in CMP: Hilt is androidMain-only (kapt). For commonMain DI, use Koin (koin-compose-viewmodel) or a manual expect/actual factory. ViewModel and viewModelScope are multiplatform from org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 2.10.0 — always provide an initializer: viewModel { MyViewModel() }. See `references/cmp-architecture-boundary.md`.


Critical Patterns

1. collectAsStateWithLifecycle — always, never collectAsState

From androidx.lifecycle:lifecycle-runtime-compose. Stops collecting when the lifecycle reaches STOPPED (app backgrounded, screen off), saving CPU and battery. collectAsState() collects forever regardless of lifecycle state.

This is a Strongly Recommended pattern in official Android docs — not optional.

kotlin
// CORRECT
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// WRONG — collects forever
val uiState by viewModel.uiState.collectAsState()
In CMP: requires androidx.lifecycle:lifecycle-runtime-compose ≥ 2.8.0 in commonMain. Below 2.8, use collectAsState() or a expect/actual shim. See references/cmp-architecture-boundary.md.
2. stateIn canonical pattern
kotlin
val uiState: StateFlow<CoursesUiState> = someFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 5 s timeout survives rotation
initialValue = CoursesUiState(),
)

The 5-second timeout keeps the pipeline alive through screen rotations without restarting. It stops when the UI is truly gone (not just rotated). NEVER use SharingStarted.Eagerly — the pipeline stays active even when no UI observes it, wasting CPU/battery.

3. No AndroidViewModel — use ViewModel with constructor injection
kotlin
// CORRECT — framework-decoupled, testable
@HiltViewModel
class CoursesViewModel @Inject constructor(
private val loadCourses: LoadCoursesUseCase,
) : ViewModel() { ... }
// WRONG — couples ViewModel to Android framework, makes testing harder
class CoursesViewModel(application: Application) : AndroidViewModel(application) { ... }
In CMP: ViewModel itself is multiplatform from org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose 2.10.0. Below 2.10, use the expect/actual boundary in references/cmp-architecture-boundary.md. AndroidViewModel is Android-only and must never appear in commonMain.
4. No ViewModel instances in child composables

Pass only the state and lambdas that the child needs. Child composables receiving a ViewModel cannot be previewed, tested independently, or reused. Screen-level composables and navigation destinations are the ONLY valid ViewModel access points.

kotlin
// CORRECT
@Composable
fun CoursesScreen(viewModel: CoursesViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CoursesContent(state = uiState, onAction = viewModel::onAction)
}
@Composable
fun CoursesContent(state: CoursesUiState, onAction: (CoursesAction) -> Unit) { ... }
// WRONG — child receives ViewModel
@Composable
fun CourseCard(viewModel: CoursesViewModel) { ... } // cannot preview or test in isolation
5. Events as state — never one-shot

Model navigation triggers, snackbars, and dialogs as Boolean or sealed class state fields in UiState, not as Channel or SharedFlow events. One-shot events are consumed and lost if the UI resubscribes; state is always replayable.

kotlin
// CORRECT — navigation modelled as state
data class CoursesUiState(
val navigateToCourseId: String? = null, // null = no navigation pending
...
)
// WRONG — event lost if UI resubscribes before consuming
private val _navigationEvent = MutableSharedFlow<String>()
val navigationEvent: SharedFlow<String> = _navigationEvent
6. Stable UI models

All properties MUST be val. Use ImmutableList<T> from kotlinx.collections.immutable or an @Immutable wrapper for list fields. Types with var properties are always inferred unstable — composables receiving them can never be skipped.

kotlin
// CORRECT — fully stable
@Immutable
data class CourseUi(
val id: String,
val title: String,
val isBookmarked: Boolean,
)
@Immutable
data class CoursesUiState(
val courses: ImmutableList<CourseUi> = persistentListOf(),
val isLoading: Boolean = false,
)
// WRONG — var makes the whole class unstable
data class CoursesUiState(
var isLoading: Boolean = false, // var = unstable
val courses: List<CourseUi> = emptyList(), // mutable List = unstable
)
7. Hoist state high, read state low

Hoist to the lowest common ancestor that needs to share state. But READ state as low in the tree as possible — pass () -> T lambdas to children for frequently-changing values so the read happens in the child's scope, not the parent's.

kotlin
// CORRECT — pass lambda; scroll position read only in child scope
@Composable
fun Parent() {
val listState = rememberLazyListState()
Child(firstVisibleIndex = { listState.firstVisibleItemIndex })
}
@Composable
fun Child(firstVisibleIndex: () -> Int) {
val index = firstVisibleIndex() // read deferred to this scope
}
// WRONG — parent reads and passes the value → parent recomposes on every scroll event
@Composable
fun Parent() {
val listState = rememberLazyListState()
Child(firstVisibleIndex = listState.firstVisibleItemIndex) // read HERE = parent recomposes
}
8. @Stable for plain class state holders

For reusable UI component state holders (not ViewModels), use a plain class annotated @Stable with MutableState properties. Do NOT use ViewModel for per-component state holders — they live too long and carry DI overhead.

kotlin
@Stable
class MapState {
private val _location = mutableStateOf<Position?>(null)
val latitude by derivedStateOf { _location.value?.latitude }
val longitude by derivedStateOf { _location.value?.longitude }
fun changeLocation(position: Position) {
_location.value = position
}
}
@Composable
fun rememberMapState(): MapState = remember { MapState() }
9. Screen/Content split

One Screen composable accesses state and handles navigation callbacks (connects to ViewModel); one Content composable renders pure UI given immutable state. The Content composable is stateless, previewable, and testable in isolation.

kotlin
// SCREEN — connects to ViewModel, handles navigation
@Composable
fun CoursesScreen(
viewModel: CoursesViewModel = hiltViewModel(),
onOpenCourse: (String) -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CoursesContent(
state = uiState,
onAction = viewModel::onAction,
onCourseClick = onOpenCourse,
modifier = Modifier.fillMaxSize(),
)
}
// CONTENT — pure, previewable, testable
@Composable
fun CoursesContent(
state: CoursesUiState,
onAction: (CoursesAction) -> Unit,
onCourseClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
// Only renders — no ViewModel access
}
10. _uiState.update { it.copy(...) } for atomic updates

Prevents intermediate inconsistent states under concurrent coroutines.

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

7-day trend score
5

Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/santimattius/performance-compose-skills/compose-architecture/badge)](https://verified-skill.com/skills/santimattius/performance-compose-skills/compose-architecture)

Something wrong with this skill or its page? Tell us.