Skip to content

Commit 3fa648b

Browse files
authored
perf: schedule engine frames at the composition frame rate (throttled ticker) (#426)
Follow-up to the discussion in #420 (and #419): as measured there, the rebuild gate from #423 was not enough — the auto-animation's ticker still re-armed a frame callback on every vsync, so the engine kept running the full pipeline (frame scheduling, build/paint flush, scene submission, raster) at the display refresh rate. Gating rebuilds alone recovered only ~2% CPU because that per-frame engine overhead dominates. ## Approach Drive the auto-animation with a **throttled `Ticker`**: after each tick, re-arming the vsync callback is delayed with a one-shot timer aimed at the last vsync preceding the next composition-frame boundary (each tick timestamp *is* a vsync timestamp, so the vsync phase is known). Timers never fire early, so the frame request goes out one vsync ahead and the tick lands on the first vsync at or after the boundary: - no *skipped* frames when the rates don't divide (24/25fps content on a 60Hz display), - no *slipped* frames when they match (60fps content on 60Hz stays at 60, the throttle becomes a no-op), - elapsed time still comes from frame timestamps, so a late timer **drops** frames instead of slowing the animation down. Because the ticker parks on a timer between composition frames, no engine frame is even scheduled in between — the whole pipeline runs at the composition rate, which is where the savings come from. This achieves the gains of the Timer-drive proposal in #420 while keeping `AnimationController` semantics (statuses, wall-clock accuracy, curves), `TickerMode` (including `forceFrames`), and app-lifecycle behavior (in the background the timer chain parks itself after a single fire), with no new public controller API. ## Measured results (macOS profile build, 120Hz display, composition in a ListView) | | Engine frames/s | Process CPU | |---|---|---| | master | 120 | 13.8% | | this PR | 30 (LottieLogo1, 30fps) | 6.3% | Composition-rate accuracy across assets: 24fps → 24 frames/s, 25fps → ~25, 30fps → ~30, 60fps → ~60. ## Behavior notes - **External `AnimationController`s are unaffected** — they keep the vsync-driven path (plus the #423 gate). - **`flutter test` is unaffected**: the throttle is inactive under the test runner so `tester.pumpAndSettle()` keeps observing the animation exactly as before. `debugThrottleAnimationsInTests` re-enables it (used by this package's own throttle tests). - `FrameRate.max` keeps rendering every display frame; `frameRate: FrameRate(x)` throttles to x. - The vsync period is resolved from the widget's own `View` (multi-view safe, 60Hz fallback). ## Extras - `FrameRate.resolveFps` — single resolver for the `composition`/`max` sentinels (previously duplicated in `roundProgress` and the widget). - `example/lib/frame_rate_demo.dart` — interactive test page with live engine-fps/build/raster metrics, asset & frame-rate pickers, TickerMode/lifecycle/external-controller toggles, concurrent staggered animations, and a side-by-side pacing comparison (`flutter run -t lib/frame_rate_demo.dart --profile`). - `example/lib/bench_main.dart` — headless benchmark entrypoint (`--dart-define=ASSET=...`). ## Testing 9 dedicated widget tests in `test/frame_rate_throttle_test.dart` (throttled rebuild rate, no engine frame scheduled between composition frames, no-op at/above display rate, wall-clock correctness, no skipped frames at 25fps-on-60Hz, TickerMode pause/resume, zero frames when mounted under a disabled TickerMode, pumpAndSettle compatibility). Full suite: 1025 tests pass, goldens unchanged.
1 parent 91ce46f commit 3fa648b

16 files changed

Lines changed: 934 additions & 40 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## 3.6.0
2+
- Reduce CPU usage by only scheduling animation frames at the composition frame rate instead of on every vsync
3+
14
## 3.5.2
25
- Fix a CanvasKit stack overflow when drawing animated Trim Paths on Flutter web
36

example/android/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
plugins {
22
id "com.android.application"
3-
id "kotlin-android"
3+
id "org.jetbrains.kotlin.android"
44
id "dev.flutter.flutter-gradle-plugin"
55
}
66

example/android/build.gradle

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,3 @@
1-
buildscript {
2-
ext.kotlin_version = '1.7.10'
3-
repositories {
4-
google()
5-
mavenCentral()
6-
}
7-
8-
dependencies {
9-
classpath 'com.android.tools.build:gradle:7.3.0'
10-
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
11-
}
12-
}
13-
141
allprojects {
152
repositories {
163
google()

example/android/gradle.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
org.gradle.jvmargs=-Xmx1536M
22
android.useAndroidX=true
33
android.enableJetifier=true
4+
# This builtInKotlin flag was added automatically by Flutter migrator
5+
android.builtInKotlin=false
6+
# This newDsl flag was added automatically by Flutter migrator
7+
android.newDsl=false

example/android/gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
33
zipStoreBase=GRADLE_USER_HOME
44
zipStorePath=wrapper/dists
5-
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
5+
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

example/android/settings.gradle

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@ pluginManagement {
55
def flutterSdkPath = properties.getProperty("flutter.sdk")
66
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
77
return flutterSdkPath
8-
}
9-
settings.ext.flutterSdkPath = flutterSdkPath()
8+
}()
109

11-
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
10+
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
1211

13-
plugins {
14-
id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false
12+
repositories {
13+
google()
14+
mavenCentral()
15+
gradlePluginPortal()
1516
}
1617
}
1718

18-
include ":app"
19+
plugins {
20+
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
21+
id "com.android.application" version "8.6.0" apply false
22+
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
23+
}
1924

20-
apply from: "${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle/app_plugin_loader.gradle"
25+
include ":app"

example/lib/bench_main.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import 'dart:async';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter/scheduler.dart';
4+
import 'package:lottie/lottie.dart';
5+
6+
void main() {
7+
WidgetsFlutterBinding.ensureInitialized();
8+
var frames = 0;
9+
SchedulerBinding.instance.addTimingsCallback((timings) {
10+
frames += timings.length;
11+
});
12+
Timer.periodic(const Duration(seconds: 1), (_) {
13+
// ignore: avoid_print
14+
print('frames/s: $frames');
15+
frames = 0;
16+
});
17+
runApp(const BenchApp());
18+
}
19+
20+
class BenchApp extends StatelessWidget {
21+
const BenchApp({super.key});
22+
23+
@override
24+
Widget build(BuildContext context) {
25+
return MaterialApp(
26+
home: Scaffold(
27+
body: ListView(
28+
children: [
29+
Lottie.asset(
30+
const String.fromEnvironment(
31+
'ASSET',
32+
defaultValue: 'assets/LottieLogo1.json',
33+
),
34+
),
35+
],
36+
),
37+
),
38+
);
39+
}
40+
}

0 commit comments

Comments
 (0)