前言:高性能的渲染一直是Flutter引以为傲的地方,通过自行调用Skia引擎绘制,达到媲美原生的渲染效果。然而这种高帧率且无法修改帧率的渲染,却在性能不高的机器上,给我们带来了不少麻烦。今天我们就来讨论下如何在Flutter上,降低动画的帧率。
一、背景
在Flutter Windows的开发过程中,展示动画的时候发现GPU占有很高,比如一个简单的加载框,都会占用很高的GPU资源。比如在NVIDIA Quadro P620的2G独显上展示下图的加载框CircularProgressIndicator
,GPU占用就长期在30%以上。这已经是专业的独显了,还这么耗性能!
所以当需要在低性能的设备上长时间的展示动画的时候,这个问题就成为性能瓶颈。比如下面这个场景:
- 设备的GPU是intel hd 630,属于入门级的低端显卡;
- 使用
AnimatedBuilder
展示两个旋转的图片,一个顺时针、一个逆时针,图片尺寸是比较大的; - 动画过程中GPU超过100%,而且还会占用CPU进行渲染,消耗了大量的资源,性能隐患很大。
二、孰对孰错
通过对动画的调用速度进行监听,我们发现刷新的间隔稳定在16ms,完全满足60fps。Skia引擎使用GPU加速渲染,尽最大的努力使用硬件的能力,GPU渲染能力不足后,会使用CPU进行软件计算渲染 ,这样的流畅度理论上值得点赞。
无奈的是Flutter的动画刷新速度,开发者目前不能做任何配置,在长期需要显示动画的时候,GPU都会占用的很高,这是很遗憾的现实。
三、Flutter动画的原理
- Flutter的动画通过
AnimationController
控制Animation
对象,controller会在构造函数中调用vsync.createTicker(_tick)
;而_tick
方法则会调用notifyListeners
方法,通知到Animation
进行刷新;
dart
// animation_controller.dart 236行
AnimationController({
double? value,
this.duration,
this.reverseDuration,
this.debugLabel,
this.lowerBound = 0.0,
this.upperBound = 1.0,
this.animationBehavior = AnimationBehavior.normal,
required TickerProvider vsync,
}) : assert(upperBound >= lowerBound),
_direction = _AnimationDirection.forward {
// 这里调用TickerProvider.createTicker方法
_ticker = vsync.createTicker(_tick);
_internalSetValue(value ?? lowerBound);
}
dart
// animation_controller.dart 891行
void _tick(Duration elapsed) {
_lastElapsedDuration = elapsed;
final double elapsedInSeconds = elapsed.inMicroseconds.toDouble() / Duration.microsecondsPerSecond;
assert(elapsedInSeconds >= 0.0);
_value = clampDouble(_simulation!.x(elapsedInSeconds), lowerBound, upperBound);
if (_simulation!.isDone(elapsedInSeconds)) {
_status = (_direction == _AnimationDirection.forward) ?
AnimationStatus.completed :
AnimationStatus.dismissed;
stop(canceled: false);
}
// 通知
notifyListeners();
_checkStatusChanged();
}
AnimationController
需要传入TickerProvider
,controller在适当的时机调用_startSimulation
这个方法,这个方法则是调用了Ticker的start方法;
dart
// animation_controller.dart 636行
TickerFuture repeat({ double? min, double? max, bool reverse = false, Duration? period }) {
min ??= lowerBound;
max ??= upperBound;
period ??= duration;
assert(() {
if (period == null) {
throw FlutterError(
'AnimationController.repeat() called without an explicit period and with no default Duration.\n'
'Either the "period" argument to the repeat() method should be provided, or the '
'"duration" property should be set, either in the constructor or later, before '
'calling the repeat() function.',
);
}
return true;
}());
assert(max >= min);
assert(max <= upperBound && min >= lowerBound);
stop();
return _startSimulation(_RepeatingSimulation(_value, min, max, reverse, period!, _directionSetter));
}
dart
// animation_controller.dart 740行
TickerFuture _startSimulation(Simulation simulation) {
assert(!isAnimating);
_simulation = simulation;
_lastElapsedDuration = Duration.zero;
_value = clampDouble(simulation.x(0.0), lowerBound, upperBound);
// 调用Ticker的start方法
final TickerFuture result = _ticker!.start();
_status = (_direction == _AnimationDirection.forward) ?
AnimationStatus.forward :
AnimationStatus.reverse;
_checkStatusChanged();
return result;
}
- Ticker的Start方法,开始请求帧绘制。注意这里是循环调用的,
scheduleTick
和_tick
互相调用;所以一帧调用完毕就会马上调用下一帧;
dart
// ticker.dart 243行
void _tick(Duration timeStamp) {
assert(isTicking);
assert(scheduled);
_animationId = null;
_startTime ??= timeStamp;
_onTick(timeStamp - _startTime!); // 回调给animationController
// The onTick callback may have scheduled another tick already, for
// example by calling stop then start again.
if (shouldScheduleTick) {
scheduleTick(rescheduling: true);
}
}
/// Schedules a tick for the next frame.
///
/// This should only be called if [shouldScheduleTick] is true.
@protected
void scheduleTick({ bool rescheduling = false }) {
assert(!scheduled);
assert(shouldScheduleTick);
// 通过scheduleFrameCallback请求下一帧,然后会回调给_tick处理
_animationId = SchedulerBinding.instance.scheduleFrameCallback(_tick, rescheduling: rescheduling);
}
从这里就可以看出,AnimationController触发了Ticker的start方法,然后Ticker开始不断请求祯进行刷新。
四、解决方案
熟悉Android的同学应该都知道,Android的属性动画有setFrameDelay
这个方法,可以设置动画祯之间的间隙,调整帧率,在不影响动画效果的情况下可以减少CPU资源消耗。
通过上面的分析,其实我们在_tick
调用scheduleTick
时,进行一下间隙的设置,等于延缓了调用下一帧的速度,这样就能实现调整帧率的问题。
在上面的分析,会涉及到两个文件的改动:
- flutter\lib\src\widgets\ticker_provider.dart
- flutter\lib\src\scheduler\ticker.dart
- ticker_provider.dart其实不需要改动,但是它应用的Ticker是写死的,我们改动不了。所以只能改下这个文件,复制下来修改
- ticker.dart主要是改动_tick方法
dart
void _tick(Duration timeStamp) {
assert(isTicking);
assert(scheduled);
_animationId = null;
_startTime ??= timeStamp;
_onTick(timeStamp - _startTime!);
// The onTick callback may have scheduled another tick already, for
// example by calling stop then start again.
if (shouldScheduleTick) {
// 设置刷新间隔,refreshInterval是调用TickerWithInterval的构造函数时传入的参数
Future.delayed(Duration(milliseconds: refreshInterval), () {
if (shouldScheduleTick) scheduleTick();
});
}
}
分析完代码后,其实改动很简单;这样能完全保留AnimationController的使用习惯,接入没有任何成本。
代码示例
- ticker_provider.dar
dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'ticker.dart';
typedef TickerCallback = void Function(Duration elapsed);
@optionalTypeArgs
mixin SingleIntervalTickerProviderStateMixin<T extends StatefulWidget>
on State<T> implements TickerProvider {
TickerWithInterval? _ticker;
@override
TickerWithInterval createTicker(TickerCallback onTick) {
assert(() {
if (_ticker == null) {
return true;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'$runtimeType is a SingleTickerProviderStateMixin but multiple tickers were created.'),
ErrorDescription(
'A SingleTickerProviderStateMixin can only be used as a TickerProvider once.'),
ErrorHint(
'If a State is used for multiple AnimationController objects, or if it is passed to other '
'objects and those objects might use it more than one time in total, then instead of '
'mixing in a SingleTickerProviderStateMixin, use a regular TickerProviderStateMixin.',
),
]);
}());
_ticker = TickerWithInterval(onTick,
debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null);
_updateTickerModeNotifier();
_updateTicker(); // Sets _ticker.mute correctly.
return _ticker!;
}
@override
void dispose() {
assert(() {
if (_ticker == null || !_ticker!.isActive) {
return true;
}
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('$this was disposed with an active Ticker.'),
ErrorDescription(
'$runtimeType created a Ticker via its SingleTickerProviderStateMixin, but at the time '
'dispose() was called on the mixin, that Ticker was still active. The Ticker must '
'be disposed before calling super.dispose().',
),
ErrorHint(
'Tickers used by AnimationControllers '
'should be disposed by calling dispose() on the AnimationController itself. '
'Otherwise, the ticker will leak.',
),
_ticker!.describeForError('The offending ticker was'),
]);
}());
_tickerModeNotifier?.removeListener(_updateTicker);
_tickerModeNotifier = null;
super.dispose();
}
ValueListenable<bool>? _tickerModeNotifier;
@override
void activate() {
super.activate();
// We may have a new TickerMode ancestor.
_updateTickerModeNotifier();
_updateTicker();
}
void _updateTicker() {
if (_ticker != null) {
_ticker!.muted = !_tickerModeNotifier!.value;
}
}
void _updateTickerModeNotifier() {
final ValueListenable<bool> newNotifier = TickerMode.getNotifier(context);
if (newNotifier == _tickerModeNotifier) {
return;
}
_tickerModeNotifier?.removeListener(_updateTicker);
newNotifier.addListener(_updateTicker);
_tickerModeNotifier = newNotifier;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
String? tickerDescription;
if (_ticker != null) {
if (_ticker!.isActive && _ticker!.muted) {
tickerDescription = 'active but muted';
} else if (_ticker!.isActive) {
tickerDescription = 'active';
} else if (_ticker!.muted) {
tickerDescription = 'inactive and muted';
} else {
tickerDescription = 'inactive';
}
}
properties.add(DiagnosticsProperty<TickerWithInterval>('ticker', _ticker,
description: tickerDescription,
showSeparator: false,
defaultValue: null));
}
}
@optionalTypeArgs
mixin IntervalTickerProviderStateMixin<T extends StatefulWidget> on State<T>
implements TickerProvider {
Set<TickerWithInterval>? _tickers;
@override
TickerWithInterval createTicker(TickerCallback onTick) {
if (_tickerModeNotifier == null) {
// Setup TickerMode notifier before we vend the first ticker.
_updateTickerModeNotifier();
}
assert(_tickerModeNotifier != null);
_tickers ??= <_WidgetTicker>{};
final _WidgetTicker result = _WidgetTicker(onTick, this,
debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null)
..muted = !_tickerModeNotifier!.value;
_tickers!.add(result);
return result;
}
void _removeTicker(_WidgetTicker ticker) {
assert(_tickers != null);
assert(_tickers!.contains(ticker));
_tickers!.remove(ticker);
}
ValueListenable<bool>? _tickerModeNotifier;
@override
void activate() {
super.activate();
// We may have a new TickerMode ancestor, get its Notifier.
_updateTickerModeNotifier();
_updateTickers();
}
void _updateTickers() {
if (_tickers != null) {
final bool muted = !_tickerModeNotifier!.value;
for (final TickerWithInterval ticker in _tickers!) {
ticker.muted = muted;
}
}
}
void _updateTickerModeNotifier() {
final ValueListenable<bool> newNotifier = TickerMode.getNotifier(context);
if (newNotifier == _tickerModeNotifier) {
return;
}
_tickerModeNotifier?.removeListener(_updateTickers);
newNotifier.addListener(_updateTickers);
_tickerModeNotifier = newNotifier;
}
@override
void dispose() {
assert(() {
if (_tickers != null) {
for (final TickerWithInterval ticker in _tickers!) {
if (ticker.isActive) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('$this was disposed with an active Ticker.'),
ErrorDescription(
'$runtimeType created a Ticker via its TickerProviderStateMixin, but at the time '
'dispose() was called on the mixin, that Ticker was still active. All Tickers must '
'be disposed before calling super.dispose().',
),
ErrorHint(
'Tickers used by AnimationControllers '
'should be disposed by calling dispose() on the AnimationController itself. '
'Otherwise, the ticker will leak.',
),
ticker.describeForError('The offending ticker was'),
]);
}
}
}
return true;
}());
_tickerModeNotifier?.removeListener(_updateTickers);
_tickerModeNotifier = null;
super.dispose();
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<Set<TickerWithInterval>>(
'tickers',
_tickers,
description: _tickers != null
? 'tracking ${_tickers!.length} ticker${_tickers!.length == 1 ? "" : "s"}'
: null,
defaultValue: null,
));
}
}
class _WidgetTicker extends TickerWithInterval {
_WidgetTicker(super.onTick, this._creator, {super.debugLabel});
final IntervalTickerProviderStateMixin _creator;
@override
void dispose() {
_creator._removeTicker(this);
super.dispose();
}
}
- ticker.dart
ini
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
class TickerWithInterval implements Ticker {
TickerWithInterval(this._onTick,
{this.refreshInterval = 30, this.debugLabel}) {
assert(() {
_debugCreationStack = StackTrace.current;
return true;
}());
}
final int refreshInterval;
MyTickerFuture? _future;
@override
bool get muted => _muted;
bool _muted = false;
set muted(bool value) {
if (value == muted) {
return;
}
_muted = value;
if (value) {
unscheduleTick();
} else if (shouldScheduleTick) {
scheduleTick();
}
}
@override
bool get isTicking {
if (_future == null) {
return false;
}
if (muted) {
return false;
}
if (SchedulerBinding.instance.framesEnabled) {
return true;
}
if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.idle) {
return true;
} // for example, we might be in a warm-up frame or forced frame
return false;
}
@override
bool get isActive => _future != null;
Duration? _startTime;
@override
TickerFuture start() {
assert(() {
if (isActive) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('A ticker was started twice.'),
ErrorDescription(
'A ticker that is already active cannot be started again without first stopping it.'),
describeForError('The affected ticker was'),
]);
}
return true;
}());
assert(_startTime == null);
_future = MyTickerFuture._();
if (shouldScheduleTick) {
scheduleTick();
}
if (SchedulerBinding.instance.schedulerPhase.index >
SchedulerPhase.idle.index &&
SchedulerBinding.instance.schedulerPhase.index <
SchedulerPhase.postFrameCallbacks.index) {
_startTime = SchedulerBinding.instance.currentFrameTimeStamp;
}
return _future!;
}
@override
DiagnosticsNode describeForError(String name) {
return DiagnosticsProperty<Ticker>(name, this,
description: toString(debugIncludeStack: true));
}
@override
void stop({bool canceled = false}) {
if (!isActive) {
return;
}
final MyTickerFuture localFuture = _future!;
_future = null;
_startTime = null;
assert(!isActive);
unscheduleTick();
if (canceled) {
localFuture._cancel(this);
} else {
localFuture._complete();
}
}
final TickerCallback _onTick;
int? _animationId;
@override
bool get scheduled => _animationId != null;
@override
bool get shouldScheduleTick => !muted && isActive && !scheduled;
void _tick(Duration timeStamp) {
assert(isTicking);
assert(scheduled);
_animationId = null;
_startTime ??= timeStamp;
_onTick(timeStamp - _startTime!);
// The onTick callback may have scheduled another tick already, for
// example by calling stop then start again.
if (shouldScheduleTick) {
// 设置刷新间隔,refreshInterval是调用TickerWithInterval的构造函数时传入的参数
Future.delayed(Duration(milliseconds: refreshInterval), () {
if (shouldScheduleTick) scheduleTick();
});
}
}
@override
void scheduleTick({bool rescheduling = false}) {
assert(!scheduled);
assert(shouldScheduleTick);
_animationId = SchedulerBinding.instance
.scheduleFrameCallback(_tick, rescheduling: rescheduling);
}
@override
void unscheduleTick() {
if (scheduled) {
SchedulerBinding.instance.cancelFrameCallbackWithId(_animationId!);
_animationId = null;
}
assert(!shouldScheduleTick);
}
@override
void absorbTicker(Ticker originalTicker) {
originalTicker as TickerWithInterval;
assert(!isActive);
assert(_future == null);
assert(_startTime == null);
assert(_animationId == null);
assert(
(originalTicker._future == null) == (originalTicker._startTime == null),
'Cannot absorb Ticker after it has been disposed.');
if (originalTicker._future != null) {
_future = originalTicker._future;
_startTime = originalTicker._startTime;
if (shouldScheduleTick) {
scheduleTick();
}
originalTicker._future =
null; // so that it doesn't get disposed when we dispose of originalTicker
originalTicker.unscheduleTick();
}
originalTicker.dispose();
}
@override
void dispose() {
if (_future != null) {
final MyTickerFuture localFuture = _future!;
_future = null;
assert(!isActive);
unscheduleTick();
localFuture._cancel(this);
}
assert(() {
_startTime = Duration.zero;
return true;
}());
}
@override
final String? debugLabel;
late StackTrace _debugCreationStack;
@override
String toString({bool debugIncludeStack = false}) {
final StringBuffer buffer = StringBuffer();
buffer.write('${objectRuntimeType(this, 'Ticker')}(');
assert(() {
buffer.write(debugLabel ?? '');
return true;
}());
buffer.write(')');
assert(() {
if (debugIncludeStack) {
buffer.writeln();
buffer.writeln(
'The stack trace when the $runtimeType was actually created was:');
FlutterError.defaultStackFilter(
_debugCreationStack.toString().trimRight().split('\n'))
.forEach(buffer.writeln);
}
return true;
}());
return buffer.toString();
}
}
class MyTickerFuture implements TickerFuture {
MyTickerFuture._();
MyTickerFuture.complete() {
_complete();
}
final Completer<void> _primaryCompleter = Completer<void>();
Completer<void>? _secondaryCompleter;
bool?
_completed; // null means unresolved, true means complete, false means canceled
void _complete() {
assert(_completed == null);
_completed = true;
_primaryCompleter.complete();
_secondaryCompleter?.complete();
}
void _cancel(Ticker ticker) {
assert(_completed == null);
_completed = false;
_secondaryCompleter?.completeError(TickerCanceled(ticker));
}
void whenCompleteOrCancel(VoidCallback callback) {
void thunk(dynamic value) {
callback();
}
orCancel.then<void>(thunk, onError: thunk);
}
Future<void> get orCancel {
if (_secondaryCompleter == null) {
_secondaryCompleter = Completer<void>();
if (_completed != null) {
if (_completed!) {
_secondaryCompleter!.complete();
} else {
_secondaryCompleter!.completeError(const TickerCanceled());
}
}
}
return _secondaryCompleter!.future;
}
@override
Stream<void> asStream() {
return _primaryCompleter.future.asStream();
}
@override
Future<void> catchError(Function onError, {bool Function(Object)? test}) {
return _primaryCompleter.future.catchError(onError, test: test);
}
@override
Future<R> then<R>(FutureOr<R> Function(void value) onValue,
{Function? onError}) {
return _primaryCompleter.future.then<R>(onValue, onError: onError);
}
@override
Future<void> timeout(Duration timeLimit,
{FutureOr<void> Function()? onTimeout}) {
return _primaryCompleter.future.timeout(timeLimit, onTimeout: onTimeout);
}
@override
Future<void> whenComplete(dynamic Function() action) {
return _primaryCompleter.future.whenComplete(action);
}
@override
String toString() =>
'${describeIdentity(this)}(${_completed == null ? "active" : _completed! ? "complete" : "canceled"})';
}
class MyTickerCanceled implements TickerCanceled {
const MyTickerCanceled([this.ticker]);
final Ticker? ticker;
@override
String toString() {
if (ticker != null) {
return 'This ticker was canceled: $ticker';
}
return 'The ticker was canceled before the "orCancel" property was first used.';
}
}
- 调用方式
dart
class DemoAnimation extends StatefulWidget {
const DemoAnimation({Key? key}) : super(key: key);
@override
State<DemoAnimation> createState() => _DemoAnimationState();
}
class _DemoAnimationState extends State<DemoAnimation>
with SingleIntervalTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
int? mill;
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 8),
);
总结
通过分析动画的源码,加入帧延时,实现对动画帧率的控制;在硬件达标的时候,这篇文章一定能帮到你;通过此方案,文章开头中的动画,同一个机器下,GPU的占用由34%降为12%
我也把这个方案提在issus上了,希望官方能尽快支持类似Android setFrameDelay
这种接口。