Gate the Android accessibility publish on assistive-tech state and batch it at Compose parity - #497
Conversation
Device trace: this is the top cost in a real app, not a micro-optimisationTraced cranscan (real app, 15-document library with thumbnails) on the Huawei EVR-AL00, thermals cool and verified unthrottled (GPU 37 °C, CPU 41-42 °C,
Two things fall out: The GPU is not the bottleneck. The cost is not the glass, either. A page with six-to-eight isolated glass layers and one with none have nearly identical CPU per frame (18.89 vs 18.38 ms). Whatever is eating the budget is paid on every screen.
Subtracting just this stage puts both pages under one vsync (13.3 ms and 11.3 ms against a 16 ms budget), which is the difference between quantising to two vsyncs and hitting the panel rate. The mechanism is already A/B-proven on the demo in this PR's own numbers: cpu p50 12.5 → 5.5 ms with the bridge gated. I have not yet built cranscan against this branch, so I am not claiming the fps number — only that the stage this PR removes is the single largest addressable item in that frame, measured. That build is the obvious next verification. Worth noting for prioritisation: this reorders the work in #500. I had assumed cranscan's 15-17 fps was GPU-bound on backdrop blur; the trace says the render-pass and backdrop-cache work there, while real, is second to this. |
9b5fdc7 to
d94793d
Compare
…tch it at Compose parity The Android bridge re-projected, re-encoded, and re-published the whole semantics tree over JNI on every frame whose semantics revision moved — every scrolled frame — whether or not any assistive technology was running, and parsed the payload on the calling frame thread. Measured on a Kirin 980 (EVR-AL00) scrolling the demo lazy list, that stage cost 6.3-6.7 ms of the 16.7 ms frame budget, the largest CPU stage of the loop; removing it dropped frame CPU p50 from 12.7 ms to under 10 ms. CranposeActivity now mirrors AccessibilityManager state into the frame loop, and a tested AccessibilityPublishPolicy publishes nothing while no assistive technology is active and at most one snapshot per 100 ms while one is (Jetpack Compose's recurring-events interval). A publish deferred by the throttle window arms a poll-timeout wake so the trailing tree lands even when the loop goes idle, the Java-side payload parse moved into the posted UI-thread task, and debug.cranpose.a11y_sync / CRANPOSE_A11Y_SYNC forces the bridge off or on for A/B diagnostics. On device with an accessibility service active, the sync stage mean fell from ~6.4 ms to ~0.3 ms and uiautomator still dumps the full virtual tree at the scrolled position. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y on publishes The demo Liquid tab bumps the semantics revision every scrolled frame while the projected elements stay identical. In that path the bridge ran the full snapshot walk and deep compare per frame (sync p50 2.3 ms on the Kirin 980) because only a successful publish started a new throttle window. The probe is the cost being rationed, so try_begin_publish now starts the window itself; measured sync mean fell to 0.55 ms with p50 at zero on the same scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
d94793d to
b88cbb3
Compare
…t finds (#517) * Gate the Android target on clippy, and fix the one real lint it finds Android was the only shipped target with no zero-warning gate. The justfile has clippy (host), clippy-wasm and clippy-ios, all -D warnings; android only runs ./gradlew :app:assembleRelease, which does not deny warnings, and no workflow sets -D warnings for an Android target. Anything behind an android-only cfg reached main unlinted, which is how #497's dead accessor got there. clippy-android mirrors what CranposeAndroidPlugin passes to cargo ndk rather than inventing a feature subset: -p desktop-app-platform --lib --no-default-features --features android,renderer-wgpu, and --platform from the demo's minSdk. missing_const_for_thread_local is allowed for this recipe only. It fires 16 times on Android and never on host, on the same pinned toolchain and source, including on initializers already written as const {} and on one that cannot be const at all. thread_local! expands per-target and the Android expansion defeats the lint's const detection. It stays enabled everywhere else. The needless_return in http.rs is a genuine android-only lint, in the cfg(target_os = "android") branch of configure_native_client_builder. * Lint every Android ABI, and fix the 32 warnings that found Android was the only shipped target with no zero-warning gate. `just android` runs Gradle, which does not deny warnings, and no workflow set `-D warnings` for an Android target, so anything behind an `android`-only `cfg` reached main unlinted. #497's dead accessor is how that surfaced; it was not the only one. `clippy-android` mirrors what `CranposeAndroidPlugin` hands `cargo ndk` rather than inventing a feature subset: `-p desktop-app-platform --lib --no-default-features --features android,renderer-wgpu`, `--platform` from the demo's `minSdk`. CI runs it before the APK build, so a lint failure does not wait behind a full release build, and `ci-full` now names it — along with `clippy-ios`, which that aggregate had also been omitting while claiming to be every gate. It lints all four ABIs `releaseAbis` ships under CI, not just arm64, and that is load-bearing rather than thorough-for-its-own-sake. `libc::timespec::tv_sec` is `i64` on the 64-bit ABIs and `i32` on armeabi-v7a and x86. An arm64-only run reports the `as i64` on that field as an unnecessary cast, and `--fix` duly removed it — which would have been a type error on the two 32-bit ABIs that CI builds. A single-ABI gate would have shipped that. That field has no lint-clean spelling: `as i64` trips `unnecessary_cast` on 64-bit, `i64::from` trips `useless_conversion` there instead, and dropping the widening breaks 32-bit. It keeps `i64::from` and allows the 64-bit complaint on that function alone, which states the widening intent rather than hiding it. The remaining 31 are mechanical and were reviewed rather than trusted: 18 `collapsible_if` (all `let`-chains with no `else`, so equivalent), 12 `unnecessary_cast` on values already `f32`, and one `needless_borrow` passing `&&Adapter`. `missing_const_for_thread_local` is allowed for this recipe only. It fires 16 times on Android and never on host, same pinned toolchain and source, including on initializers already written as `const {}` and on one that cannot be const at all. `thread_local!` expands per-target and the Android expansion defeats the lint's const detection. Enabled everywhere else, so a genuine non-const initializer is still caught by `just clippy`. Verified: `just clippy-android` (four ABIs), `just clippy`, `just test`, `just fmt`, `just android`.
Problem
Scroll on a 2018 Huawei (EVR-AL00, Kirin 980, 60 Hz) is laggy in cranpose apps. On-device per-stage frame telemetry (
debug.cranpose.frame_telemetry) showed thesyncstage — the Android accessibility bridge — costing 6.3–6.7 ms of every 16.7 ms frame while scrolling, the single largest CPU stage of the loop (frame CPU p50 ≈ 12.7 ms, zero headroom).Every frame whose semantics revision moved (= every scrolled frame) did a full layout+semantics tree walk, a full TSV encode, a JNI hop, and a Java-side parse on the frame thread — whether or not any assistive technology was running.
Confirmed by removal: a probe build skipping the sync dropped frame CPU p50 from 12.7 ms to 5.5–9.7 ms.
Fix
accessibility_publish_policy.rs— pure, host-tested policy (9 unit tests): publish nothing while no assistive technology is active; while one is, publish at most once per 100 ms — Jetpack Compose'sSendRecurringAccessibilityEventsIntervalMillis.CranposeActivitymirrorsAccessibilityManagerstate into native (nativeOnAccessibilityStateChanged, registered inonCreate, removed inonDestroy); enable transitions force a full republish.debug.cranpose.a11y_sync/CRANPOSE_A11Y_SYNC(0force off,1force on).Measured on device (demo lazy list, EVR-AL00)
uiautomator dump(reads through the realAccessibilityNodeInfopath) still returns the complete virtual tree at the scrolled position, so screen readers keep working with Compose-equivalent freshness. This device runs a phone-control accessibility service permanently, so the throttled path (not just the off path) is what fixed it here.Gates
just fmt/typos/versions/test/clippy/doc/just androidall green.just budgetsfails on the macOS dev host only (pre-existingobjc2*duplicate families; the CI gate runs on Linux where those crates never resolve — no manifests or lockfile touched here). Robot suite untouched: all changed code is Android-target-gated or test-only.Also included:
TIME_WASTERS.mdentry on Android on-device scroll-perf measurement traps, and a.gitignorefor the gradle-plugin's local build dirs.🤖 Generated with Claude Code