Iris, from her phone: "some stuttering when flinging in particular. Harder to notice with my finger directly moving the scroll." Her fling phase was 103fps on a 120Hz screen at p50 6.3ms. Two of the four things found are corrections to the instrument, not the renderer. The swapchain acquire -- `get_current_texture`, which *blocks* until the compositor frees an image -- was inside the span the report called iris's CPU work, so a fling comfortably ahead of the display read as milliseconds of being slow. A frame is now three measured parts (`FrameParts`: build, acquire, submit), per phase as well as per run. And nothing could say a frame was never *produced*: `late` counts frames that cost too much, which a reader does not see, while a frame that never happens leaves the last one up for two refreshes, which is the stutter. `PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980 vsyncs. The other two are the frame loop. `Choreographer.postFrameCallback` schedules for the next vsync after the call, and iris asked at the *end* of the callback -- so any frame whose work ran past the boundary registered too late and got the vsync after, one frame over budget silently costing a second. It is asked for immediately after `tick_animations` now, on both backends. And the fling was advanced on `Instant::now()` rather than the vsync `do_frame` carries: frames are presented on an even cadence whatever clock computes them, so sampling the spline at "whenever the callback ran" moves the content unevenly with no frame late enough to appear in any report -- and a drag never had it, which is the asymmetry Iris described. `PointerClock` is `DeviceClock` and the view keeps one, anchored by whichever of a touch or a frame comes first, so a fling is advanced on the clock its velocity was measured on. `opt-level` for the Android release build goes from "s" to 3. The table in RUST.md picked "s" on bytes alone; over the same warm fling eight times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK. `app-rust/tests/fling_profile.rs` is the rig that established what a fling frame actually costs and is kept for next time (Iris: "please keep the profiling rig around for future use"): only one frame in six lays anything out, and the multi-millisecond spikes are all first-pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
15 KiB
Rust
436 lines
15 KiB
Rust
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
|
//! in-process with **no window, no compositor and no GPU**, on an
|
|
//! explicit clock and a replayed touch stream.
|
|
//!
|
|
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
|
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
|
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
|
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
|
//! phone's size and density, feed it a recorded flick, and assert on
|
|
//! where the list ended up. What it answers that the emulator cannot:
|
|
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
|
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
|
//! ones -- so the gesture shape a finger actually makes is only
|
|
//! reproducible from a *file* of timestamped samples.
|
|
//!
|
|
//! It is a third backend in the sense `default/` and `android/` are, and
|
|
//! deliberately the smallest one: the platform half of each of those
|
|
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
|
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
|
//! so a test can assert the platform *was asked*, which is the only
|
|
//! thing either backend does with those calls anyway.
|
|
//!
|
|
//! ```ignore
|
|
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
|
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
|
//! h.frame(0);
|
|
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
|
//! h.frames_until(20, 2_000, 8);
|
|
//! ```
|
|
|
|
use crate::prelude::*;
|
|
use std::marker::PhantomData;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
|
|
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum TouchAction {
|
|
Down,
|
|
Move,
|
|
Up,
|
|
/// The gesture taken away by the system (a parent view claiming it, a
|
|
/// call arriving, the swipe up from the bottom edge to leave the
|
|
/// app). It ends the press, because a release that never arrives
|
|
/// leaves pointer capture held forever -- but it is not a release,
|
|
/// and nothing follows from it: no tap, no selection, no fling. See
|
|
/// `CursorState::cancelled`, which is what it sets.
|
|
Cancel,
|
|
}
|
|
|
|
impl TouchAction {
|
|
fn parse(word: &str) -> Option<Self> {
|
|
match word {
|
|
"down" => Some(Self::Down),
|
|
"move" => Some(Self::Move),
|
|
"up" => Some(Self::Up),
|
|
"cancel" => Some(Self::Cancel),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
|
|
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
|
|
/// `.touch` file agree on one spelling of each action.
|
|
pub fn word(self) -> &'static str {
|
|
match self {
|
|
Self::Down => "down",
|
|
Self::Move => "move",
|
|
Self::Up => "up",
|
|
Self::Cancel => "cancel",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct TouchSample {
|
|
/// Milliseconds since the start of the recording -- the sample's own
|
|
/// time, which becomes `CursorState::time`. See that field's doc for
|
|
/// why a replay may not date its samples by when the loop got to
|
|
/// them.
|
|
pub t_ms: u64,
|
|
pub action: TouchAction,
|
|
pub pos: Vec2,
|
|
}
|
|
|
|
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
|
/// blank lines ignored. Deliberately a plain text file rather than a
|
|
/// serialisation format -- it is written by hand as often as it is
|
|
/// recorded, and a diff of one has to be readable.
|
|
pub struct TouchScript {
|
|
pub samples: Vec<TouchSample>,
|
|
}
|
|
|
|
impl TouchScript {
|
|
/// Parses a script, naming the line and what was wrong with it: these
|
|
/// are hand-written files, so a typo is the ordinary case and
|
|
/// "expected 4 fields" without a line number is not enough to fix it.
|
|
pub fn parse(text: &str) -> Result<Self, String> {
|
|
let mut samples: Vec<TouchSample> = Vec::new();
|
|
for (i, line) in text.lines().enumerate() {
|
|
let line = line.split('#').next().unwrap_or("").trim();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
|
let mut words = line.split_whitespace();
|
|
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
|
words.next(),
|
|
words.next(),
|
|
words.next(),
|
|
words.next(),
|
|
words.next(),
|
|
) else {
|
|
return Err(at("expected `t_ms action x y`"));
|
|
};
|
|
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
|
let action = TouchAction::parse(action)
|
|
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
|
|
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
|
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
|
if let Some(last) = samples.last()
|
|
&& t_ms < last.t_ms
|
|
{
|
|
return Err(at("samples must be in time order"));
|
|
}
|
|
samples.push(TouchSample {
|
|
t_ms,
|
|
action,
|
|
pos: Vec2::new(x, y),
|
|
});
|
|
}
|
|
Ok(Self { samples })
|
|
}
|
|
|
|
/// The last sample's time, i.e. how long the recording runs.
|
|
pub fn end_ms(&self) -> u64 {
|
|
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
/// Counts the frames something asked for without drawing any -- the
|
|
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
|
|
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
|
|
/// tell "nothing moved" from "nothing was even asked to move".
|
|
#[derive(Default)]
|
|
pub struct RedrawCounter(AtomicUsize);
|
|
|
|
impl RedrawCounter {
|
|
pub fn count(&self) -> usize {
|
|
self.0.load(Ordering::Relaxed)
|
|
}
|
|
}
|
|
|
|
impl RequestRedraw for RedrawCounter {
|
|
fn request_redraw(&self) {
|
|
self.0.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// The harness's app state: what each real backend keeps for the platform
|
|
/// half, recorded instead of performed.
|
|
pub struct HarnessState {
|
|
pub root: Option<StrongWidget>,
|
|
pub focus: Option<WeakWidget<TextEdit>>,
|
|
last_click: Instant,
|
|
/// How many times a tap asked for the keyboard (`FocusHost::
|
|
/// focus_gained` with a region -- `showSoftInput` on Android,
|
|
/// `set_ime_cursor_area` on winit). The platform's own answer is not
|
|
/// available here, so this says what was *asked*, and a test must not
|
|
/// read it as "the IME is up".
|
|
pub keyboard_shown: usize,
|
|
/// Every URL a tapped link asked the platform to open, in order.
|
|
pub opened_urls: Vec<String>,
|
|
}
|
|
|
|
impl HarnessState {
|
|
fn new() -> Self {
|
|
Self {
|
|
root: None,
|
|
focus: None,
|
|
last_click: Instant::now(),
|
|
keyboard_shown: 0,
|
|
opened_urls: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HasRoot for HarnessState {
|
|
fn set_root(&mut self, root: StrongWidget) {
|
|
self.root = Some(root);
|
|
}
|
|
}
|
|
|
|
impl FocusHost for HarnessState {
|
|
fn recent_click(&mut self) -> bool {
|
|
crate::attr::recent_click(&mut self.last_click)
|
|
}
|
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
|
self.focus = id;
|
|
}
|
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
|
self.focus == Some(id)
|
|
}
|
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
|
if region.is_some() {
|
|
self.keyboard_shown += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OpenUrl for HarnessState {
|
|
fn open_url(&mut self, url: &str) {
|
|
self.opened_urls.push(url.to_string());
|
|
}
|
|
}
|
|
|
|
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
|
|
/// `AndroidRsc` minus the windowing, for the same reason those two are
|
|
/// separate types (`AndroidRsc`'s own doc).
|
|
pub struct HarnessRsc {
|
|
pub ui: UiData,
|
|
pub events: EventManager<Self>,
|
|
pub tasks: Tasks<Self>,
|
|
pub state: WidgetState,
|
|
_state: PhantomData<HarnessState>,
|
|
}
|
|
|
|
impl UiRsc for HarnessRsc {
|
|
fn ui(&self) -> &UiData {
|
|
&self.ui
|
|
}
|
|
fn ui_mut(&mut self) -> &mut UiData {
|
|
&mut self.ui
|
|
}
|
|
fn on_draw(&mut self, active: &ActiveData) {
|
|
self.events.draw(active);
|
|
}
|
|
fn on_undraw(&mut self, active: &ActiveData) {
|
|
self.events.undraw(active);
|
|
}
|
|
fn on_remove(&mut self, id: WidgetId) {
|
|
self.events.remove(id);
|
|
self.state.remove(id);
|
|
}
|
|
}
|
|
|
|
impl HasState for HarnessRsc {
|
|
type State = HarnessState;
|
|
}
|
|
|
|
impl HasEvents for HarnessRsc {
|
|
fn events(&self) -> &EventManager<Self> {
|
|
&self.events
|
|
}
|
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
|
&mut self.events
|
|
}
|
|
}
|
|
|
|
impl HasTasks for HarnessRsc {
|
|
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
|
&mut self.tasks
|
|
}
|
|
}
|
|
|
|
impl HasWidgetState for HarnessRsc {
|
|
fn widget_state(&self) -> &WidgetState {
|
|
&self.state
|
|
}
|
|
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
|
&mut self.state
|
|
}
|
|
}
|
|
|
|
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
|
|
type Output = I::Output;
|
|
fn index(&self, index: I) -> &Self::Output {
|
|
index.get(self)
|
|
}
|
|
}
|
|
|
|
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
|
|
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
|
index.get_mut(self)
|
|
}
|
|
}
|
|
|
|
/// A screen running with no window: the widget tree, the frame loop and
|
|
/// the pointer, all advanced by the caller. See the module doc.
|
|
pub struct Harness {
|
|
pub rsc: HarnessRsc,
|
|
pub render: UiRenderState,
|
|
pub state: HarnessState,
|
|
task_recv: TaskMsgReceiver<HarnessRsc>,
|
|
redraws: Arc<RedrawCounter>,
|
|
cursor: CursorState,
|
|
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
|
/// nothing reads the wall clock -- see [`Self::at`].
|
|
base: Instant,
|
|
size: Vec2,
|
|
}
|
|
|
|
impl Harness {
|
|
/// `size` is in physical pixels and `density` is physical pixels per
|
|
/// dp, the pair Android reads from the surface and
|
|
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
|
|
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
|
|
/// `PHONE_SCALE`.
|
|
pub fn new(size: Vec2, density: f32) -> Self {
|
|
let redraws = Arc::new(RedrawCounter::default());
|
|
let (tasks, task_recv) = Tasks::init(redraws.clone());
|
|
let mut rsc = HarnessRsc {
|
|
ui: UiData::default(),
|
|
events: EventManager::default(),
|
|
tasks,
|
|
state: WidgetState::default(),
|
|
_state: PhantomData,
|
|
};
|
|
rsc.ui.text.density = density;
|
|
let mut render = UiRenderState::new();
|
|
render.set_density(density);
|
|
render.resize(size);
|
|
Self {
|
|
rsc,
|
|
render,
|
|
state: HarnessState::new(),
|
|
task_recv,
|
|
redraws,
|
|
cursor: CursorState::default(),
|
|
base: Instant::now(),
|
|
size,
|
|
}
|
|
}
|
|
|
|
/// The `Instant` this harness means by `t_ms`. Public because a
|
|
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
|
|
/// to date those calls on the same clock the touch samples use.
|
|
pub fn at(&self, t_ms: u64) -> Instant {
|
|
self.base + Duration::from_millis(t_ms)
|
|
}
|
|
|
|
pub fn size(&self) -> Vec2 {
|
|
self.size
|
|
}
|
|
|
|
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
|
pub fn redraws(&self) -> usize {
|
|
self.redraws.count()
|
|
}
|
|
|
|
/// One frame at `t_ms`: drain finished tasks, advance anything
|
|
/// animating, lay out and "draw". The same three steps
|
|
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
|
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
|
pub fn frame(&mut self, t_ms: u64) {
|
|
while let Ok(update) = self.task_recv.try_recv() {
|
|
update(&mut self.state, &mut self.rsc);
|
|
}
|
|
let now = self.at(t_ms);
|
|
let at = Instant::now();
|
|
let animating = self.rsc.ui.tick_animations(now);
|
|
self.render.update(&self.state.root, &mut self.rsc);
|
|
// No GPU here, so there is nothing to acquire and nothing to
|
|
// submit: the frame is all `build`, which is honest rather than
|
|
// zero-filled (`FrameParts::whole`). `layout`/`redraw`/
|
|
// `primitives` are still real, because `render.update` just ran;
|
|
// see `iris::diagnostics::log_frame`'s own doc for why this reads
|
|
// those back rather than timing anything itself.
|
|
crate::diagnostics::log_frame(
|
|
&self.render,
|
|
now,
|
|
FrameParts::whole(at.elapsed()),
|
|
animating,
|
|
);
|
|
}
|
|
|
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
|
/// fling needs, since it moves only while something ticks it
|
|
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
|
|
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
|
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
|
let mut t = from_ms;
|
|
while t <= end_ms {
|
|
self.frame(t);
|
|
t += step_ms;
|
|
}
|
|
t - step_ms
|
|
}
|
|
|
|
/// One pointer sample through the sensors, then the frame it belongs
|
|
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
|
/// call. Each sample is its own input frame, dated by the sample
|
|
/// rather than by when this ran.
|
|
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
|
self.cursor.time = self.at(t_ms);
|
|
self.cursor.pos = pos;
|
|
match action {
|
|
TouchAction::Down => {
|
|
self.cursor.exists = true;
|
|
self.cursor.buttons.left.update(true);
|
|
}
|
|
TouchAction::Move => {}
|
|
TouchAction::Up => self.cursor.buttons.left.update(false),
|
|
// The platform taking the gesture away, not the finger
|
|
// lifting -- see `CursorState::cancelled`.
|
|
TouchAction::Cancel => {
|
|
self.cursor.buttons.left.update(false);
|
|
self.cursor.cancelled = true;
|
|
}
|
|
}
|
|
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
|
|
// doc): no batching happens here, so `historical` is always empty
|
|
// and `t_ms` is the script's own column, which is what makes this
|
|
// round-trip through `report_to_touch.py` back into an identical
|
|
// `TouchScript`.
|
|
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
|
|
let cursor = self.cursor.clone();
|
|
self.render
|
|
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
|
self.frame(t_ms);
|
|
self.cursor.end_frame();
|
|
}
|
|
|
|
/// Replays a whole recorded gesture. Nothing is inserted between the
|
|
/// samples: a file with three lines produces three input frames, so
|
|
/// the batched shape a real flick arrives in is preserved exactly as
|
|
/// recorded rather than smoothed into evenly-spaced motion.
|
|
pub fn replay(&mut self, script: &TouchScript) {
|
|
for sample in &script.samples {
|
|
self.touch(sample.action, sample.pos, sample.t_ms);
|
|
}
|
|
}
|
|
}
|