技能创建器
skill-creator
创建、改进和评估自定义技能,把专业流程转化为可重复使用的 AI 能力。
jetpack-compose-builder【数据与研究】Builds Android Jetpack Compose UI with hoisted state, unidirectional data flow, and recomposition-safe patterns backed by measured stability rules. Use when someone asks "build this screen in Compose", "where should this state live", "why does my LazyColumn stutter", or is writing Composable functions, ViewModel and StateFlow wiring, or fixing recomp…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Build the state and data flow first, the pixels second - most Compose bugs are state-ownership and recomposition bugs, not layout bugs. The costly failure is a screen that works in a demo, then recomposes entire subtrees on every keystroke or scroll frame because state lives in the wrong place and parameters are unstable.
Before writing a composable, establish:
Decide which state is UI-local (remember) versus screen-level (ViewModel exposing StateFlow, collected via collectAsStateWithLifecycle()). Hold mutable state in the lowest common ancestor that needs it - no lower (parent cannot drive it) and no higher (recomposes too wide a scope).
Pass immutable state down as parameters; emit changes up via lambdas (onValueChange, onClick). A composable that owns its own remember { mutableStateOf } cannot be controlled, previewed, or tested by its parent - hoist it. Every leaf composable must render in a @Preview without a ViewModel.
Use remember to cache allocations and computations across recompositions, rememberSaveable to survive config change and process death. Key remember, LaunchedEffect, and derivedStateOf on the inputs they depend on; LaunchedEffect(Unit) where an id should restart the effect is a bug. Wrap derived values whose input changes faster than the output (scroll offset to "show FAB" boolean) in derivedStateOf so downstream recomposition happens only when the result changes.
LaunchedEffect for scoped suspend work, rememberCoroutineScope for event-driven launches, DisposableEffect for cleanup, SideEffect to publish Compose state to non-Compose code. Never launch coroutines or write state in the composable body - state writes during composition throw or loop.
Use LazyColumn/LazyRow with a stable item key (server id, not index) so reorders animate and item state survives. Hoist scroll position with rememberLazyListState when it must be observed or controlled. Prefer slot APIs (content lambdas) over boolean flags. Never nest a lazy list inside a scrollable of the same axis.
Apply the recomposition cost rules below, cheapest first: push state reads down, defer reads past composition, then fix stability.
Capture recomposition counts in Layout Inspector and skippability in the Compose compiler metrics report (enable the compiler's reports output and read the -classes.txt for "unstable" parameters). Fix the composable that actually recomposes too often. Add stability annotations only against that evidence. If frames still miss budget after recomposition is contained, move to mobile-perf-profiler for a full trace.
Compose skips a composable only when the call site's arguments are all stable and unchanged. Work through these in order:
Red line: if Layout Inspector shows a composable recomposing on interactions that do not concern it (typing in an unrelated field recomposes the whole list), that is the bug to fix - not a candidate for micro-tuning.
Bad - unstable params, inline work, index keys:
@Composable
fun MessageList(vm: ChatViewModel) { // child reaches into ViewModel
val messages by vm.messages.collectAsState()
LazyColumn {
items(messages.size) { i -> // index key: reorders reset state
val m = messages[i]
Row {
Text(SimpleDateFormat("HH:mm").format(m.sentAt)) // allocation + parse per recomposition
Text(m.body)
Button(onClick = { vm.retry(m) }) { Text("Retry") } // lambda captures vm per pass
}
}
}
}
Good - stateless, stable keys, display-ready data, hoisted events:
@Immutable
data class MessageUi(val id: String, val timeLabel: String, val body: String)
@Composable
fun MessageList(
messages: ImmutableList<MessageUi>,
onRetry: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier) {
items(messages, key = { it.id }) { m ->
MessageRow(message = m, onRetry = onRetry) // previews without a ViewModel
}
}
}
Time formatting moved to the ViewModel, identity keys keep row state across reorders, and every parameter is stable so unchanged rows skip.
Produce the screen as a state-hoisted composable tree: an immutable UI-state class, a stateless screen composable taking state plus event lambdas, preview functions for the key states (loading, content, error), and - for any performance claim - the Layout Inspector recomposition counts or compiler-metrics lines that justify it.