Skip to content

feat: Lottie animation renders at composition frame rate, reducing unnecessary load - #420

Open
OuHT wants to merge 2 commits into
xvrh:masterfrom
OuHT:master
Open

feat: Lottie animation renders at composition frame rate, reducing unnecessary load#420
OuHT wants to merge 2 commits into
xvrh:masterfrom
OuHT:master

Conversation

@OuHT

@OuHT OuHT commented Jun 25, 2026

Copy link
Copy Markdown

From issue #419

Lottie animation renders at composition frame rate, reducing unnecessary load

Flutter Ticker forces Lottie animation to synchronize with 60Hz Vsync. Even when the composition's original frame rate (fr field) is only 15/30fps, the system still executes the full rendering pipeline at 60Hz, causing unnecessary CPU/GPU load.

This solution adopts Timer drive by default, rendering at the composition's original frame rate or configured frameRate, decoupling animation frequency from screen refresh rate to reduce power consumption.

Key Changes

Change Description
Default Timer drive Without external controller / with LottieController, render via Timer at configured frame rate
Compatible Ticker drive With external AnimationController, automatically uses original Ticker drive
New LottieController Same API as AnimationController, no TickerProviderStateMixin required
frameRate takes effect Configuring FrameRate(30) or FrameRate.composition renders at specified frame rate

Usage

// Default: Timer drive at composition's original frame rate (fr field)
Lottie.asset('assets/animation.json')

// Specify 30fps
Lottie.asset('assets/animation.json', frameRate: FrameRate(30))

// External LottieController
late final LottieController _controller;

Measured Benefits (Android)

Test Demo:

import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ListView(
          children: [
            // Load a Lottie file from your assets
            Lottie.asset('assets/LottieLogo1.json'),
          ],
        ),
      ),
    );
  }
}

Rendering at fr-specified frame rate from JSON:

Metric Before (Ticker 60Hz) After (Timer at fr-specified rate) Reduction
Test 1 4,768,733,827 2,783,022,888 41.6%
Test 2 4,591,814,457 2,975,770,379 35.2%
Test 3 4,765,358,968 2,938,497,704 38.3%
Average 4,708,635,751 2,899,097,124 38.4%

Average 38.4% reduction in rendering overhead.

Backward Compatibility

Existing AnimationController code works without modification, automatically uses Ticker drive.

Signed-off-by: weixin_46147069 <501436674@qq.com>
@xvrh

xvrh commented Jun 25, 2026

Copy link
Copy Markdown
Owner

@OuHT Did you try the frameRate property: https://github.com/xvrh/lottie-flutter#frame-rate
What is better here?

@OuHT

OuHT commented Jun 25, 2026

Copy link
Copy Markdown
Author

@xvrh Yes, I have tried the frameRate property. However, compared to our current driver solution, the power savings were not significant.

@xvrh

xvrh commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Can you explain me what the trick is then? The existing frameRate tries to not schedule a repaint when it's not necessary. Is it the build itself that is eating the CPU?

@OuHT

OuHT commented Jun 26, 2026

Copy link
Copy Markdown
Author

The existing frameRate tries to not schedule a repaint when it's not necessary. It only skips the repaint, but doesn't skip the entire VSync wake-up mechanism that causes the repaint.

It's not the build itself, but being forced to run build 60 times/second that eats the CPU (assuming a 60Hz screen):

frameRate=30: VSync wakes up every 16.7ms → build runs 60/s, paint is just skipped at the end
Timer 30Hz: No VSync forced wake-up → build only runs 30/s

Can you explain me what the trick is then? The existing frameRate tries to not schedule a repaint when it's not necessary. Is it the build itself that is eating the CPU?

@OuHT

OuHT commented Jun 26, 2026

Copy link
Copy Markdown
Author

Summary

frameRate only skips paint, but VSync still wakes up the entire Flutter pipeline every 16.7ms. Timer skips VSync itself.


The problem with existing frameRate: VSync loop can't stop

VSync interrupt (16.7ms)
  ↓
AnimationController.tick → notifyListeners()
  ↓
AnimatedBuilder.build() → setProgress() → roundProgress()
  ↓
markNeedsPaint()? ❌ skipped (the only one skipped)
paint()? ❌ skipped (the only one skipped)
  ↓
Engine::RequestFrame() → register next VSync
  ↓
Back to start, wake up again after 16.7ms...

Even with frameRate=30, this loop still runs every 16.7ms. roundProgress() finds the frame hasn't changed, only skips paint, but all preceding steps like build still execute.

Why can't RequestFrame() stop? AnimationController registers callbacks with SchedulerBinding through Ticker:

Ticker.scheduleTick()
SchedulerBinding.scheduleFrameCallback()
scheduleFrame()
platformDispatcher.scheduleFrame().

The Engine calls scheduleFrame() at the end of every frame to register the next VSync, forming an infinite loop. As long as AnimationController is running, this loop cannot stop.


Timer drive: Breaking the VSync loop

Timer (33ms)
  ↓
setState() → build() → setProgress()
  ↓
markNeedsPaint() → paint() → GPU
  ↓
Trigger again after 33ms...

No AnimationController, no Ticker, no RequestFrame(). The VSync loop is broken.

