The user's ask: 'put in our own frame rate timer that doesn't drop
frames so we are not depending on the OS.' The OS can still own the
display latch (SF) — render-ahead presents ~1 ms after the edge with
~7 ms margin, so owning the RENDER clock is sufficient.
WHILE ANIMATING, the UI thread no longer waits on any OS wake:
* poll is non-blocking (timeout 0); the loop spins at ~3-4k iters/s
(250 us hrtimer park after each no-render iteration — an hrtimer
park cannot be swallowed the way ALooper timeouts were, measured
4-6 ms late from inside kernel/epoll)
* the render decision is grid-driven: render when in a not-yet-
rendered slot AND >=0.3 ms past its edge. No saw_wake.
* the Choreographer + eventfd remain only as the GRID SENSOR.
Two grid bugs found and fixed while landing this (both made the first
driver build DOUBLES 8-10% of frames):
* ANCHOR POLICY (wgpu_30_surface::note_vsync_edge): VSYNC_EDGE_NS is
a SMOOTH grid anchor, not 'last observed edge'. A callback that
fits the extrapolation (<1.5 ms deviation) does not move the
anchor — a LATE callback re-anchoring shifts the anchor by
(interval - P) and makes an already-rendered slot look NEW to a
value-space guard. Period updates now require single-slot deltas.
* BOOKKEEPING in SLOT-INDEX space (last_rendered_slot), not edge
values, and ALL render paths mark it: locked accept, unlocked
dirty render, and the render-ahead FLUSH (the flush not marking
caused the +1.1/+1.2 true doubles after animating flickers).
* POINTER window 200 -> 400 ms: bursty input (adb drags; finger
pauses) expired 'animating' mid-drag, firing the locked->unlocked
flush on top of the dirty render (double per flicker, ~5%).
* flush now also skips when the iteration already rendered.
Phone, sltest (3s+1s+3s adb drags, jank_eval referee):
buildC (pre-wake-fd-fix): 127 drops + 12 stalls
slt14-spin (this driver): 2 drops + 1 stall + 8 dbl + 4 late
in 978 frames; phase p10-p90 0.86-0.90 ms
gallery (real textures, same driver): drag windows 6+2 drops,
7+11 doubles in 820 frames; phase p10-p90 0.73-0.95 ms
Remaining (visible next): drag-START warmup (first frame 20-40 ms:
CPU clock ramp after idle — the 'fling start jank' the user reports)
and isolated 2-slot drops (gallery: FullCache insert/eviction cost on
the UI thread).
Cost: ~60-70% of one core during active scroll; the perpetual
runnability also forces EAS big-core placement (the thread was
measured at 820 MHz on little cores during scroll — a large part of
the gallery's finger jank).
678 lines
35 KiB
Rust
678 lines
35 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;
|
||
// Edge (ns) of the last vsync delivered to this thread via the
|
||
// eventfd trampoline (vsync.rs). A value newer than this at
|
||
// decision time means a wake was delivered THIS iteration — immune
|
||
// to the ALooper wake-fd swallow (see vsync.rs note).
|
||
let mut last_delivered_edge = 0u64;
|
||
// DRIVER: the slot index (relative to the smooth grid anchor) we
|
||
// last rendered for. One render per SLOT — the double-fire guard
|
||
// for the grid-driven accept. Slot-index space (not edge-value
|
||
// space): a late-callback re-anchor shifts edge VALUES by
|
||
// ~0.2–0.5 ms and would make an already-rendered slot look new.
|
||
let mut last_rendered_slot: i128 = i128::MIN;
|
||
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();
|
||
// Push (animating, dirty) to the vsync driver: the Choreographer
|
||
// callback chain self-sustains while animating OR dirty
|
||
// (vsync.rs) — the dirty leg is what keeps flings alive after
|
||
// pointer inactivity expires the `animating` window.
|
||
let dirty = self.window.pending_redraw.get();
|
||
vsync.set_state(animating, dirty);
|
||
let pr_before = self.window.pending_redraw.get();
|
||
let locked = stg::VSYNC_LOCKED_RENDER
|
||
.load(std::sync::atomic::Ordering::Relaxed)
|
||
&& animating;
|
||
if locked {
|
||
// DRIVER mode (2026-10-30): the render clock is owned by the
|
||
// app, not the OS. The decision is grid-driven (see below) —
|
||
// no wake required — so the poll is NON-BLOCKING (timeout 0);
|
||
// the loop spins at ~3-4k iterations/s (a 250 µs park after
|
||
// each no-render iteration, see end of loop) checking the
|
||
// grid. A 250 µs hrtimer park cannot be lost the way an
|
||
// ALooper timeout wake can (measured: poll(1) returning
|
||
// 4-6 ms LATE from inside kernel/epoll — the residual
|
||
// +4.5-6 ms present population of slt13), and the thread
|
||
// stays HOT (EAS big-core placement; a sleeping thread idles
|
||
// at 820 MHz on little cores). The Choreographer + eventfd
|
||
// stay alive as the GRID SENSOR (VSYNC_EDGE_NS), never as
|
||
// the render trigger.
|
||
timeout = Some(Duration::ZERO);
|
||
} else if animating || dirty {
|
||
// When animating (scrolling/flinging) or dirty, cap the poll
|
||
// timeout to 2ms. (2026-10-30: the cap was 6ms — but
|
||
// ALooper_pollOnce(6) itself returns 2-5 ms LATE on this
|
||
// device (measured: polls of 8.4-10.7 ms with to=6, no
|
||
// context switches — the delay is inside kernel/epoll,
|
||
// not app-side). One lost/late wake then ate a whole slot:
|
||
// render start edge+6-8 ms → present edge+7-9 ms → the
|
||
// residual 0.6% slip-frames. With a 2ms cap, even if a wake
|
||
// is lost to a kernel delay the next poll picks it up
|
||
// within 2 ms → render start edge+2-3 ms → present edge
|
||
// +3-4 ms, inside the slot. Cost: ~200-400 extra idle
|
||
// iterations/s during active scroll, each ~0.1-0.3 ms
|
||
// (no render) ≈ a few % UI CPU. Emulator A/B (2ms vs 6ms,
|
||
// 2026-10-30): no difference — 5-8 drops/capture both ways,
|
||
// vs 47-147/capture for the pre-evfd build, so the evfd
|
||
// wake-fd fix dominates and the shorter cap is kept.)
|
||
let frame_duration = Duration::from_millis(2);
|
||
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.
|
||
if pr_before && !locked {
|
||
timeout = Some(Duration::ZERO);
|
||
}
|
||
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();
|
||
// Wake-fd-race fix (2026-10-30): the ALooper wake byte can be
|
||
// CONSUMED by Looper::pollInner while pollOnce returns a
|
||
// co-batched input ident (system/core/libutils/Looper.cpp — the
|
||
// POLL_WAKE result is a dropped local; see vsync.rs). The
|
||
// dedicated eventfd trampoline runs inside poll_events and
|
||
// publishes the delivered edge there — check it NOW, after.
|
||
let __delivered = vsync.take_delivered_edge();
|
||
if __delivered > last_delivered_edge {
|
||
saw_wake = true;
|
||
last_delivered_edge = __delivered;
|
||
}
|
||
|
||
// 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;
|
||
let mut __render_cpu = 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.
|
||
//
|
||
// SLOT-BASED WAKE GUARD (hypotheses-120hz.md, 2026-07-09 (3)).
|
||
// Replaces the interval freshness guard, which had a 0.2 ms
|
||
// false-reject margin (threshold 6.83 ms vs earliest
|
||
// legitimate interval 7.03 ms after a Choreographer-phase
|
||
// render) and — worse — could not express the real rule.
|
||
// The rule: at most ONE render per display slot. A wake
|
||
// belongs to slot floor((t − E)/P) (E = last observed vsync
|
||
// edge, P = period; a stale E only shifts all indices by a
|
||
// constant, so the comparison is exact). Reject only when
|
||
// the wake's slot is the one already rendered; a wake N
|
||
// slots later (after a spike or lost wakes) MUST render —
|
||
// and the gap is logged as MISS with the wake state, which
|
||
// is the missing evidence for the stall slots.
|
||
let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
|
||
let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
|
||
.load(std::sync::atomic::Ordering::Relaxed);
|
||
let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_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 __slot = |t: u64| -> i128 {
|
||
if t >= __e { ((t - __e) as i128) / (__p as i128) }
|
||
else { -((( __e - t) as i128 + __p as i128 - 1) / (__p as i128)) }
|
||
};
|
||
let __sw = if __p == 0 { 0 } else { __slot(__now_ns) };
|
||
let __sn = if __p == 0 || __last == 0 { i128::MIN } else { __slot(__last) };
|
||
if __p != 0 && __last != 0 && __sw >= __sn + 2
|
||
&& stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed)
|
||
{
|
||
stg::log_miss(__sw - __sn - 1, saw_wake, __now_ns,
|
||
__sw, __sn,
|
||
__now_ns.saturating_sub(__e) / 1000,
|
||
__now_ns.saturating_sub(__last) / 1000);
|
||
}
|
||
// DIRTY BACKSTOP (2026-10-30): if the window is dirty and
|
||
// the last render is >=2 slots old, render NOW regardless
|
||
// of wake state (bounds any grid/sensor outage).
|
||
let __dirty = self.window.pending_redraw.get();
|
||
let __age = __now_ns.saturating_sub(__last);
|
||
let __backstop = __dirty && __last != 0 && __p != 0 && __age >= 2 * __p;
|
||
// DRIVER (2026-10-30): the APP owns the render clock. Accept
|
||
// when we are in a slot we have not rendered yet AND at
|
||
// least 0.3 ms past that slot's edge. No saw_wake: the 1 ms
|
||
// micro-park keeps `now` within ~1.3 ms of the grid, so the
|
||
// present (render-ahead, at render start) lands at
|
||
// edge+~1.5 ms with ~6.8 ms of latch margin, every frame,
|
||
// regardless of Choreographer/wake delivery. The grid is the
|
||
// SMOOTH anchor (note_vsync_edge: a late callback that fits
|
||
// the extrapolation does not move it), so the slot index
|
||
// here is stable across late callbacks — one render per
|
||
// slot, never a double. A render overrun lands the next
|
||
// slot one slot late. If the grid is dead (__p==0 or
|
||
// __now < __e) the driver is off and the saw_wake path
|
||
// governs.
|
||
let __ph = if __p != 0 && __now_ns >= __e {
|
||
(__now_ns - __e) % __p
|
||
} else {
|
||
0
|
||
};
|
||
let __driver = __p != 0 && __now_ns >= __e + 300_000
|
||
&& __ph >= 300_000 && __sw > last_rendered_slot;
|
||
let __wake_ok = saw_wake && (__p == 0 || __sw > __sn);
|
||
let __accept = __driver || __wake_ok || __backstop;
|
||
if __accept {
|
||
i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
|
||
.store(__now_ns, std::sync::atomic::Ordering::Relaxed);
|
||
if __sw > last_rendered_slot {
|
||
last_rendered_slot = __sw;
|
||
}
|
||
}
|
||
__accept
|
||
} 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();
|
||
// Unlocked renders participate in the slot bookkeeping
|
||
// too: without this, a locked→unlocked flicker mid-drag
|
||
// leaves last_rendered_slot stale and the re-locked
|
||
// driver re-renders the slot the unlocked render just
|
||
// painted (double-present).
|
||
let __t =
|
||
i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
|
||
i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
|
||
.store(__t, std::sync::atomic::Ordering::Relaxed);
|
||
let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
|
||
.load(std::sync::atomic::Ordering::Relaxed);
|
||
let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS
|
||
.load(std::sync::atomic::Ordering::Relaxed);
|
||
if __p != 0 && __t >= __e {
|
||
let s = ((__t - __e) as i128) / (__p as i128);
|
||
if s > last_rendered_slot {
|
||
last_rendered_slot = s;
|
||
}
|
||
}
|
||
}
|
||
// 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 — under the model THEN believed (callback 3-6 ms
|
||
// late). That model was WRONG: the callback fires at
|
||
// edge+0.5 ms (measured, VSYNC mon−ftn). See
|
||
// docs/BUGS/hypotheses-120hz.md.
|
||
//
|
||
// RENDER-AHEAD (re-enabled under the corrected model): the
|
||
// wake arrives ON the edge, so the held frame — presented
|
||
// at the START of this render — lands at edge+~1.5 ms:
|
||
// constant phase, ~4 ms of deadline margin, and render
|
||
// SPIKES (Skia 5-10 ms) stop delaying visible frames: a
|
||
// spike only stretches the PREPARE phase of the frame held
|
||
// for the NEXT slot (presented at the NEXT edge+1.5 ms,
|
||
// same phase). Cost: one slot of constant latency
|
||
// (imperceptible).
|
||
i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE
|
||
.store(locked, std::sync::atomic::Ordering::Relaxed);
|
||
// PRESENT GATE: DISABLED (UI-thread sleep ate the slot
|
||
// budget — history in hypotheses-120hz.md). Reference only.
|
||
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);
|
||
// Spike classifier (hypotheses-120hz.md): CPU time inside
|
||
// do_render. rcpu≈render ⇒ Skia/wgpu CPU work; rcpu≪render ⇒
|
||
// blocked on the GPU/driver (queue back-pressure, fences).
|
||
let __rc0 = stg::thread_cpu_us();
|
||
self.window.do_render()?;
|
||
let t3 = stg::now_us();
|
||
stg::S_RENDER_END.store(t3, std::sync::atomic::Ordering::Relaxed);
|
||
render_us = t3 - t2;
|
||
__render_cpu = stg::thread_cpu_us().saturating_sub(__rc0);
|
||
} 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 —
|
||
// EXCEPT when this iteration already rendered (unlocked, dirty):
|
||
// that render presented the latest content immediately, so the
|
||
// flush would only double-present the slot. (2026-10-30: this
|
||
// double fired on every animating-flicker during bursty drags.)
|
||
let mut flushed_now = false;
|
||
if prev_locked && !locked && !do_render {
|
||
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()?;
|
||
// Bookkeeping (2026-10-30): the flush render presents the
|
||
// current slot; without marking it, the re-locked driver
|
||
// re-renders the same slot ~1 ms later (the +1.1/+1.2
|
||
// double-presents of slt13).
|
||
let __t = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
|
||
i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS
|
||
.store(__t, std::sync::atomic::Ordering::Relaxed);
|
||
let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
|
||
.load(std::sync::atomic::Ordering::Relaxed);
|
||
let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS
|
||
.load(std::sync::atomic::Ordering::Relaxed);
|
||
if __p != 0 && __t >= __e {
|
||
let s = ((__t - __e) as i128) / (__p as i128);
|
||
if s > last_rendered_slot {
|
||
last_rendered_slot = s;
|
||
}
|
||
}
|
||
flushed_now = true;
|
||
}
|
||
// DRIVER spin throttle (2026-10-30): see the timeout note. After
|
||
// a no-render locked iteration, park 250 µs (hrtimer — cannot be
|
||
// swallowed like an ALooper timeout) so the grid is re-checked
|
||
// within ~0.3 ms of each edge.
|
||
if locked && !do_render && !flushed_now {
|
||
std::thread::sleep(Duration::from_micros(250));
|
||
}
|
||
prev_locked = locked;
|
||
// Miss forensics (CONDITIONAL — 2026-07-09): the per-iteration
|
||
// /proc read was itself a confounder (procfs reads cost tens of
|
||
// µs on the UI thread; with render completion near the slot
|
||
// boundary that pushed the present phase across it — 10x worse
|
||
// drops/doubles measured). Sample only around SLOW iterations
|
||
// (>5 ms wall): those are exactly the miss candidates, and the
|
||
// common path pays nothing.
|
||
let __iter_wall = stg::now_us().saturating_sub(t_start);
|
||
let (__vsw, __nvol):
|
||
(u64, u64) = if __iter_wall > 5_000 && stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed)
|
||
{
|
||
// Delta against the rolling baseline (previous slow sample).
|
||
let c = stg::ctxsw_sample();
|
||
let pv = stg::CTX_PREV_V.load(std::sync::atomic::Ordering::Relaxed);
|
||
let pn = stg::CTX_PREV_NV.load(std::sync::atomic::Ordering::Relaxed);
|
||
let first = !stg::CTX_SAMPLED.swap(true, std::sync::atomic::Ordering::Relaxed);
|
||
stg::CTX_PREV_V.store(c.0, std::sync::atomic::Ordering::Relaxed);
|
||
stg::CTX_PREV_NV.store(c.1, std::sync::atomic::Ordering::Relaxed);
|
||
if first { (0, 0) } else { (c.0.saturating_sub(pv), c.1.saturating_sub(pn)) }
|
||
} else {
|
||
(0, 0)
|
||
};
|
||
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,
|
||
__render_cpu,
|
||
__vsw,
|
||
__nvol,
|
||
);
|
||
}
|
||
}
|
||
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(())
|
||
}
|