Skip to content
cccskills
FreePublished on GitHub

compose-performance

RIPDPI Compose performance: stability annotations, compiler metrics, LazyColumn key/contentType, per-screen recomposition notes. Use when diagnosing recomposition, stability, or list performance here.

The English summary is being prepared. Showing the original description.

See how to install

Files included (1)

  • SKILL.md7.6 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

Compose Performance -- RIPDPI

1. Stability system

The Compose compiler decides at compile time whether each class is stable (safe to skip recomposition when equal) or unstable (must always recompose). Classes from non-Compose modules (protobuf, java.time, Room entities) are unstable by default unless declared in the stability config.

@Immutable -- all public properties never change after construction. Use for sealed-class hierarchies and pure value types (DiagnosticsUiModels, HistoryUiModels, theme tokens like Color, Spacing, Shape, Surface, Motion).

@Stable -- property reads return equal values between recompositions, or the runtime is notified on change. Use for state holders with callbacks or MutableState (SettingsUiModels: DesyncCoreUiState, ProxyNetworkUiState, etc.).

Project conventions

  • activities/DiagnosticsUiModels.kt -- 60+ annotations, @Immutable for data, @Stable for models with callbacks.
  • activities/SettingsUiModels.kt -- @Stable on all section state holders.
  • activities/HistoryUiModels.kt -- @Immutable on all models (pure data).
  • activities/MainViewModel.kt -- @Immutable on sealed states.
  • ui/theme/ -- @Immutable on all token classes.

Rule: every new class passed to a @Composable MUST be annotated. Explicit annotation prevents regressions when a field type changes.

Stability configuration file

app/compose-stability.conf marks external types as stable:

  • com.poyka.ripdpi.proto.*, com.google.protobuf.GeneratedMessageLite
  • java.time.{Instant,Duration,LocalDate,LocalDateTime,ZonedDateTime,ZoneId}
  • kotlin.time.Duration, kotlinx.collections.immutable.*
  • com.poyka.ripdpi.data.{TcpChainStepModel,UdpChainStepModel,ActivationFilterModel,NumericRangeModel}

Add new non-Compose-module types here rather than wrapping in UI models.

2. Compiler metrics

Convention plugin ripdpi.android.compose.gradle.kts enables reports when CI=true or -Pripdpi.composeReports=true:

./gradlew :app:assembleGithubFullRelease -Pripdpi.composeReports=true

Output: app/build/compose-reports/ and app/build/compose-metrics/.

FilePurpose
*-composables.txtRestartable/skippable status and param stability
*-classes.txtStability of every class seen by the compiler
*-composables.csvMachine-readable, diff between commits
*-module.jsonModule summary: skippable %, restartable count

Look for "not skippable" composables (unstable parameter) and "unstable" classes (often a List<T> that should be ImmutableList<T>).

3. Recomposition debugging

TrackRecomposition (built-in, ui/debug/TrackRecomposition.kt)

  • TrackRecomposition(tag) -- SideEffect counting recompositions. Already in HomeScreen. Add to any suspect composable.
  • RecompositionReportEffect(intervalMs) -- periodic logcat dump. Place once in root composable. Markers: ! (delta>5), !!! (delta>20).
  • Filter: adb logcat -s RecomposeTracker:D RecomposeReport:I *:S
  • No-op in release builds (gated on BuildConfig.DEBUG).

Layout Inspector

Enable "Show Recomposition Counts" in the toolbar. High recomposition count with low skip count = hot path that needs investigation.

Compose runtime tracing

The project includes androidx-compose-runtime-tracing. Capture a Perfetto trace and open in ui.perfetto.dev, filter for Compose: slices.

4. LazyColumn optimization

Keys and contentType

Every items() call MUST provide key. Without keys, Compose recreates item state on every list change. Also provide contentType when mixing different item structures -- the slot pool is keyed by content type.

Project status:

  • History screen family (HistorySections.kt): key = { it.id }, contentType = { "connection_session" } / { "diagnostics_session" } / { "event" }
  • DiagnosticsLiveSection: key = { it.label }, contentType = { "trend" }
  • LogsScreen: key = { _, entry -> entry.id }, contentType = { _, _ -> "log_entry" }

Lambda captures

Avoid new lambda instances inside items {}. Hoist callbacks:

// Bad: new lambda per recompose
items(list, key = { it.id }) { item ->
    Row(onClick = { viewModel.onClick(item.id) })
}
// Good: stable outer lambda
val onClick = remember(viewModel) { { id: String -> viewModel.onClick(id) } }
items(list, key = { it.id }) { item ->
    Row(onClick = { onClick(item.id) })
}