Why doesn't Timer trigger RequestFrame()? setState() only marks the current Element as dirty, adding it to BuildOwner's dirty list. It does not call scheduleFrame(), does not request VSync from the Engine. Only when VSync arrives due to other reasons (like user interaction or other animations) will it rebuild in handleBuildDirtyElements(). Timer controls its own trigger rhythm at 33ms intervals, independent of VSync.


@OuHT

OuHT commented Jun 26, 2026

Copy link
Copy Markdown
Author

Use the above Test Demo

E4418CF6-06E8-4D53-CBB3-A556FA3A605D B4216D1C-8795-4A6E-ECF0-FDC7AB2BC153

@OuHT

OuHT commented Jul 7, 2026

Copy link
Copy Markdown
Author

Hi @xvrh , sorry for the follow-up.

Regarding the implementation details I explained earlier, I was wondering if the explanation was clear enough, or if there's anything I should clarify?

I'd be happy to add more test cases or adjust the implementation approach if needed. Looking forward to your feedback. Thank you!

@xvrh

xvrh commented Jul 7, 2026

Copy link
Copy Markdown
Owner

@OuHT I propose this alternative approach that should bring the same gains: #423

Signed-off-by: weixin_46147069 <501436674@qq.com>
@OuHT

OuHT commented Jul 16, 2026

Copy link
Copy Markdown
Author

@xvrh I studied your PR, the gate rebuild approach is clever and does reduce unnecessary build calls.

I tested three approaches with a 30fps composition on a 60Hz screen, measuring CPU cycles:

Approach Test 1 Test 2 Test 3 Average vs Baseline
Baseline 4,777M 4,767M 4,749M 4,764M baseline
Your PR (gate rebuild) 4,463M 4,759M 4,739M 4,654M -2.3%
Timer drive 2,901M 2,943M 2,962M 2,935M -38.4%

Your PR successfully reduced Lottie widget's rebuild frequency (from 60/s to ~28/s), but the AnimationController VSync loop still runs at 60Hz:

VSync (16.7ms) → AnimationController.tick → notifyListeners → [gate: skip Lottie build] → RequestFrame → next VSync

The gate skips Lottie's build()/paint(), but all upstream steps (VSync interrupt, Engine scheduling, ticker callback, listener notification) still fire 60 times per second — this overhead is the dominant cost.

Timer drive bypasses AnimationController and breaks this VSync loop entirely:

Timer (33ms) → setState() → build → paint

No AnimationController, no Ticker, no RequestFrame(). The whole pipeline truly drops to 30Hz.

To achieve this, I added LottieController as an alternative. It fully replaces AnimationController as an external controller, with the same control API (forward/reverse/stop/repeat/animateTo), same listener interface (addListener/addStatusListener), same property access (value/status/isAnimating). In my follow-up commits, it now also supports TickerMode (via setTickerModeEnabled()) and App Lifecycle (internal AppLifecycleListener for auto pause/resume).

External Controller Comparison:

External Controller Drive Mode TickerMode App Lifecycle
LottieController Timer
AnimationController Ticker

Both controllers support TickerMode and App Lifecycle, just with different drive mechanisms.

Perfetto confirms this — your PR keeps AsyncWorker wake-up interval at 16.7ms, while Timer drive changes it to 33.3ms.

Would you consider defaulting to Timer drive when no external controller is provided, using Timer drive with LottieController, and automatically falling back to Ticker drive with AnimationController for compatibility? This achieves 38% power savings while fully maintaining backward compatibility.

@xvrh

xvrh commented Jul 23, 2026

Copy link
Copy Markdown
Owner

@OuHT can you have a look at this alternative PR #426 and test it with your benchmark. Thanks

xvrh added a commit that referenced this pull request Aug 21, 2026
The #423 gate stopped the Lottie subtree from rebuilding on every vsync,
but the auto-animation's ticker still re-armed a frame callback each
display frame, so the engine kept running the full pipeline (scheduling,
build/paint flush, scene submission, raster) at the display refresh
rate. As measured in the #420 discussion, that per-frame overhead
dominates: gating rebuilds alone recovered only ~2% CPU.

Drive the auto-animation with a throttled Ticker instead: 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
(tick timestamps are vsync timestamps, 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 — frames
are neither skipped (24fps content on a 60Hz display) nor slipped
(60fps content rendering at half rate). 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 frames, the whole pipeline
now runs at the composition rate. Benchmarked on macOS (profile,
120Hz display, LottieLogo1 30fps in a ListView): 120 -> 30 engine
frames/s, 13.8% -> 6.3% process CPU. 24/25/30/60fps compositions all
land exactly on their composition rate.

Behavior notes:
- External AnimationControllers keep the previous vsync-driven path.
- TickerMode (including forceFrames) and app lifecycle keep working:
  muting cancels the pending timer, and in the background the chain
  parks itself after a single timer fire.
- The throttle is inactive under `flutter test` so that
  tester.pumpAndSettle() keeps observing the animation;
  debugThrottleAnimationsInTests re-enables it (used by this package's
  own tests).
- FrameRate.max keeps rendering every display frame.

Also add FrameRate.resolveFps as the single place that resolves the
composition/max sentinels, an interactive test page
(example/lib/frame_rate_demo.dart) with live engine-fps metrics, and a
headless benchmark entrypoint (example/lib/bench_main.dart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xvrh added a commit that referenced this pull request Aug 21, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants