gallery/vendor/i-slint-backend-android-activity-1.18.1/lib.rs
Greg Pomerantz c72bfa481d 120Hz hunt: EW wake probe + poll/render CPU-time splits; sltest baseline
Measurements only, no behavior change:
- vsync.rs: EW lines (every early-waker landing, EW_ON flag, off by default)
- lib.rs/stg.rs: IT lines gain dec (locked/saw_wake/pr_before/do_render) and
  pcpu (CPU us inside poll_events — 2% = asleep in epoll, rules out CPU
  spin/deschedule as the wake latency)

Findings (docs/BUGS/hypotheses-120hz.md, sltest baseline):
- Choreographer callback fires at edge+0.52ms (p90 0.65) — the 'late
  Choreographer' model from earlier sessions was a mislabeled STG field
  (poll block time, not callback latency). The pipeline actually works at
  median: present at edge+~4ms, inside the 5.5ms deadline.
- sltest zero-jank definition measured: 98.8% of frames land in the next
  display slot; residual = 0.5-0.7% drops + 0.7-0.9% doubles + 1.4ms
  phase spread (p10-p90) = the visible micro-judder.
- Residual cost is VARIANCE, not median: per-frame render 3.5ms median
  (SKD 1.07 + CLR 0.8 + PRE 1.0 + SKF/ORD 0.65) with spikes to 10.7ms
  (SKD max 5.2, CLR max 3.2, PRE max 2.5). Spikes push presents past the
  deadline (drops) and shake the phase (judder).
Next: classify spikes (CPU work vs GPU/driver block) via render-window
CPU splits, then kill the top source.
2026-10-30 11:59:39 -04:00

