Flutter使得构建美观的用户界面变得非常快捷。这种高效性正是该框架最突出的优势之一,但同时也带来了一个潜在问题:如果没有合适的工具,就很容易出现性能问题,而且也很难发现这些问题。
那些用户能够明显察觉到的卡顿、延迟或冻结现象,其根源往往并不在开发者预想的地方。当问题出在组件的重新渲染时,人们往往会将原因归咎于网络连接;而当问题实际上是由于主线程中的同步解析操作导致的,人们又会去检查API的性能;还有些情况下,真正的罪魁祸首其实是那些每一帧都在生成“SaveLayer”动画的代码,但人们却误以为问题出在状态管理上。
猜测性能问题的存在与对其进行详细分析是完全不同的两回事。Flutter DevTools使得性能分析变得简单、准确且具有实际意义。本文将为您提供如何有效使用这一工具的实际指导。
目录
什么是卡顿现象
卡顿是指Flutter应用程序的用户界面中出现的任何可见的延迟、停滞或不流畅的现象。这种体验会让用户觉得有些地方出了问题,比如动画播放时出现了跳帧,滚动页面时出现了短暂的停顿,或者屏幕切换时显得非常迟缓。
导致卡顿的原因几乎总是相同的:生成某一帧所需的时间太长了。
在大多数设备上,Flutter能够以每秒60帧的速度进行渲染;而在更先进的硬件上,则可以达到每秒120帧。以每秒60帧的速度运行时,Flutter有恰好16毫秒的时间来完成每一帧的生成过程——包括执行Dart代码、构建组件树、计算布局、绘制图像,然后将结果传递给GPU。如果无法在这段时间内完成任务,用户就会看到卡顿现象。
正常帧(流畅运行):
│████████░░░░░░░│ 12毫秒 — 在规定的16毫秒时间范围内 ✓
│████████░░░░░░░│ 12毫秒 — 运行顺畅
│████████░░░░░░░│ 12毫秒 — 运行顺畅
卡顿帧:
│████████░░░░░░░│ 12毫秒 — 运行顺畅
│████████████████████████│ 28毫秒 — 超过了规定时间 ✗
│████████░░░░░░░│ 12毫秒 — 再次运行顺畅
卡顿现象主要有两种不同的成因,因此正确的解决方法也完全取决于具体是哪种原因导致的。
-
UI线程卡顿:Dart代码执行了过多的操作。复杂的组件渲染、在主线程上进行的繁重计算以及同步解析操作都会导致卡顿。
-
光栅线程卡顿:GPU负担过重。过于复杂的视觉效果、过度绘图、过多的图层叠加以及SaveLayer操作都会引发卡顿。
DevTools能帮助你判断到底是哪个环节导致了问题。在修改任何代码之前,了解这种区别是非常重要的。
为进行精确的性能分析做好准备
有一个原则比其他任何原则都更重要:在进行性能分析时,必须使用“分析模式”,而绝不能使用“调试模式”。
调试模式会带来巨大的开销——额外的断言语句、热重载机制、调试绘图功能以及冗长的日志记录。
应用程序在调试模式下运行时,其速度会比在生产环境中慢很多。在调试模式下进行分析可能会发现一些对用户来说并不存在的问题,而那些真正影响应用性能的问题却会被忽略。
# 调试模式——会扭曲测量结果,不适合用于性能分析
flutter run
# 分析模式——能够获得与生产环境相同的环境下的性能数据
# 在DevTools连接的情况下使用此模式
flutter run --profile
分析模式去除了调试模式带来的额外开销,同时仍能保持与DevTools的连接。这种分析方式最接近真实用户的使用体验。
在VS Code中打开DevTools的方法如下:
Cmd+Shift+P → Flutter: Open DevTools → 选择“Performance”选项
在开发过程中,也可以直接在应用程序内部启用性能显示功能,这样无需打开DevTools就能立即看到帧数是否超出了预算:
MaterialApp(
// 屏幕顶部会显示两栏信息。
// 上面一栏显示UI线程的状态,下面一栏显示光栅线程的状态。
// 绿色表示在预算范围内,红色表示超过了预算。
showPerformanceOverlay: true,
home: const MyScreen(),
)
性能视图:解读帧时间线
性能视图是排查任何卡顿问题的起点。在观察帧数变化图表的同时,你可以继续操作应用程序——滚动列表、触发动画效果或在不同屏幕之间切换。
帧数图表
每根垂直条代表一帧内容,条的高度表示该帧的持续时间。红色的水平线标志着16毫秒的预算时间限制。
帧数图表:
▲ 毫秒
28 │ ██
20 │ ██
16 │─────────────────── 红色线条(16毫秒预算)
12 │ ██ ██ ██ ██
8 │ ██ ██ ██ ██
0 └─────────────────────────────→ 帧数
正常 正常 卡顿 正常
任何位于红色线条上方的条都表示该帧存在卡顿现象。点击这些条,可以查看在该帧期间发生了哪些具体操作。
两条线程
当你点击某个出现卡顿的帧时,会看到一个分为两个部分的火焰图,这个图表能帮助你更清楚地了解问题发生的缘由。
UI线程 ████████████████░░░░ — Dart代码执行
光栅线程 ████░░░░░░░░░░░ — GPU操作
如果UI线程对应的条形图非常宽,说明Dart代码存在问题;如果光栅线程对应的条形图很宽,则表示GPU在处理绘图操作时遇到了困难。
解读火焰图
火焰图是一种水平条形图。每一行代表一次函数调用,条形的宽度表示该调用的耗时长度。这些条形图堆叠在一起,可以显示出函数调用的层次结构。
帧耗时(总计28毫秒)
├── dart:ui (16毫秒)
│ └── build (14毫秒)
│ ├── ExpensiveList.build (8毫秒)
│ │ └── _buildItem (8毫秒) ← 条形最宽,表示该操作耗时较多
│ └── AppBar.build (2毫秒)
└── layout (4毫秒)
堆叠结构中最顶端的那些条形图代表耗时最多的函数;它们下面的所有条状图仅显示调用这些函数的函数而已。
CPU分析工具:找出根本原因
“性能视图”能够识别出哪些帧的运行速度较慢,而CPU分析工具则能精确地确定是哪个函数导致了这一问题。
录制性能分析数据
-
在DevTools中打开“CPU分析工具”选项卡
-
点击“开始录制”按钮
-
重新模拟导致性能问题的操作流程
-
点击“停止录制”按钮
-
DevTools会根据录制的数据生成火焰图
解读火焰图
CPU分析工具的火焰图:
← 耗时时间 →
_CounterScreenState.build [████████████████] 45毫秒
Column.build [████████████ ] 35毫秒
ExpensiveWidget.build [████████████ ] 35毫秒
_buildRows [████████ ] 25毫秒
jsonDecode [████████ ] 25毫秒 ← 根本原因
条形图中最宽的部分表示相应的操作耗时最多。在这个例子中,jsonDecode这个函数在`build`方法中被多次调用——每次应用程序重新构建时都会执行这个操作,而不是只在组件树之外被调用一次。
自下而上的分析表格
这种自下而上的分析表格能够显示哪些具体函数承担了最多的工作量:
-
自身耗时:指该函数本身执行所花费的时间,不包括它调用的其他函数。如果某个函数的“自身耗时”很长,说明这个函数本身的运行效率很低。
-
总耗时:包括该函数调用所有下游函数所花费的时间。如果某个函数的“总耗时”很高,说明它在下面的代码中引发了其他耗时的操作。
按照“自身耗时”对函数进行排序,可以找出问题的根本原因;而按照“总耗时”排序,则能确定哪些操作是导致性能问题的诱因。
解决CPU瓶颈问题
在主线程中同步解析大量数据是导致UI线程运行缓慢的常见原因之一。解决方法是将这类任务转移到其他线程中去执行:
// 在修改代码之前的处理方式——每次搜索结果生成时,都会阻塞主线程
Future> processResults(dynamic data) async {
return (data as List)
.map((json) => User.fromJson(json))
.ToList();
}
// 修改代码之后的处理方式——在后台线程中解析数据
// 这样主线程就可以继续正常渲染页面,而数据解析操作可以在后台并行进行
Future> processResults(dynamic data) async {
return Isolate.run(() {
return (data as List)
.map((json) => User.fromJson(json as Map
有一点需要注意:将一个庞大的对象图传递给Isolate.run时,这些数据会被复制到隔离环境中。对于体积庞大的数据来说,这种复制操作所带来的开销可能会与通过隔离机制来处理这些数据所节省的成本相当。
对于那些包含大量数据的JSON响应,更安全的做法是将原始的响应字符串直接传递给隔离环境并在那里进行解析,而不是先将其解码成对象后再传递:
// 对于大数据量来说,这种做法更为安全——原始字符串会被复制到隔离环境中,在那里被解析,只有最终得到的类型化列表才会被复制回来。这样就不会有中间对象图被复制了。
Future> processResults(String rawJson) async {
return Isolate.run(() {
final data = jsonDecode(rawJson) as List;
return data
.map((json) => User.fromJson(json as Map
Flutter Inspector:检测不必要的重建操作
并非所有导致性能下降的问题都源于某些耗时的单独操作。有些问题其实是由于应用程序进行了过多的重建操作造成的——比如那些因为父组件调用了setState而实际上并不需要更新的组件,也会被重新构建。
启用重建次数统计功能
在DevTools的Inspector选项卡中,打开设置并启用“跟踪组件的重建次数”这一功能。当你在应用程序中进行操作时,DevTools会为每个组件显示其重建次数:
带有重建次数统计的组件树结构:
MyApp 0次重建
└── MaterialApp 0次重建
└── CounterScreen 0次重建
└── Scaffold 0次重建
└── Column 24次重建
├── Text 24次重建 —— 是必要的操作
├── Text 24次重建 —— 是必要的操作
└── ExpensiveList 24次重建 —— 这就是问题所在
ExpensiveList这个组件虽然与计数器的状态无关,但却被重新构建了24次。这是因为它位于那些确实需要更新的组件的同一子树结构中,所以也会跟着被重新构建。
通过提取状态信息来修复不必要的重建操作
解决这个问题的方法是把那些会随状态变化而需要重新构建的组件提取出来,单独创建一个新组件。只有这个新组件的状态发生变化时,才会被重新构建,其他组件则不会受到影响。
// 修改前的代码——每次调用setState时,整个Scaffold都会被重建
class _CounterScreenState extends State
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Text('计数:$_count'),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('增加'),
),
// 这个组件的状态永远不会改变,但每次点击都会导致重建
const ExpensiveList(),
],
),
);
}
}
// 在这里,CounterDisplay拥有自己的状态;而ExpensiveList则永远不会被重新构建。
class CounterDisplay extends StatefulWidget {
const CounterDisplay({super.key});
@override
State createState() => _CounterDisplayState();
}
class _CounterDisplayState extends State {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('计数:$_count'),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('增加'),
),
],
);
}
}
// CounterScreen类没有自己的状态,因此也不会被重新构建。
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(
children: [
CounterDisplay(), // 当计数值发生变化时,这个组件会被重新构建
ExpensiveList(), // 这个组件永远不会被重新构建
],
),
);
}
}
使用 RepaintBoundary 来隔离那些需要频繁重绘的元素
当用户界面的某一部分需要频繁重绘,而相邻的部分保持不变时,Repaint Boundary会将这些部分放置在不同的图层中。这样,经常需要更新的部分就可以独立进行重绘,而不会影响到静态内容。
// 不使用 RepaintBoundary — 动画效果会导致整个 Column 在每一帧时都重新绘制
Column(
children: [
AnimatedWidget(controller: _controller),
const ExpensiveStaticContent(),
],
)
// 使用 RepaintBoundary — ExpensiveStaticContent 会位于自己的图层中,在动画播放期间永远不会被重绘
Column(
children: [
AnimatedWidget-controller: _controller),
const RepaintBoundary(
child: ExpensiveStaticContent(),
),
],
)
Repaint Boundary应该谨慎使用,而不是盲目滥用。每添加一个边界,GPU就需要处理一个新的合成图层,因此过度使用这种技术会增加栅格线程的负担,从而抵消掉使用它所带来的性能提升。
内存视图:在用户发现问题之前及时检测内存泄漏
由内存泄漏引起的问题与其他类型的问题表现不同——它们不会立即显现出来。
应用程序在最初的几分钟内运行正常,但随着内存使用量的不断增加,垃圾收集器需要花费更多的精力来回收内存,应用程序的性能会逐渐下降。等到用户发现程序行为异常或运行速度变慢时,内存泄漏已经持续了一段时间了。
在 DevTools 中查看内存泄漏的情况
“内存视图”可以显示内存使用量随时间的变化情况。一个正常运行的应用程序,其内存使用量会呈现锯齿状波动:当新的对象被创建时,内存使用量会上升;而当垃圾收集器运行时,内存使用量又会急剧下降。
正常情况下的内存使用:
▲ MB
60 │ ▲ ← 垃圾收集器运行,内存使用量回归基线值
40 │ ██│██
20 │ ██ │ ██▼ ← 内存使用量先上升后下降
0 └──────────────→ 时间
稳定的基线值
存在内存泄漏的情况下:
▲ MB
80 │ ██
60 │ ████
40 │ ████ ← 内存使用量始终无法回归基线值
20 │████
0 └──────────────→ 时间
内存使用量持续上升
查找内存泄漏点
查找内存泄漏点的步骤如下:
-
打开“内存视图”,记录当前的内存使用量。
-
导航到疑似存在内存泄漏的页面。
-
然后离开该页面。
-
在 DevTools 中点击垃圾收集器按钮,强制执行垃圾收集操作。
-
观察内存使用量的变化:如果它没有回到之前的水平,那就说明某个对象仍然存在于内存中,而这些对象本应该被垃圾收集器清除掉。
在导航到相关页面之前和之后分别获取内存使用量的数据,并进行比较,就可以找出哪些对象在本应被清除的时候仍然留在了内存中。
最常见的内存泄漏来源
未释放的AnimationController:
class _AnimatedScreenState extends State
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void dispose() {
// 如果不执行这行代码,Ticker会持续在每一帧中被触发,
// 这会导致State对象一直被保留在内存中,从而造成内存浪费。
_controller.dispose();
superdispose();
}
}
未取消的StreamSubscription:
class _ChatScreenState extends State {
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_subscription = messageStream.listen((message) {
if (mounted) setState(() => messages.add(message));
});
}
@override
void dispose() {
// 如果不调用cancel()方法,stream会一直保持对这段回调代码的引用,
// 而这段回调代码又会保留对State对象的引用,从而阻止垃圾回收机制正常工作。
_subscription?.cancel();
super.dispose();
}
}
任何在initState方法中创建的对象,如果提供了dispose()、cancel()或close()方法,那么就必须在dispose()方法中被调用。这条规则没有任何例外。
修复最常见的性能问题
DevTools在分析生产环境中的Flutter应用程序时,总会发现同一类性能问题。一旦找到了根本原因,相应的解决方法也就非常明显了。
主隔离线程上进行的耗时同步操作
当出现这种问题时,DevTools会显示UI线程占用时间过长;CPU分析工具则会显示出那些执行耗时较长的解析或排序操作。
// 之前——同步地对10,000个数据进行排序
// 在性能较低的设备上,这一操作会导致主隔离线程被阻塞80到200毫秒
final sorted = List.from(items)
..sort((a, b) => a.name.compareTo(b.name));
// 现在——在后台隔离线程中进行排序
final sorted = await Isolate.run(() {
final copy = List.from(items);
copy.sort((a, b) => a.name.compare(b.name));
return copy;
});
在构建过程中创建的Future对象
当出现这种问题时,DevTools会显示针对同一接口端点的重复API调用;CPU分析工具则会显示出某些网络函数在每次用户操作时都被多次执行。
// 之前——每次重新构建应用程序时都会创建一个新的Future对象。
// FutureBuilder会将每个新的Future对象视为一次独立的操作,并将其状态重置为“加载中”。
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: repository.fetchUser(userId),
builder: (context, snapshot) { ... },
);
}
// 现在——Future对象只在初次初始化时被创建一次,
// 此后的所有重新构建操作都会重用同一个Future对象。
late final Future _userFuture;
@override
void initState() {
super.initState();
_userFuture = repository.fetchUser(widget.userId);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _userFuture,
builder: (context, snapshot) { ... },
);
}
以列形式显示的长列表
DevTools显示:在进入列表界面后,第一帧的加载速度明显慢于后续帧。检查工具显示,该列表包含数百个子元素。
// 旧方式 — 无论当前有多少元素可见,都会一次性生成所有元素
Column(
children: items
.map((item) => ItemCard(item: item))
.ToList(),
)
// 新方式 — 只生成当前在屏幕上可见的元素,以及少量缓冲区中的元素
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ItemCard(item: items[index]);
},
)
动画效果导致的光栅线程卡顿问题
DevTools显示:在动画播放过程中,光栅线程的负载会显著增加。火焰图可以清晰地反映出这一现象。
当`Opacity`的值在不断变化时,Flutter会强制在每一帧都将相关子组件渲染到离屏缓冲区中,然后再根据最终设定的不透明度进行合成显示。这种`SaveLayer`操作是光栅线程执行的最耗时的操作之一。
需要特别说明的是,Flutter的新渲染引擎Impeller——目前它在iOS上已经是默认设置,在Android上也正在逐步推广——显著降低了`SaveLayer`操作带来的性能影响,同时也消除了旧版Skia引擎中存在的着色器编译导致的卡顿问题。
如果应用程序仅针对支持Impeller功能的较新版本的Flutter进行开发,那么由于`Opacity`动画引起的光栅线程卡顿现象可能会比使用旧版Skia时轻缓一些。不过,仍然建议优先使用`FadeTransition`来实现渐变效果,而非使用动画形式的`Opacity`设置,但在这种新引擎环境下,这种推荐的紧迫性已经降低了。
// 不良做法 — 当`Opacity`值在变化时,每一帧都会生成一个新的`SaveLayer`对象,从而导致光栅线程卡顿
Opacity(
opacity: _animationValue,
child: myWidget,
)
// 正确做法 — `FadeTransition`直接利用合成器进行渲染,无需使用离屏缓冲区
FadeTransition(
opacity: _animation,
child: myWidget,
)
验证你的修复方案是否真正有效
性能优化往往只是将性能瓶颈从一处转移到了另一处,并不能彻底解决根本问题。修复了一个效率低下的功能后,可能会发现另一个效率更低的操作成为了影响整体性能的关键因素。
在每次进行修复之前和之后都进行性能测试,可以确保这些问题不会被忽视或忽略掉。
验证步骤如下:
-
在进行任何修改之前,先使用性能分析工具进行测试
-
记录在出现问题的交互过程中最慢的那一步的性能数据
-
确定到底是哪个线程成为了性能瓶颈
-
应用修复方案
-
在相同的条件下再次进行性能测试
-
对比修改前后的性能数据及线程使用情况
也可以通过编程方式来获取帧级性能数据,这种方式对于跟踪长期以来的性能改进情况或在持续集成环境中验证修复方案的有效性非常有用。
WidgetsBinding.instance.addTimingsCallback((timings) {
for (final timing in timings) {
if (timing.totalSpan.inMilliseconds > 16) {
debugPrint(
'帧绘制时间过长:${timing.totalSpan.inMilliseconds}毫秒 '
'代码编译耗时:${timing.buildDuration.inMilliseconds}毫秒 '
'图像渲染耗时:${timing.rasterDuration.inMilliseconds}毫秒',
);
}
}
});
如果在进行了修复后,各项测量数据持续得到改善,那就说明根本原因已经被正确找出了;但如果这些数据没有变化,那么真正的瓶颈其实存在于其他地方,在继续修改代码之前,还需要再进行一次性能分析。
结论
在Flutter应用程序中,性能问题很少会出现在开发者最初怀疑的地方。
最可靠的做法是先进行性能分析,然后再进行修复,而不是反过来。
DevTools能够提供关于帧绘制时间、CPU使用情况、组件重建频率以及内存使用情况的详细信息。通过“性能视图”可以确定是哪个线程导致了帧绘制时间过长的问题;“CPU分析工具”能找出具体导致问题的函数;“检查器”功能则有助于发现那些不必要的组件重建操作;而“内存视图”能够在这些问题影响到用户之前及时发现内存泄漏现象。
在分析模式下进行性能测试、在优化代码之前先进行分析、以及在优化之后再次进行测量,这三点做法能够确保性能问题得到有效解决,而不会一直成为一个反复出现的难题。
对于大多数与Flutter性能相关的问题,其答案其实已经存在于DevTools中。在修改任何代码之前,先打开DevTools查看相关信息吧。