feat(android): Start frames on vsync with its time - #24981
MartinZikmund wants to merge 4 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp
|
Claude finished @MartinZikmund's task in 4m 6s —— View job Review complete ✅Verdict: ship — the change is a solid, self-contained improvement (one shared Findings posted inline (2, both low severity / nitpicks):
Cross-cutting observations (not inline, don't map to a changed line):
Not independently re-validated (relying on the PR description's own validation section): the Android-emulator runtime pass and the "fails before / passes after" vsync test behavior — I did not have an Android emulator available in this review environment. |
|
Tick the box to add this pull request to the merge queue (same as
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The strengthened test has an insufficient timeout and incorrectly assumes a fixed refresh cadence.
Review effort: Balanced
Findings: 2
Open (3)
What changed in this PR
Adds Android Choreographer-based frame pacing and forwards native vsync timestamps into the composition frame clock.
Changes:
- Shares frame pacing across Canvas, Vulkan, and WebGPU renderers.
- Removes WebGPU’s periodic idle wake-up.
- Extends and strengthens Android vsync runtime coverage.
| File | Description |
|---|---|
Given_CompositionTarget.cs |
Expands vsync timing validation to Android. |
UnoWebGpuView.cs |
Adds paced rendering and removes idle polling. |
UnoVulkanView.cs |
Forwards Choreographer timestamps. |
UnoCanvasView.cs |
Gates GL rendering on Choreographer callbacks. |
ChoreographerFramePacer.cs |
Converts, stores, and expires vsync timestamps. |
| try | ||
| { | ||
| await TestServices.WindowHelper.WaitFor(() => ticks >= 10, message: "the driver should keep ticking"); | ||
| await TestServices.WindowHelper.WaitFor(() => timestamps.Count >= 40, message: "the driver should keep ticking"); |
| var intervals = timestamps.Skip(5).Zip(timestamps.Skip(6), (a, b) => b - a).ToArray(); | ||
| var period = intervals.OrderBy(i => i).ElementAt(intervals.Length / 2); | ||
| var tolerance = TimeSpan.TicksPerMillisecond * 0.3; | ||
| var worst = intervals.Max(i => Math.Abs(i - Math.Max(1, Math.Round(i / (double)period)) * period)); |
| [TestMethod] | ||
| [RunsOnUIThread] | ||
| [PlatformCondition(ConditionMode.Include, RuntimeTestPlatforms.SkiaWasm | RuntimeTestPlatforms.SkiaUIKit)] | ||
| [PlatformCondition(ConditionMode.Include, RuntimeTestPlatforms.SkiaWasm | RuntimeTestPlatforms.SkiaUIKit | RuntimeTestPlatforms.SkiaAndroid)] |
| if (disposing) | ||
| { | ||
| _disposed = true; | ||
| _framePacer.Dispose(); |
There was a problem hiding this comment.
Disposal ordering here differs from UnoVulkanView.Dispose(bool) (lines 361-385 of that file), which disposes _framePacer only after _renderThread?.Join(...) has returned — i.e. only once the render thread is guaranteed to have stopped calling into it.
Here _framePacer.Dispose() runs before _renderEvent.Set()/_renderThread?.Join(...), so the render thread can still be inside RenderFrame() → _framePacer.TakeVsyncTimestamp() while the pacer is being disposed (Choreographer.RemoveFrameCallback on the Java peer). It likely doesn't crash today since TakeVsyncTimestamp() is pure C# state (no JNI call), but it's a latent inconsistency — a future change to ChoreographerFramePacer.Dispose() that touches more state would introduce a real race here. Consider moving _framePacer.Dispose() after the Join call to mirror the (correct) ordering in UnoVulkanView.
| var intervals = timestamps.Skip(5).Zip(timestamps.Skip(6), (a, b) => b - a).ToArray(); | ||
| var period = intervals.OrderBy(i => i).ElementAt(intervals.Length / 2); | ||
| var tolerance = TimeSpan.TicksPerMillisecond * 0.3; | ||
| var worst = intervals.Max(i => Math.Abs(i - Math.Max(1, Math.Round(i / (double)period)) * period)); |
There was a problem hiding this comment.
period is the median interval (line 241). If two consecutive vsync timestamps ever come back equal (e.g. a duplicate/deduped tick), that 0 has a good chance of landing at the median index, making i / (double)period divide by zero → NaN → Math.Max(1, NaN) is NaN (Math.Max propagates NaN) → worst becomes NaN, and NaN <= tolerance is false. The test would then fail with a NaN-filled message instead of a clear diagnostic, making a real flake hard to distinguish from this edge case.
Not a correctness bug in the production code, just a robustness gap in the new assertion — worth an explicit guard/assert that period > 0 before using it as a divisor, so a degenerate case fails loudly with its own message rather than producing NaN noise.


GitHub Issue: Part of #24963
PR Type:
✨ Feature
What changed? 🚀
Before
Choreographervsync, but droppedframeTimeNanos.GLSurfaceViewthat drew as soon as it was asked, held back only byeglSwapBuffersback-pressure.After
ChoreographerFramePacer:DoFramestores the vsync time and wakes the render thread;OnNativePlatformFrameRequested(..., vsyncTimestamp:).frameTimeNanosis converted through its age (JavaSystem.NanoTime() - frameTimeNanos), the same way the other hosts do it.When_Host_Reports_Vsync_Then_Frame_Time_Is_The_Vsyncnow runs onSkiaAndroid. It checks that frame intervals sit within 0.3 ms of whole multiples of the median. The oldaheadOfClock <= 0check could never fail, because frame times are clamped to now.Trade-off: the Canvas view now waits for the next vsync instead of drawing at once, the way Android's own views do. This can add up to a frame to one-off redraws after input. Starting a frame at once when the current vsync interval has none yet is a follow-up. It will reuse the shared frame scheduler from the macOS PR.
Validation
net10.0-androidSamplesApp.Given_CompositionTarget+Given_Compositor+Given_InteractionTrackeron the Canvas view (software and GLES) and the Vulkan view.When_Rendering_Then_Layout_Of_The_Tick_Is_Currentfails the same way before and after.PR Checklist ✅
Screenshots Compare Test Runresults.🤖 Generated with Claude Code
https://claude.ai/code/session_01JmE5zrbCJiMAv1E5yChjQp