ROOT CAUSE of the 8-12% slot drops (uniform dec=1/0/1/0: locked, no wake seen, dirty, no render): AOSP Looper::pollInner (system/core/libutils/ Looper.cpp) drains the wake fd in the SAME epoll batch (up to 16 fds) as any ready input fd, and pollOnce returns the input's ident — the POLL_WAKE result is a local that gets dropped. ALooper_wake's byte is consumed WITHOUT PollEvent::Wake ever reaching Slint, unrecoverable. With motion events arriving 1-4 ms after the edge (always for a real 120-240 Hz finger; ~1 in 5 frames for 20 Hz adb input) the wake is lost and the locked decision (saw_wake && slot-guard) drops the dirty frame for that slot. Fix (vendor backend): a dedicated eventfd registered with the MAIN looper under ALOOPER_POLL_CALLBACK. The Looper invokes the C trampoline INSIDE pollInner (before pollOnce returns, regardless of which ident is handed back), so delivery can never be swallowed by a co-batched input response. - frame_callback: note the edge, write the eventfd, then ALooper_wake (secondary). The fd is leaked: it belongs to the main looper for the process lifetime. - trampoline: DRAINS the eventfd (level-triggered — without the drain the counter stays >0, the fd stays readable and the loop busy-spins on POLL_CALLBACK at ~10 kHz: 1812 spin iterations measured) and publishes the edge into delivered_edge. - main loop: saw_wake |= (delivered_edge newer than last seen). - DIRTY BACKSTOP: the 6 ms poll cap was never a backstop — timeout iterations have saw_wake=false so the locked decision never rendered (the 20-40 ms silences). Now: dirty && last render >= 2 slots old renders regardless of wake state. Measured on the phone (Pixel 9 Pro, 120 Hz, sltest, identical capture): buildC (before): 127 drops + 12 stalls in 1142 frames buildE (after): 8 drops + 0 stalls in 1266 frames Phase (p10-p90): 0.72-1.00 ms in all windows. Remaining 8/1266 (0.63%) are slip-and-recover pairs where the UI thread is DESCHEDULED 6-8 ms while sleeping in epoll (wakes arrive on time, cb-lat 0.5 ms) — EAS scheduling variance, no app-side contention in sltest. Referee fixes found while judging this data (tools/jank_eval.py): - NEAREST-OBSERVED-EDGE slot grid: the 120 Hz panel clock phase-slips after idle blanking (re-locks at arbitrary phase), invalidating any grid anchored at ftns[0]. An entire drag window read late=230/ dropped=24 while per-cycle tracing showed presents at edge+2.0 ms. slot_of_ftn assigns each present to the nearest observed hardware edge in a time-continuous slot domain (slip-immune: a constant delta shifts all slots, differences preserved; stall-accurate: ftn times are CLOCK_MONOTONIC). - tools/jank_capture.py: capture is INVALID unless the screen is Awake AND the keyguard is down (a run on a sleeping phone silently injects no input: 49 IT lines, 1 present).
462 lines
19 KiB
Rust
462 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
|
|
|
|
//! Drives animation frames from the display's vsync using the Android Choreographer.
|
|
//! Optimized for ultra-low latency: self-re-entrant Choreographer callbacks wake
|
|
//! the main loop directly on vsync without thread round-trip serialization delay.
|
|
|
|
use std::os::fd::FromRawFd;
|
|
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
|
|
use std::sync::{Arc, Condvar, Mutex};
|
|
use std::thread::JoinHandle;
|
|
|
|
use crate::android_activity::AndroidAppWaker;
|
|
use crate::ndk::looper::{ForeignLooper, ThreadLooper};
|
|
|
|
mod ffi {
|
|
use core::ffi::{c_int, c_long, c_void};
|
|
|
|
#[repr(C)]
|
|
pub struct AChoreographer {
|
|
_unused: [u8; 0],
|
|
}
|
|
#[repr(C)]
|
|
pub struct ALooper {
|
|
_unused: [u8; 0],
|
|
}
|
|
pub type FrameCallback = unsafe extern "C" fn(frame_time_nanos: c_long, data: *mut c_void);
|
|
pub type FdCallback =
|
|
unsafe extern "C" fn(fd: c_int, events: c_int, data: *mut c_void) -> c_int;
|
|
|
|
pub const ALOOPER_POLL_CALLBACK: c_int = -1;
|
|
pub const ALOOPER_EVENT_INPUT: c_int = 0x0001;
|
|
|
|
#[link(name = "android")]
|
|
unsafe extern "C" {
|
|
pub fn AChoreographer_getInstance() -> *mut AChoreographer;
|
|
pub fn AChoreographer_postFrameCallback(
|
|
choreographer: *mut AChoreographer,
|
|
callback: FrameCallback,
|
|
data: *mut c_void,
|
|
);
|
|
pub fn ALooper_forThread() -> *mut ALooper;
|
|
pub fn ALooper_addFd(
|
|
looper: *mut ALooper,
|
|
fd: c_int,
|
|
ident: c_int,
|
|
events: c_int,
|
|
callback: Option<FdCallback>,
|
|
data: *mut c_void,
|
|
) -> c_int;
|
|
}
|
|
}
|
|
|
|
/// ALooper wake-fd race (2026-10-30, ROOT CAUSE of the 120 Hz slot drops):
|
|
/// `Looper::pollInner` (system/core/libutils/Looper.cpp) drains the wake fd
|
|
/// in the SAME epoll batch as any ready input fd, and `pollOnce` returns the
|
|
/// input's ident — the `POLL_WAKE` result is a local that gets dropped. The
|
|
/// `ALooper_wake` byte is consumed WITHOUT `PollEvent::Wake` ever reaching
|
|
/// Slint, and it cannot be recovered (the byte is gone). With motion events
|
|
/// arriving 1-4 ms after the edge (always, for a real finger at 120-240 Hz
|
|
/// input rate; ~1 in 5 frames for 20 Hz adb input) the wake is lost and the
|
|
/// locked render decision (`saw_wake && slot-guard`) drops the dirty frame
|
|
/// for that slot — measured: 8-12% of slots dropped, uniform `dec=1/0/1/0`.
|
|
///
|
|
/// Fix: a dedicated eventfd registered with the main looper under
|
|
/// `ALOOPER_POLL_CALLBACK`. The Looper invokes its C callback INSIDE
|
|
/// `pollInner` (before pollOnce returns, and regardless of which ident
|
|
/// pollOnce hands back), so the delivery can never be swallowed by an input
|
|
/// response. The trampoline publishes the edge ns into `delivered_edge`; the
|
|
/// main loop treats "new edge delivered" exactly like `saw_wake`.
|
|
|
|
struct Shared {
|
|
state: Mutex<State>,
|
|
cond: Condvar,
|
|
driving: AtomicBool,
|
|
looper: Mutex<Option<ForeignLooper>>,
|
|
main_waker: AndroidAppWaker,
|
|
choreographer: *mut ffi::AChoreographer,
|
|
/// Dedicated vsync-delivery eventfd (see the wake-fd race note above).
|
|
/// Raw fd (>=0 once registered; -1 = unavailable). The fd is leaked on
|
|
/// purpose: it is registered with the MAIN looper, which outlives the
|
|
/// driver (same lifetime convention as the forgotten Timer).
|
|
evfd_raw: AtomicI32,
|
|
/// Edge (CLOCK_MONOTONIC ns) of the latest vsync DELIVERED to the main
|
|
/// thread via the eventfd trampoline. The main loop swaps it out each
|
|
/// iteration; a value > last-seen means "saw a wake this iteration" —
|
|
/// immune to the ALooper wake-fd swallow.
|
|
delivered_edge: AtomicU64,
|
|
}
|
|
|
|
/// Trampoline: runs on the MAIN thread inside ALooper_pollOnce (see the
|
|
/// race note above). Publishes the edge that `frame_callback` just wrote.
|
|
unsafe extern "C" fn vsync_fd_trampoline(fd: i32, _events: i32, data: *mut core::ffi::c_void) -> i32 {
|
|
// DRAIN FIRST (level-triggered): the eventfd is a counter; if we never
|
|
// read it, it stays readable forever and ALooper_pollOnce returns
|
|
// POLL_CALLBACK on EVERY poll — the main loop busy-spins at ~10 kHz
|
|
// (measured: 1812 spin iterations per run, wake→render latency stretched
|
|
// to +5.5 ms phase in the affected window).
|
|
let mut counter: u64 = 0;
|
|
libc::read(fd, &mut counter as *mut u64 as *mut core::ffi::c_void, 8);
|
|
let shared = &*(data as *const Shared);
|
|
let edge = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS
|
|
.load(Ordering::Acquire);
|
|
if edge > 0 {
|
|
shared.delivered_edge.store(edge, Ordering::Release);
|
|
}
|
|
1 // keep the fd registered
|
|
}
|
|
|
|
unsafe impl Send for Shared {}
|
|
unsafe impl Sync for Shared {}
|
|
|
|
#[derive(Default)]
|
|
struct State {
|
|
animating: bool,
|
|
/// Window is dirty (pending_redraw). The Choreographer callback chain
|
|
/// re-posts while `animating || dirty` (2026-07-09): gating on
|
|
/// `animating` alone let the chain die during flings (pointer
|
|
/// inactivity >200 ms) and on any `has_active_animations` flicker —
|
|
/// with the early waker disabled the loop then froze until the next
|
|
/// input event (measured: 30 frames in 4 s of drag, then silence).
|
|
/// `set_state` is called by the main loop on EVERY iteration, so the
|
|
/// dirty mirror can be at most one iteration (≈8 ms) stale — the 6 ms
|
|
/// poll backstop for dirty windows bounds the damage of a chain break.
|
|
dirty: bool,
|
|
quit: bool,
|
|
callback_posted: bool,
|
|
}
|
|
|
|
pub struct VsyncDriver {
|
|
shared: Arc<Shared>,
|
|
handle: Option<JoinHandle<()>>,
|
|
waker_handle: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl VsyncDriver {
|
|
pub fn new(main_waker: AndroidAppWaker) -> Self {
|
|
let shared = Arc::new(Shared {
|
|
state: Mutex::new(State::default()),
|
|
cond: Condvar::new(),
|
|
driving: AtomicBool::new(false),
|
|
looper: Mutex::new(None),
|
|
main_waker: main_waker.clone(),
|
|
choreographer: std::ptr::null_mut(),
|
|
evfd_raw: AtomicI32::new(-1),
|
|
delivered_edge: AtomicU64::new(0),
|
|
});
|
|
// Register the delivery eventfd with the MAIN looper (this runs on
|
|
// the main thread, from run_event_loop). ALOOPER_POLL_CALLBACK makes
|
|
// the Looper invoke the trampoline itself inside pollInner.
|
|
unsafe {
|
|
let evfd = libc::eventfd(0, libc::EFD_NONBLOCK);
|
|
if evfd >= 0 {
|
|
let looper = ffi::ALooper_forThread();
|
|
let r = ffi::ALooper_addFd(
|
|
looper,
|
|
evfd,
|
|
ffi::ALOOPER_POLL_CALLBACK,
|
|
ffi::ALOOPER_EVENT_INPUT,
|
|
Some(vsync_fd_trampoline),
|
|
Arc::as_ptr(&shared) as *mut core::ffi::c_void,
|
|
);
|
|
if r >= 0 {
|
|
log::info!("VSYNC EVFD registered fd={evfd}");
|
|
shared.evfd_raw.store(evfd, Ordering::Release);
|
|
// Leak: the fd belongs to the main looper for the life of
|
|
// the process (the driver's drop must not close it).
|
|
std::mem::forget(std::os::fd::OwnedFd::from_raw_fd(evfd));
|
|
} else {
|
|
log::error!("VSYNC EVFD ALooper_addFd failed");
|
|
libc::close(evfd);
|
|
}
|
|
}
|
|
}
|
|
let handle = std::thread::Builder::new()
|
|
.name("slint vsync".into())
|
|
.spawn({
|
|
let shared = shared.clone();
|
|
move || vsync_thread(shared)
|
|
})
|
|
.ok();
|
|
// EARLY WAKER (the real 120Hz fix, part 2): the Choreographer
|
|
// callback only reaches the UI thread ~2.5-6 ms AFTER the vsync
|
|
// edge — with a 2.5-5 ms Skia render plus present, that leaves
|
|
// no phase margin inside the 8.3 ms slot (measured: 51% of frames
|
|
// landed one slot late). This thread learns the vsync grid from
|
|
// the callback's `frame_time_nanos` (vkprobe::note_vsync_edge) and
|
|
// wakes the main loop slightly BEFORE each edge, so render start ≈
|
|
// the edge and present lands at a constant ~edge+3 ms phase.
|
|
// The Choreographer's late wake remains as fallback (the render
|
|
// freshness guard in the event loop prevents double renders).
|
|
let waker_handle = std::thread::Builder::new()
|
|
.name("slint early-waker".into())
|
|
.spawn({
|
|
let shared = shared.clone();
|
|
move || early_waker_thread(shared)
|
|
})
|
|
.ok();
|
|
Self {
|
|
shared,
|
|
handle,
|
|
waker_handle,
|
|
}
|
|
}
|
|
|
|
pub fn is_driving(&self) -> bool {
|
|
self.shared.driving.load(Ordering::Acquire)
|
|
}
|
|
|
|
/// Consume the latest vsync edge delivered to the main thread via the
|
|
/// eventfd trampoline (0 = nothing new this iteration). Call ONCE per
|
|
/// main-loop iteration, after poll_events.
|
|
pub fn take_delivered_edge(&self) -> u64 {
|
|
self.shared.delivered_edge.swap(0, Ordering::AcqRel)
|
|
}
|
|
|
|
/// (edge delivery happens in `frame_callback`, which owns &Shared)
|
|
|
|
/// Push (animating, dirty) into the driver every main-loop iteration.
|
|
/// The vsync thread re-posts the first Choreographer callback, and
|
|
/// `frame_callback` re-posts the chain, while `animating || dirty`.
|
|
pub fn set_state(&self, animating: bool, dirty: bool) {
|
|
let mut state = self.shared.state.lock().unwrap();
|
|
let was_active = state.animating || state.dirty;
|
|
let now_active = animating || dirty;
|
|
state.animating = animating;
|
|
state.dirty = dirty;
|
|
if !was_active && now_active {
|
|
drop(state);
|
|
// Wake the vsync thread: it may be parked in `poll_once` (the
|
|
// Choreographer queue drained once the previous active phase's
|
|
// self-sustaining callback chain ended). Waking the looper is
|
|
// what makes it re-check the state and post the first frame
|
|
// callback for this new phase.
|
|
if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() {
|
|
looper.wake();
|
|
}
|
|
self.shared.cond.notify_one();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for VsyncDriver {
|
|
fn drop(&mut self) {
|
|
let mut state = self.shared.state.lock().unwrap();
|
|
state.quit = true;
|
|
drop(state);
|
|
self.shared.cond.notify_one();
|
|
if let Some(looper) = self.shared.looper.lock().unwrap().as_ref() {
|
|
looper.wake();
|
|
}
|
|
if let Some(handle) = self.handle.take() {
|
|
let _ = handle.join();
|
|
}
|
|
if let Some(handle) = self.waker_handle.take() {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wakes the main loop a few hundred microseconds before each vsync edge
|
|
/// (see the `early_waker_handle` docs in `VsyncDriver::new`). Runs at 120 Hz
|
|
/// only while animating; otherwise parks in short sleeps.
|
|
fn early_waker_thread(shared: Arc<Shared>) {
|
|
let waker = shared.main_waker.clone();
|
|
// Wake slightly BEFORE the edge so poll-returns + render-start lands on
|
|
// the edge; with a 2.5-5 ms Skia render the present then sits at a
|
|
// constant ~edge+3 ms (middle of the slot, ~2.5 ms of deadline margin
|
|
// both ways).
|
|
const LEAD_NS: u64 = 800_000;
|
|
loop {
|
|
{
|
|
let state = shared.state.lock().unwrap();
|
|
if state.quit {
|
|
return;
|
|
}
|
|
if !state.animating {
|
|
drop(state);
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
continue;
|
|
}
|
|
}
|
|
let edge = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS
|
|
.load(Ordering::Relaxed);
|
|
let period = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS
|
|
.load(Ordering::Relaxed);
|
|
let now = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns();
|
|
// Grid unknown or stale (no callbacks for ~100 ms, e.g. the app
|
|
// went to the background): park briefly and re-check.
|
|
if edge == 0 || period == 0 || now == 0 || now > edge + 100_000_000 {
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
continue;
|
|
}
|
|
// Next edge at/after (now + LEAD).
|
|
let mut target = edge + period;
|
|
while target < now.saturating_add(LEAD_NS) {
|
|
target += period;
|
|
}
|
|
let delay = target.saturating_sub(now + LEAD_NS);
|
|
if delay > 0 {
|
|
std::thread::sleep(std::time::Duration::from_nanos(delay));
|
|
}
|
|
let landed = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns()
|
|
.saturating_sub(target);
|
|
// WAKE PROBE: log every attempt (120/s, non-blocking logger) so we
|
|
// can see the nanosleep landing distribution: `land` is ns relative
|
|
// to the edge target (negative = early). H1 (hypotheses-120hz.md):
|
|
// if land is systematically > +1 ms the waker skips every wake and
|
|
// the late Choreographer wake (~edge+5) wins every slot.
|
|
if i_slint_renderer_skia::wgpu_30_surface::vkprobe::EW_ON
|
|
.load(Ordering::Relaxed)
|
|
{
|
|
log::info!("EW land={}ms", (landed as i64 - 800_000) / 1000);
|
|
}
|
|
// Wake only if we landed within ~1 ms of the edge. If we overshot
|
|
// (CPU starvation), the Choreographer's late wake (~5 ms after the
|
|
// edge) covers the frame instead.
|
|
//
|
|
// EW_ON (2026-07-09) now gates the WAKE itself, not just the EW log.
|
|
// The waker was built on the refuted "callback 3-6 ms late" model;
|
|
// the Choreographer fires at edge+0.52 ms (measured) and is the
|
|
// single correct wake source. Two wake sources race the slot guard
|
|
// and each renders at its own phase — the measured render-phase
|
|
// wander (-4..+4 ms) that turned a 120 Hz stream into 12-17 ms
|
|
// gaps (drops) and 4-6 ms gaps (doubles). With the Choreographer
|
|
// alone, the render phase is pinned to edge+~0.5-1.0 ms.
|
|
if landed <= 1_000_000
|
|
&& i_slint_renderer_skia::wgpu_30_surface::vkprobe::EW_ON
|
|
.load(Ordering::Relaxed)
|
|
{
|
|
waker.wake();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Jank-hunt: frame-alignment probes. `frame_time_nanos` is the Choreographer
|
|
// vsync timestamp (System.nanoTime epoch = CLOCK_MONOTONIC on Android); we
|
|
// ALSO stamp CLOCK_MONOTONIC here so VSYNC and PRES lines share one clock and
|
|
// we can measure, per presented frame, how far past the vsync edge it landed
|
|
// (phase). If presents systematically land in the back half of the 8.3 ms
|
|
// window the compositor shows them one vsync late → effective 60 Hz + judder
|
|
// even though our frame-to-frame cadence looks like 120 Hz.
|
|
unsafe fn mon_ns() -> u64 {
|
|
#[repr(C)]
|
|
struct Ts {
|
|
tv_sec: i64,
|
|
tv_nsec: i64,
|
|
}
|
|
unsafe extern "C" {
|
|
fn clock_gettime(clkid: i32, tp: *mut Ts) -> i32;
|
|
}
|
|
const CLOCK_MONOTONIC: i32 = 1;
|
|
let mut ts = Ts { tv_sec: 0, tv_nsec: 0 };
|
|
if unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) } == 0 {
|
|
ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
unsafe extern "C" fn frame_callback(
|
|
frame_time_nanos: core::ffi::c_long,
|
|
data: *mut core::ffi::c_void,
|
|
) {
|
|
let shared = &*(data as *const Shared);
|
|
// Publish the exact vsync edge (CLOCK_MONOTONIC ns — the same epoch as
|
|
// `mon_ns()` above) FIRST: the trampoline reads VSYNC_EDGE_NS, and the
|
|
// slot guard in the event loop keys off it.
|
|
i_slint_renderer_skia::wgpu_30_surface::vkprobe::note_vsync_edge(
|
|
frame_time_nanos as u64,
|
|
);
|
|
// CRITICAL PATH: deliver the edge to the main loop. (1) the dedicated
|
|
// eventfd — its trampoline runs inside ALooper_pollOnce and CANNOT be
|
|
// swallowed by a co-batched input response (the wake-fd race); (2)
|
|
// ALooper_wake as secondary (still the normal PollEvent::Wake path when
|
|
// no other fd is ready in the batch). The VSYNC log line below is a
|
|
// file write (50-200us typical) — kept after both wakes so it cannot
|
|
// push the main loop's render start later in the slot.
|
|
let evfd = shared.evfd_raw.load(Ordering::Acquire);
|
|
if evfd >= 0 {
|
|
let one: u64 = 1;
|
|
libc::write(
|
|
evfd,
|
|
&one as *const u64 as *const core::ffi::c_void,
|
|
core::mem::size_of::<u64>(),
|
|
);
|
|
}
|
|
shared.main_waker.wake();
|
|
let mon = unsafe { mon_ns() };
|
|
if mon > 0 {
|
|
log::info!("VSYNC ftn={} mon={}", frame_time_nanos, mon);
|
|
}
|
|
|
|
let mut state = shared.state.lock().unwrap();
|
|
if (state.animating || state.dirty) && !state.quit {
|
|
unsafe {
|
|
ffi::AChoreographer_postFrameCallback(
|
|
shared.choreographer,
|
|
frame_callback,
|
|
data,
|
|
);
|
|
}
|
|
} else {
|
|
state.callback_posted = false;
|
|
}
|
|
}
|
|
|
|
fn vsync_thread(shared: Arc<Shared>) {
|
|
let looper = ThreadLooper::prepare();
|
|
*shared.looper.lock().unwrap() = Some(looper.as_foreign().clone());
|
|
|
|
let choreographer = unsafe { ffi::AChoreographer_getInstance() };
|
|
if choreographer.is_null() {
|
|
return;
|
|
}
|
|
|
|
let ptr = Arc::as_ptr(&shared) as *mut Shared;
|
|
unsafe {
|
|
(*ptr).choreographer = choreographer;
|
|
}
|
|
|
|
shared.driving.store(true, Ordering::Release);
|
|
|
|
let raw_shared = Arc::into_raw(shared);
|
|
|
|
loop {
|
|
{
|
|
let mut state = unsafe { &(*raw_shared).state }.lock().unwrap();
|
|
if state.quit {
|
|
break;
|
|
}
|
|
// Post the FIRST frame callback ONLY when we're animating AND no
|
|
// callback is already pending. While animating, `frame_callback`
|
|
// re-posts itself on every vsync (self-sustaining chain), so a
|
|
// callback is always pending — we must NOT post another here, or
|
|
// the pending-callback count grows by one every vsync (1, 2, 3,
|
|
// 4…) and each vsync fires N callbacks → N main-loop wakes with N
|
|
// unbounded. That was the ~1600 iters/s busy-spin. See
|
|
// docs/BUGS/scroll-jank-120hz.md.
|
|
if (state.animating || state.dirty) && !state.callback_posted {
|
|
state.callback_posted = true;
|
|
unsafe {
|
|
ffi::AChoreographer_postFrameCallback(
|
|
choreographer,
|
|
frame_callback,
|
|
raw_shared as *mut core::ffi::c_void,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Block until the next Choreographer message (a vsync fires the frame
|
|
// callback) or a wake (set_animating/quit). While animating the
|
|
// self-sustaining chain keeps delivering one message per vsync; when
|
|
// the animation stops the chain ends and this blocks until the next
|
|
// animation's set_animating(true) wakes the looper.
|
|
let _ = looper.poll_once();
|
|
}
|
|
|
|
let _ = unsafe { Arc::from_raw(raw_shared) };
|
|
}
|