Derived keys for a truncated live preview

DiagnosticsScanSection's live-probe preview (livePreviewProbes) is a remember-memoized reversed().take(N) window over the growing completedProbes list, so the raw display index is not stable across new completions. The list key uses liveProbeItemKey(completedProbeCount, previewIndex): Int = completedProbeCount - previewIndex - 1, which resolves to each probe's own append-order position in the untruncated list rather than its position in the preview window -- that position never changes once assigned, and it no longer risks colliding on repeated target/outcome pairs the way a literal "$index-${probe.target}-${probe.outcome}" key would. Follow this pattern (a stable position derived from a monotonically growing count, not the display index) for any other reversed/truncated live-preview list.

5. Project-specific screen performance

HomeScreen

  • rememberInfiniteTransition for connecting-state pulse -- correctly scoped to ConnectionState.Connecting, stops when idle.
  • animateColorAsState, animateFloatAsState for press feedback.
  • AnimatedContent for state transitions.
  • Already instrumented with TrackRecomposition("HomeScreen").
  • Watch: entire screen recomposing on connection state change. MainUiState is @Immutable, so parameters should remain stable.

DiagnosticsScreen (~1200 lines)

  • HorizontalPager with multiple tabs, each containing a LazyColumn.
  • derivedStateOf for event filtering -- correct pattern.
  • LazyRow chips with proper keys and contentType.
  • Watch: off-screen page recomposition. HorizontalPager defaults to beyondBoundsPageCount = 0 -- do not increase it.

LogsScreen

  • Streaming viewer, potentially thousands of entries.
  • derivedStateOf for FAB visibility based on scroll position.
  • LazyColumn has key but no contentType (fix opportunity).
  • ViewModel converts to ImmutableList<LogEntry> before UI exposure.
  • Watch: entries.size in header causing parent recomposition on each new log line. Int is stable, but keep parent scope narrow.

AdvancedSettingsScreen

  • RipDpiSettingsScaffold wrapping LazyColumn, ~20 expandable sections.
  • Each section is item(key = "..."), all state holders are @Stable.
  • Architecture passes individual section state (DesyncCoreUiState, etc.) so each section only recomposes when its own state changes.

History screen family

  • Three tabs, each with a LazyColumn. The items() calls with key + contentType live in HistorySections.kt, not HistoryScreen.kt.
  • Models are @Immutable. Uses derivedStateOf for empty-state detection.

6. Quick wins checklist

  • Run compiler reports and diff -composables.csv against baseline
  • Verify new UI model classes have @Immutable or @Stable
  • New compose-stability.conf entries must match actual immutability
  • New LazyColumn: always provide key and contentType
  • Hoist and remember lambdas passed to LazyColumn items
  • Use ImmutableList for list params to @Composable, not List<T>

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

RIPDPI's own Compose conventions: ViewModel pattern, Route/Screen split, DataStore->StateFlow, RipDpiThemeTokens. Use for how this app does Compose, not generic Compose API questions (see compose).

The English summary is being prepared. Showing the original description.

po4yka/RIPDPI81Updated Oct 20, 2026

ADB-based RIPDPI device/emulator debugging: build/install/launch, logcat filtering, fixture port-forwarding, instrumented tests, crash/ANR triage. Use when debugging on a real device or emulator.

The English summary is being prepared. Showing the original description.

po4yka/RIPDPI81Updated Oct 20, 2026

RIPDPI Appium launch-contract reference: start routes and permission/service/data presets. Use when a test launches to the wrong screen or a new automation route is added. General flakiness: appium-test-debug.

The English summary is being prepared. Showing the original description.

po4yka/RIPDPI81Updated Oct 20, 2026

RIPDPI Appium test authoring: page objects, resource-id locators, assertions, wait tiers. Use when writing a new Appium test or page object, or adding coverage for a new screen.

The English summary is being prepared. Showing the original description.

po4yka/RIPDPI81Updated Oct 20, 2026

RIPDPI Appium failure triage: flaky tests, locator/session/wait issues, screenshot and element-tree debugging. Use when an Appium test fails or is flaky.

The English summary is being prepared. Showing the original description.

po4yka/RIPDPI81Updated Oct 20, 2026

Add or modify a GitHub Actions job. Use when editing a file under .github/workflows/. Not for running a workflow locally (local-ci-act) or release signing (release-signing).

The English summary is being prepared. Showing the original description.

po4yka/RIPDPI81Updated Oct 20, 2026

See all skills by po4yka

Report a problem with this skill