ViewCompose Diagnostics
1. Correlated event entry point
Install one immutable RenderDiagnostics at a Host or Preview root. Child navigation, lazy, pager,
and overlay sessions inherit that sink and receive process-local session and parent IDs. Passing a
new diagnostics instance to a low-level nested session deliberately starts a new tree.
fun renderDiagnosticsEventSample(): RenderDiagnostics {
return RenderDiagnostics(
collection = RenderDiagnosticCollection(
lifecycle = true,
failures = true,
frameLevel = RenderFrameDiagnosticLevel.Stats,
),
sink = { event ->
when (event) {
is RenderFrameCompleted -> println(event.stats)
is RenderFailureObserved -> println(event.failure.phase)
is RenderSessionStarted,
is RenderSessionActivityChanged,
is RenderSessionEnded,
-> println(event.context)
}
},
)
}
None builds no renderer counters or tree details, Stats builds aggregate counters, and Tree
also builds the bounded tree, patches, warnings, and composition diagnostics. debug controls
logging and slow-operation warnings only; it does not select event collection.
RenderTreeResult currently contains:
stats / structure / warnings: aggregate binding work, tree size, and warnings;tree: the node tree consumed by the renderer, including node type, key, and hierarchy;patches: orderedInsert / Remove / Rebind / Patch / SkipSelf / SkipSubtreerecords for the frame, including parent key, position, moves, and patch type;composition: invalidated, recomposed, and skipped scope counts, plus each scope path, signature, recomposition reason, and Local snapshot.
2. Recomposition reasons
The runtime distinguishes:
InitialCompositionStateInvalidationAncestorInvalidationInputsChangedExplicitRequestStructureChanged
Scope diagnostics are capped at 500 records and signatures are truncated so diagnostic cost does not grow without bound with page size.
3. CompositionLocal diagnostics
uiLocalOf(debugName = ..., debugValueFormatter = ...) supplies a stable name and safe summary.
Built-in core Locals such as Theme, Environment, LifecycleOwner, SavedState, and ContentColor have
explicit names.
The default summary displays only Strings, numbers, Booleans, Chars, and enums directly. Other
objects display only their type and do not invoke an arbitrary application toString(). Crop a
sensitive application value deliberately through a custom formatter, or omit the formatter.
4. Ordering and failure isolation
Subscribed lifecycle starts first. Failures are emitted after recovery is known, and one
RenderFrameCompleted follows every synchronous attempt after lastFrameReport is authoritative.
Activity events represent real transitions; terminal end follows cleanup. Preparation stays silent
until activation. Delivery is synchronous and session-serialized; re-entry fails fast, while a
throwing sink is recorded and disabled without changing recovery or recursively publishing.
5. Alpha migration from callbacks
The alpha API removes all three callbacks and the result-only Local without adapters. Read stats
and trees from RenderFrameCompleted, failures from RenderFailureObserved, or poll
lastFrameReport / lastRenderFailure when no stream is needed.
6. Bounded production failure aggregation
The optional viewcompose-diagnostics artifact now ships BoundedRenderFailureAggregator. Install
it with lifecycle disabled, failures enabled, and frame level None to count recurring structured
failures without activating frame trees or debug inspection. Its fixed privacy fingerprint excludes
messages, application frames, file/line data, raw keys, View text, Local values, causes, and the
original Throwable.
Aggregation is application-owned, thread-safe, and bounded to 64 fingerprints by default with a hard maximum of 128. A 15-minute default monotonic window expires lazily on record or snapshot; least-recently-updated eviction and count saturation are visible in immutable snapshot counters. Storage, consent, scheduling, upload, vendor metadata, and downstream failure policy remain outside the framework. See the module manual.
7. Correlated running-device inspector
Add viewcompose-preview through debugImplementation, foreground the debuggable application, and
choose Inspect Device Diagnostics. The single inspector hard-replaces the earlier source,
highlight, clear, and timing actions. Its Session tree preserves parent/child roles and keeps the
latest committed frame distinct from the latest completed attempt and latest failure. A failure
shows only typed phase, recovery, optional Android View operation, and a bounded exception binary
class name; the original exception, message, cause, stack, key, and application content never cross
the tooling boundary.
The same selected Session owns three views: source candidates, mounted nodes, and finite timing.
Source, node, and timing records can each navigate to their bounded current-project call site.
Every navigable row displays the resolved authored-project location that the navigation action will
open; internal framework stack frames are neither shown as the destination nor opened instead.
Components expose stable viewcompose.deviceDiagnostics.* automation roles; the Demo retains stable
tags for refresh, highlight replacement, timing action, visible timing status, and its deterministic
eight-frame fixture.
Mounted-node highlighting
Request one current mounted-tree snapshot, choose a declarative node, and use Highlight node or Clear highlight. The selected node draws its clipped real Android View boundary for at most five seconds.
Tokens are opaque, process-local, and snapshot-scoped. They contain no application key. A newer snapshot, node replacement, View reuse by another logical owner, Session disposal, or process restart makes them stale. The response distinguishes selected, partially clipped, missing, stale, recycled, hidden, fully clipped, synthetic/unsupported, ended, rejected, and cleared outcomes. Bounds are screen coordinates plus the globally visible clipped rectangle.
The request visits at most 2,048 mounted nodes, returns 512 to depth 64, retains only weak native targets, and serializes at most 256 KiB. Inactive tooling performs no traversal, geometry read, overlay mutation, report write, or listener installation. An active overlay is non-interactive and cannot recompose, invoke application callbacks, change focus or accessibility focus, intercept input, or mutate layout. The Diagnostics → Renderer page includes a unique AndroidView target and a replacement action for deterministic manual validation.
Finite per-node timing
Use Capture timing in the selected Session and trigger the interaction under investigation. The
Diagnostics → Renderer page provides Run 8-frame timing workload; its visible counter advances
from 0/8 to 8/8 so manual acceptance does not depend on an invisible state change.
Each capture stops after at most eight completed frame attempts or two monotonic seconds. It records only executed composition scopes, renderer reconciliation, and direct native binding. Composition and reconciliation report both inclusive and self duration; binding reports direct duration. One opaque capture-scoped node token connects phases without exposing application keys. Skipped scopes perform no timing callback or clock read.
The response retains at most 64 nodes per frame, 512 aggregate records, depth 32, 128 distinct strings of at most 256 characters, and 256 KiB of JSON. It reports attempted and retained clock reads, an empty-pair overhead estimate, unsupported domains, drops, truncation, completion, and the terminal reason. Studio ranks additive self/direct records to avoid double-counting inclusive parents. The first contract deliberately excludes measure/layout/draw, GPU, RenderThread, SurfaceFlinger, image decode, network, database, and external-SDK work; use platform profilers for those domains.
Only one process capture may be active. Ordinary rendering supplies no collector and performs zero per-node clock reads, timing-record allocation, report writes, polling, or recurring observation. The timing result is diagnostic evidence, not a frame-time benchmark: instrumentation overhead and the small finite sample remain visible limitations.
Lazy-session field-use rule
Starting Capture timing requests one immediate structural frame so the selected Session can produce bounded records. Treat that first frame as capture setup unless the interaction itself caused it; do not label its phase durations as the later gesture or mutation. The capture follows the selected logical Session only. When a lazy key leaves the viewport and its Session ends, a newly visible key receives a new Session ID even if RecyclerView reuses the same physical holder or mounted presentation.
For cold lazy-list work, select the exact live parent Session and use Capture next LazyItem. The
process owns at most one arm. It waits at most ten monotonic seconds for a child with that exact
parent, the LazyItem role, and a Session ID above the arm-time floor, then captures one completed
frame. Registration happens before the matched child's initial frame, so timing attaches to the
frame already entering preparation rather than forcing another structural render. Terminal reasons
distinguish matched, duration limit, parent ended, superseded, and capture rejected. The arm never
accepts or serializes an application key, node content, callback, source string, or native object.
Each Session row also carries an opaque process-local physical-container token, and the arm reports the token of its match. Equal tokens across different logical Session IDs prove physical holder reuse without making the token a selector or stable identity. Verify the selected Host's authored source and the matched node types before attribution; a foreground process may contain another valid Host. A Session-ended result remains identity evidence, not an empty performance result. Measure/layout/draw, RenderThread, GPU, and buffer-queue ownership still requires Perfetto or another platform profiler.
The performance.list@5 field recheck applied this rule. Repeated captures resolved the authored
LazyItem source and ranked Text/direct binding inside the selected item frame, while a Host capture
showed no supported phase during the actual pure-scroll interval. Future-item capture then observed
the cold logical Session's first supported frame. Twelve consecutive matches reused physical tokens
9, 8, and 13 across different logical Session IDs, proving that the workload already reuses
holders. A matching platform trace placed cold direct render under RV Scroll, not RV Prefetch,
and retained input/traversal and RenderThread work outside the finite timer. The upgrade was
actionable but partial: it removed the future-Session and holder-creation ambiguities and changed the
next source-level decision, but it could not rank stable-path allocation or repeated call-site cost.
When repeated bounded captures reached that limit, a one-method Debug method trace and matched Release Perfetto traces supplied the next distinction. The method trace found 16 cyclic-rotation calculations per mutation transaction, 945 structural map-equality calls while collecting unchanged environment snapshots, and repeated general dependency-replacement machinery for one-state item observations. Perfetto independently kept the remaining row work inside animation/traversal rather than holder creation. Temporary counters and method-trace switches were hard-cut after attribution; they are not packaged or enabled in optimized Release. This investigation therefore establishes a durable escalation rule: use the bounded Session tool for source and ownership correlation, then switch to a one-method trace or Perfetto when the unresolved distinction is repeated call count, allocation, measure/layout/draw, or platform scheduling. Do not broaden the always-available diagnostic contract merely to duplicate those profiler domains.
8. Demo inspector
Diagnostics -> Renderer provides the render tree, patch timeline, recomposition reasons,
CompositionLocal browser, aggregate metrics, the mounted-node highlight fixture, and the explicit
eight-frame timing workload. Cross-session correlation, production aggregation, real View-boundary
highlighting, finite per-node timing, and the correlated Studio inspector are implemented.
Same-device idle/request performance, weak lifecycle ownership, optimized-Release exclusion, and
isolated Maven consumption have also closed without an accepted regression. The execution record
is retained in the archived diagnostics correlation, inspection, and production observability
plan.
9. Remaining expansion contract
ADR-0021 freezes Phase 1
and its bounded future-session extension.
A failure-only sink activates no frame detail. The optional viewcompose-diagnostics artifact owns
production aggregation; viewcompose-preview owns the shipped request-driven correlated inspector,
highlighting, selected-session timing, and one-shot future-LazyItem timing. The archived list-tail
record owns the extension's no-regression and release-isolation acceptance. A future continuous observer,
new timing domain, or broader device contract requires a new attributed plan and must preserve
ADR-0009's inactive and Release isolation rules.
./gradlew verifyDemoReleaseToolingApk assembles the optimized Demo Release APK and rejects the
device request action, v7 report path, receiver, service registration, or concrete inspection class
in any packaged entry. qaQuick runs this artifact-level gate in addition to the Release runtime
dependency-graph check.