504 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
// cSpell: ignore androidwindowadapter javahelper
#![doc = include_str!("README.md")]
#![doc(html_logo_url = "https://slint.dev/logo/slint-logo-square-light.svg")]
#![cfg_attr(not(target_os = "android"), allow(rustdoc::broken_intra_doc_links))]
#![cfg(target_os = "android")]
#![cfg_attr(slint_nightly_test, feature(non_exhaustive_omitted_patterns_lint))]
#![cfg_attr(slint_nightly_test, warn(non_exhaustive_omitted_patterns))]
mod androidwindowadapter;
mod javahelper;
mod vsync;
pub mod stg;
#[cfg(all(not(feature = "aa-06"), feature = "aa-05"))]
pub use android_activity_05 as android_activity;
#[cfg(feature = "aa-06")]
pub use android_activity_06 as android_activity;
#[cfg(all(not(feature = "aa-06"), feature = "aa-05"))]
use ndk_08 as ndk;
#[cfg(feature = "aa-06")]
use ndk_09 as ndk;
pub use android_activity::AndroidApp;
use android_activity::PollEvent;
use androidwindowadapter::AndroidWindowAdapter;
use core::ops::ControlFlow;
use core::time::Duration;
use i_slint_core::api::{EventLoopError, PlatformError};
use i_slint_core::platform::{Clipboard, WindowAdapter};
use i_slint_renderer_skia::SkiaRendererExt;
use std::cell::RefCell;
use std::rc::{Rc, Weak};
use std::sync::{Arc, Mutex};
thread_local! {
static CURRENT_WINDOW: RefCell<Weak<AndroidWindowAdapter>> = RefCell::new(Default::default());
}
pub struct AndroidPlatform {
app: AndroidApp,
window: Rc<AndroidWindowAdapter>,
event_listener: Option<Box<dyn Fn(&PollEvent<'_>)>>,
context: core::cell::OnceCell<i_slint_core::SlintContextWeak>,
}
impl AndroidPlatform {
/// Instantiate a new Android backend given the [`android_activity::AndroidApp`]
///
/// Pass the returned value to [`slint::platform::set_platform()`](`i_slint_core::platform::set_platform()`)
///
/// # Example
/// ```
/// #[cfg(target_os = "android")]
/// #[unsafe(no_mangle)]
/// fn android_main(app: i_slint_backend_android_activity::AndroidApp) {
/// slint::platform::set_platform(Box::new(
/// i_slint_backend_android_activity::AndroidPlatform::new(app),
/// ))
/// .unwrap();
/// // ... your slint application ...
/// }
/// ```
pub fn new(app: AndroidApp) -> Self {
let window = AndroidWindowAdapter::new(app.clone());
CURRENT_WINDOW.set(Rc::downgrade(&window));
Self { app, window, event_listener: None, context: Default::default() }
}
/// Instantiate a new Android backend given the [`android_activity::AndroidApp`]
/// and a function to process the events.
///
/// This is the same as [`AndroidPlatform::new()`], but it allow you to get notified
/// of events.
///
/// Pass the returned value to [`slint::platform::set_platform()`](`i_slint_core::platform::set_platform()`)
///
/// # Example
/// ```
/// #[cfg(target_os = "android")]
/// #[unsafe(no_mangle)]
/// fn android_main(app: i_slint_backend_android_activity::AndroidApp) {
/// slint::platform::set_platform(Box::new(
/// i_slint_backend_android_activity::AndroidPlatform::new_with_event_listener(
/// app,
/// |event| { eprintln!("got event {event:?}") }
/// ),
/// ))
/// .unwrap();
/// // ... your slint application ...
/// }
/// ```
pub fn new_with_event_listener(
app: AndroidApp,
listener: impl Fn(&PollEvent<'_>) + 'static,
) -> Self {
let mut this = Self::new(app);
this.event_listener = Some(Box::new(listener));
this
}
}
impl i_slint_core::platform::Platform for AndroidPlatform {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> {
Ok(self.window.clone())
}
fn run_event_loop(&self) -> Result<(), PlatformError> {
let ctx = self
.context
.get()
.and_then(|ctx| ctx.upgrade())
.expect("the event loop runs inside the context that owns this platform");
// 120Hz scroll fix (docs/BUGS/scroll-jank-120hz.md): enable the
// render-ahead pipeline in the wgpu surface. It is only ACTIVE per-
// frame while `locked` (VSYNC_LOCKED_RENDER && animating); unlocked
// renders always present immediately. See RENDER_AHEAD / RENDER_AHEAD_ACTIVE.
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD
.store(true, std::sync::atomic::Ordering::Relaxed);
let vsync = vsync::VsyncDriver::new(self.app.create_waker());
// RENDER_AHEAD flush: tracks the previous iteration's locked state so
// that when animation ends we do one final render() — which presents
// the frame held by the render-ahead pipeline (otherwise the last
// content change would never be displayed).
let mut prev_locked = false;
loop {
let mut timeout = ctx.duration_until_next_timer_update();
// While animating, the vsync thread wakes this loop at each display refresh
// so rendering tracks the refresh rate instead of a fixed poll timeout.
//
// We also treat an ACTIVE POINTER DRAG as "animating" (see
// stg::pointer_recently_active): during a direct drag Slint has no
// physics animation, so has_active_animations() is false and, without
// this, the loop would sleep until the next timer (16 ms) between
// pointer events → 16-30 ms frame gaps. Keeping the vsync driver
// alive makes poll_events wake at each display refresh instead.
let animating = self.window.window.has_active_animations()
|| stg::pointer_recently_active();
vsync.set_animating(animating);
if animating {
// When animating (scrolling/flinging), cap the poll timeout to 6ms
// as a safety net in case a vsync wake is lost. Rendering still
// only happens when `pending_redraw` is set (content actually
// changed) — we do NOT force a redraw here, which would waste
// GPU re-rendering an unchanged scene on every wake.
let frame_duration = Duration::from_millis(6);
timeout = Some(timeout.map_or(frame_duration, |x| x.min(frame_duration)));
}
// `request_redraw()` only raises the flag, so a request issued outside the
// poll would otherwise wait here for an unrelated wakeup.
// EXCEPT in vsync-locked mode: the render is deliberately deferred
// to the vsync WAKE, so a zero timeout here would busy-spin the
// loop at ~100 kHz (timeout event, no wake, flag never consumed)
// for up to 8.3 ms after every input event. Block up to the cap
// instead and let the wake arrive.
let pr_before = self.window.pending_redraw.get();
let locked = stg::VSYNC_LOCKED_RENDER
.load(std::sync::atomic::Ordering::Relaxed)
&& animating;
if pr_before && !locked {
timeout = Some(Duration::ZERO);
}
// Jank-hunt: classify the timeout path to explain the 1451 iters/s
// busy-spin. ZERO = forced by pending_redraw; CAP = 6 ms anim cap;
// NATURAL = the next timer (>= ~7 ms).
if stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) {
let to_ms = timeout.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(f64::MAX);
let kind = if pr_before {
0
} else if animating && to_ms <= 6.0 {
1
} else {
2
};
stg::note_timeout_class(kind);
}
// STG (jank-hunt instrumentation): reset per-iteration marks.
{
let pr = stg::S_RENDER_START.load(std::sync::atomic::Ordering::Relaxed);
let pe = stg::S_RENDER_END.load(std::sync::atomic::Ordering::Relaxed);
if pr != 0 && pe != 0 {
stg::S_LAST_RENDER_US
.store(pe - pr, std::sync::atomic::Ordering::Relaxed);
}
}
let t_start = stg::now_us();
let timeout_ms = timeout.map(|d| d.as_millis() as u64).unwrap_or(u64::MAX);
stg::S_START.store(t_start, std::sync::atomic::Ordering::Relaxed);
stg::S_FIRST_EV.store(0, std::sync::atomic::Ordering::Relaxed);
stg::S_TIMER_US.store(0, std::sync::atomic::Ordering::Relaxed);
stg::S_EVENT_US.store(0, std::sync::atomic::Ordering::Relaxed);
stg::S_N_EVENTS.store(0, std::sync::atomic::Ordering::Relaxed);
let mut r = Ok(ControlFlow::Continue(()));
// Jank-hunt / 120Hz: did this poll return because the vsync driver
// (or any waker) fired? Used to lock rendering to the vsync edge
// (see the render decision below) instead of rendering in reaction
// to input events (which arrive at a random phase in the slot and
// push the present past the ~5.5 ms compositor deadline → every
// frame shown one vsync late → effective 60 Hz + judder).
let mut saw_wake = false;
// H5 (hypotheses-120hz.md): CPU time inside poll_events. wall≫cpu
// ⇒ the UI thread slept/descheduled while the wake byte was
// pending (epoll not woken, or the thread lost the CPU); wall≈cpu
// ⇒ the loop spun inside ALooper.
let __cpu_p0 = stg::thread_cpu_us();
self.app.poll_events(timeout, |e| {
if matches!(e, PollEvent::Wake) {
saw_wake = true;
}
if stg::S_FIRST_EV.load(std::sync::atomic::Ordering::Relaxed) == 0 {
stg::S_FIRST_EV
.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed);
}
let t3_start = stg::now_us();
let t3_cpu_start = stg::thread_cpu_us();
r = self.window.process_event(&e);
let t3_end = stg::now_us();
let t3_cpu_end = stg::thread_cpu_us();
let __dt3 = t3_end - t3_start;
// Jank-hunt: count wake sources. If WAKE events dominate, the
// loop is being woken far more often than vsync (120/s) or input,
// which explains the 1600 iters/s busy-spin.
match e {
PollEvent::Wake => { stg::WAKE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }
PollEvent::Timeout => { stg::TIMEOUT_EV_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); }
PollEvent::Main(android_activity::MainEvent::InputAvailable) => {
stg::INPUT_EV_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
_ => {}
}
// Jank-hunt: identify which MainEvent variant causes the big
// `ev` spikes (pointer input is ~2.5 ms max, so a 20 ms event
// is something else entirely).
if __dt3 > 5_000 && stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) {
let nm = match e {
PollEvent::Main(android_activity::MainEvent::InputAvailable) => "Input",
PollEvent::Main(android_activity::MainEvent::InitWindow { .. }) => "InitWin",
PollEvent::Main(android_activity::MainEvent::TerminateWindow { .. }) => "TermWin",
PollEvent::Main(android_activity::MainEvent::WindowResized { .. }) => "WinResized",
PollEvent::Main(android_activity::MainEvent::RedrawNeeded { .. }) => "RedrawNeeded",
PollEvent::Main(_) => "Main-other",
_ => "NonMain",
};
stg::log_slow_event(nm, __dt3, t3_cpu_end.saturating_sub(t3_cpu_start));
}
stg::S_EVENT_US.fetch_add(__dt3, std::sync::atomic::Ordering::Relaxed);
stg::S_N_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Some(event_listener) = &self.event_listener {
event_listener(&e)
}
});
let t_poll1 = stg::now_us();
let __poll_cpu = stg::thread_cpu_us().saturating_sub(__cpu_p0);
let lv1 = stg::lv_snapshot();
// Unconditionally update timers and animations on every event-loop wakeup
ctx.update_timers_and_animations();
let t2 = stg::now_us();
stg::S_TIMER_US.store(t2 - t_poll1, std::sync::atomic::Ordering::Relaxed);
stg::S_POLL_END.store(t2, std::sync::atomic::Ordering::Relaxed);
if r?.is_break() {
break;
}
let mut render_us = 0u64;
// Jank-hunt / 120Hz: vsync-locked rendering (the native-Android
// model). While animating, input events only MARK the window dirty;
// the render happens on the next vsync WAKE, so every present
// starts at a deterministic phase (vsync edge + wake latency) and
// lands well inside the compositor's ~5.5 ms deadline. Cost: up
// to one slot of extra input latency.
let do_render = if locked {
// Present EVERY vsync while animating (not only on content
// change): the regular 120 Hz present stream locks the
// display cadence. Re-rendering unchanged content is cheap
// with Skia's incremental pipeline.
//
// IMPORTANT: do NOT `pending_redraw.take()` here. The render
// is driven by the vsync wake, but the pending flag also
// carries the motion events' invalidations. Consuming it
// here starves the follow-up unlocked iteration (which runs
// 0.4 ms later, before the next motion event arrives) of
// anything to render, collapsing the cadence to 60 Hz
// (one render per two vsyncs). Letting the flag survive
// means the next iteration renders too; if the content is
// unchanged the extra render is invisible.
//
// FRESHNESS GUARD: with the early waker (vsync.rs), TWO
// wakes can arrive per slot — the early one (~0.8 ms BEFORE
// the vsync edge) and the Choreographer's late fallback
// (~5 ms after it). The first one of each slot renders;
// the second must NOT (double render = 2× the slot budget =
// 60 Hz collapse). The intervals (measured):
// late-after-early = ~5.8 ms → must be REJECTED
// early-after-late = ~7.5 ms → must be ACCEPTED
// same-kind = ~8.33 ms → ACCEPTED
// Threshold `period − 1.5 ms` (≈6.8 ms) sits between 5.8
// and 7.5 with ~0.7-1.0 ms of jitter margin on both sides.
// (A 0.7-period threshold rejects early-after-late and a
// 1.0-period threshold locks the cadence onto the late
// wakes — both measured to fragment the burst.)
let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
let __period_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
.load(std::sync::atomic::Ordering::Relaxed);
let __last = i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
.load(std::sync::atomic::Ordering::Relaxed);
let __min_gap = __period_ns.saturating_sub(1_500_000);
let __fresh = __period_ns == 0
|| __now_ns.saturating_sub(__last) >= __min_gap;
if __fresh {
i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
.store(__now_ns, std::sync::atomic::Ordering::Relaxed);
}
saw_wake && __fresh
} else {
self.window.pending_redraw.get()
};
if do_render {
// Locked (vsync-wake) renders leave the flag set so the
// follow-up unlocked iteration renders too (60 Hz collapse,
// see the do_render note); unlocked renders consume it.
if !locked {
self.window.pending_redraw.take();
}
// RENDER_AHEAD (hold-without-present) is DISABLED: the held
// frame is presented at the start of the NEXT render, i.e.
// after the next vsync wake's Choreographer latency
// (measured 3.2–6 ms on this device) plus the present cost —
// landing at vsync+12.4–15.2 ms against a 13.8 ms latch
// deadline: a 1.4 ms margin that missed 52% of frames
// (one slot late) during real slow scrolls. Presenting at
// the END of the render instead still left 51% one slot
// late (the wake-arrival latency 2.4–6 ms + render 3–9 ms
// make the present phase random). The actual fix is the
// PRESENT GATE: the render still runs on the vsync wake
// (locked pacing kept), but `Queue::present` sleeps to a
// fixed vsync-grid phase (next edge + 1.5 ms, from the
// Choreographer frameTimeNanos), so cadence is exactly one
// period. See `vkprobe::present_gate` and
// docs/BUGS/scroll-jank-120hz.md §9.
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
.store(false, std::sync::atomic::Ordering::Relaxed);
// PRESENT GATE: quantize the present to the vsync grid while
// animating (drag/fling) — including the follow-up UNLOCKED
// renders (see the do_render note above), so every present
// in the burst lands on the same grid phase. One-off
// redraws (taps, model updates) stay ungated for minimum
// latency.
// PRESENT GATE: DISABLED. Sleeping on the UI thread to
// quantize the present proved counterproductive — every ms
// of gate sleep adds to the slot budget (wake 5.3 + core 3
// + gate 3 + present 1 = 12.3 ms > 8.3 ms slot → 60 Hz
// collapse, measured). The early waker (vsync.rs) instead
// moves the RENDER START to the vsync edge, so the present
// lands at a constant natural phase (~edge+3 ms) with no
// sleep at all. The gate code stays for reference.
i_slint_renderer_skia::wgpu_30_surface::vkprobe::PRESENT_GATE_ON
.store(false, std::sync::atomic::Ordering::Relaxed);
stg::S_RENDER_START.store(t2, std::sync::atomic::Ordering::Relaxed);
self.window.do_render()?;
let t3 = stg::now_us();
stg::S_RENDER_END.store(t3, std::sync::atomic::Ordering::Relaxed);
render_us = t3 - t2;
} else {
stg::S_RENDER_END.store(0, std::sync::atomic::Ordering::Relaxed);
}
// RENDER_AHEAD flush (see above): on the locked → unlocked
// transition, render once more to present the held frame.
if prev_locked && !locked {
self.window.pending_redraw.take();
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
.store(false, std::sync::atomic::Ordering::Relaxed);
self.window.do_render()?;
}
prev_locked = locked;
if stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) {
let first_ev = stg::S_FIRST_EV.load(std::sync::atomic::Ordering::Relaxed);
let lv2 = stg::lv_snapshot();
stg::log_iter(
t_start,
t_poll1 - t_start,
stg::S_N_EVENTS.load(std::sync::atomic::Ordering::Relaxed),
if first_ev == 0 { 0 } else { first_ev - t_start },
t2 - t_poll1,
stg::S_EVENT_US.load(std::sync::atomic::Ordering::Relaxed),
render_us,
animating,
timeout_ms,
lv1,
lv2,
stg::flick_snapshot(),
stg::input_snapshot(),
stg::timeout_snapshot(),
stg::wake_snapshot(),
(locked, saw_wake, pr_before, do_render),
__poll_cpu,
);
}
}
Ok(())
}
fn new_event_loop_proxy(&self) -> Option<Box<dyn i_slint_core::platform::EventLoopProxy>> {
Some(Box::new(AndroidEventLoopProxy {
event_queue: self.window.event_queue.clone(),
waker: self.app.create_waker(),
}))
}
fn set_clipboard_text(&self, text: &str, clipboard: Clipboard) {
if clipboard == Clipboard::DefaultClipboard {
self.window
.java_helper
.set_clipboard(text)
.unwrap_or_else(|e| javahelper::print_jni_error(&self.app, e));
}
}
fn clipboard_text(&self, clipboard: Clipboard) -> Option<String> {
if clipboard == Clipboard::DefaultClipboard {
Some(
self.window
.java_helper
.get_clipboard()
.unwrap_or_else(|e| javahelper::print_jni_error(&self.app, e)),
)
} else {
None
}
}
fn bind_context(&self, ctx: i_slint_core::SlintContextWeak, _: i_slint_core::InternalToken) {
let _ = self.context.set(ctx.clone());
let ctx = ctx.upgrade().expect("bind_context called while the SlintContext is still alive");
let color_scheme = match self
.window
.java_helper
.color_scheme()
.unwrap_or_else(|e| javahelper::print_jni_error(&self.app, e))
{
0x10 => i_slint_core::items::ColorScheme::Light, // UI_MODE_NIGHT_NO
0x20 => i_slint_core::items::ColorScheme::Dark, // UI_MODE_NIGHT_YES
_ => i_slint_core::items::ColorScheme::Unknown,
};
ctx.set_color_scheme(color_scheme);
if let Ok(accent) = self.window.java_helper.accent_color() {
ctx.set_accent_color(accent);
}
if let Ok(scale) = self.window.java_helper.font_scale()
&& let Some(size) = javahelper::font_scale_to_logical_length(scale)
{
ctx.set_platform_default_font_size(Some(size));
}
}
fn long_press_interval(&self, _: i_slint_core::InternalToken) -> Duration {
self.window.java_helper.long_press_timeout().unwrap_or(Duration::from_millis(500))
}
}
enum Event {
Quit,
Other(Box<dyn FnOnce() + Send + 'static>),
}
type EventQueue = Arc<Mutex<Vec<Event>>>;
struct AndroidEventLoopProxy {
event_queue: EventQueue,
waker: android_activity::AndroidAppWaker,
}
impl i_slint_core::platform::EventLoopProxy for AndroidEventLoopProxy {
fn quit_event_loop(&self) -> Result<(), EventLoopError> {
self.event_queue.lock().unwrap().push(Event::Quit);
self.waker.wake();
Ok(())
}
fn invoke_from_event_loop(
&self,
event: Box<dyn FnOnce() + Send>,
) -> Result<(), EventLoopError> {
let n = crate::stg::INVK_PUSH_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
crate::stg::log_line(&format!("INVK n={n}"));
self.event_queue.lock().unwrap().push(Event::Other(event));
self.waker.wake();
Ok(())
}
}
pub fn set_requested_graphics_api(
requested_graphics_api: Option<i_slint_core::graphics::RequestedGraphicsAPI>,
) -> Result<(), PlatformError> {
let Some(adapter) = CURRENT_WINDOW.with_borrow(|x| x.upgrade()) else {
return Err(format!("On Android a graphics API for Slint can only be requested after calling slint::android::init()").into());
};
adapter.set_requested_graphics_api(requested_graphics_api);
Ok(())
}