gallery/vendor/i-slint-backend-android-activity-1.18.1/lib.rs
Greg Pomerantz 2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00

427 lines
19 KiB
Rust

// 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;
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 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 keeps the Mali
// present path warm (PRE ≈ 0.4 ms) and locks the display
// cadence. Re-rendering unchanged content is cheap with
// Skia's incremental pipeline.
self.window.pending_redraw.take();
saw_wake
} else {
self.window.pending_redraw.get()
};
if do_render {
self.window.pending_redraw.take();
// RENDER_AHEAD: activate the hold-without-present pipeline
// only for locked (animating) renders; an unlocked render
// presents immediately (see RENDER_AHEAD_ACTIVE).
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
.store(locked, 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(),
);
}
}
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(())
}