flutter_deer这个项目开源也近一年了,目前收获了3100+的star,这无疑是对这个项目的最大承认。虽然从功能和UI看来和一年前的没什么区别。不过这期间我不断在优化它,但愿它的性能和体验愈来愈好。这篇集中整理了deer在UI流畅上的优化细节,以实践为主,源码为辅。分享出来,但愿对你有所启发和帮助。html
既然要优化,那么首先就要掌握定位问题、分析性能问题的方法,这样才能够对比优化先后的效果。具体方法这里我就不详细介绍了,能够参考官方文档,或是看这个视频:Flutter 的性能测试和理论。java
在官方文档中,性能分析须要确保使用真机并在profile
模式下运行。不过咱们可使用debug
模式来寻找卡顿,由于我以为它能够放大你的“问题”。git
下面正式进入正题。(为了显得口语化一点,我会将Flutter的构建(build
)用“刷新”表示。本篇源码基于Flutter SDK版本 1.17.0
)github
咱们使用setState
方法就能够轻松刷新页面,可是要尽力控制刷新范围。我举一个例子:json
在注册帐户时,一般须要获取验证码。这时会有一个倒计时功能,那么咱们就须要每隔一秒刷新一下这个倒计时数字并显示出来。canvas
若是这个倒计时的逻辑处理你放在了注册页面,那么每当setState
时都是一整个页面的刷新。而这整页刷新显然是没必要要的。而它并不会让你感知到卡顿,因此也不易发现,api
解决方法就是将这个倒计时的按钮单独封装到一个StatefulWidget
,在这个StatefulWidget
中使用setState
刷新,控制刷新范围。缓存
一样的,你也可使用provider等状态管理框架来实现局部刷新。精准控制你的刷新范围,千万不要setState
刷新一把梭。安全
比起控制刷新范围,控制刷新次数(避免无效刷新)甚至更加剧要。这部分我整理了四点,下面逐一说明一下。性能优化
仍是上面的注册场景,这里须要咱们输入的内容知足条件才能够点击注册
按钮。
那么咱们的作法就是监听TextField
的文字输入,每次输入时判断是否知足条件,更新按钮是否可点击的状态。代码大体以下:
bool _clickable = false; void _verify() { String phone = _phoneController.text; String vCode = _vCodeController.text; String password = _passwordController.text; _clickable = true; if (phone.isEmpty || phone.length < 11) { _clickable = false; } if (vCode.isEmpty || vCode.length < 6) { _clickable = false; } if (password.isEmpty || password.length < 6) { _clickable = false; } setState(() { }); } MyButton( onPressed: _clickable ? _register : null, text: '注册', )
其实这里能够优化一下。由于如今的每次输入都一定刷新,咱们能够在_clickable
参数有变化时再刷新,避免无效的刷新。优化的代码以下:
void _verify() { String phone = _phoneController.text; String vCode = _vCodeController.text; String password = _passwordController.text; bool clickable = true; if (phone.isEmpty || phone.length < 11) { clickable = false; } if (vCode.isEmpty || vCode.length < 6) { clickable = false; } if (password.isEmpty || password.length < 6) { clickable = false; } /// 状态不一致时刷新 if (clickable != _clickable) { setState(() { _clickable = clickable; }); } }
就这样一个简单的处理,试想一下能够减小多少次的刷新。
相似的,在CustomPainter
中有个shouldRepaint
的重写方法,咱们能够根据需求控制CustomPainter
是否进行重绘。
动画的使用在实际开发中很常见,可是一旦使用不当也会形成没必要要的刷新,甚至会带来卡顿。
举一个deer中的例子,商品列表页中有一个商品操做菜单的呼入呼出动画(这里就不谈具体的实现效果了,有兴趣的能够去看源码)。一开始的写法以下:
AnimatedBuilder( animation: animation, builder:(_, __) { return MenuReveal( revealPercent: animation.value, child: _buildGoodsMenu(context), ); } )
效果以下:
这个动画看起来仍是比较流畅的。顶部的性能图表(Performance Overlay
)中,UI花费的时间平均在7.2ms/frame。比起16ms的安全标准来讲已经很是好了。
可是咱们来看看构建次数(呼入呼出各一次):
这里仔细看就有点问题,动画执行时咱们只但愿可变的部分刷新(MenuReveal),但实际上连菜单中的按钮也一块儿刷新构建了。
那么优化的方法就是预构建菜单中的按钮,将_buildGoodsMenu(context)
方法放在AnimatedBuilder
以前执行再传入或是放在AnimatedBuilder
的child
中。
AnimatedBuilder( animation: animation, child: _buildGoodsMenuContent(context), // <-----放在这里 builder:(_, child) { return MenuReveal( revealPercent: animation.value, child: child // <----这里使用 ); } )
效果以下:
能够看到UI线程花费的时间在6ms/frame左右。这个提高仍是比较大的(16%左右),虽然对于用户来讲是无感知的。
再次看一下构建次数:
那么提高的缘由也就找到了,由于避免了没必要要的构建。因此针对这类不依赖于动画的子Widget,预构建它能够显著提升性能。
相似这种builder/child的模式还有很多,你能够多多留意一下。
const
来定义一些不变的Widget,这至关于缓存一个Widget并复用它。我以前看到过一篇博客,做者测试一个页面上构建1000个重复图标,结果使用const
构造函数的,FPS大约高8.4%,内存使用量下降约20%。
固然做者也说了,实际一个页面上有1000个Widget也不现实。其实说这个点的缘由也是但愿你们能养成一个好习惯。
GlobalKey
也能复用widget。这个使用场景相对较少,能够了解一下。相关内容连接:说说Flutter中的Key 这个我以前有详细介绍过,能够直接查看:说说Flutter中的RepaintBoundary,这里我就不重复说了。合理的使用RepaintBoundary
能够减小没必要要的刷新提高性能。
推荐使用ListView.builder
来动态实现列表,而不是直接使用ListView
静态建立。注意这里在使用ListView.builder
的itemBuilder
来构建item时,可不要预构建Widget了。相似的Widget还有PageView.builder
和 GridView.builder
。
PS:按需加载是一种策略,并非仅仅依靠这几个类型的Widget。好比以前阿里AliFlutter的分享中,就有提到列表中加载图片的优化。经过判断图片的在屏和离屏,来合理回收图片,这样减少了内存的波动,一样也能够带来性能的提高。
错峰加载的目的是为了不因同一时间的大量构建,而产生卡顿现象。这里我举一个例子:
在使用PageView.builder
这个Widget时,我发如今左右滑动切换页面时会有卡顿的现象。使用timeline
来分析发现两个问题,一是切换的页面比较复杂,比较耗时。二是页面构建的时间点在滑动中。
页面复杂的问题我进行了必定的优化,虽然有效果,但仍是有卡顿发生。那么只能针对第二点再进行优化,咱们先看一下PageView.
相关源码:
return NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) { final PageMetrics metrics = notification.metrics; final int currentPage = metrics.page.round(); if (currentPage != _lastReportedPage) { _lastReportedPage = currentPage; widget.onPageChanged(currentPage); } } return false; }, child: Scrollable(), );
代码很简单,若是咱们设置了onPageChanged
的监听,那么在滑动中(ScrollUpdateNotification
)计算当前页的页码并返回(round方法,四舍五入)。因此在滑动到一半的时候,onPageChanged
就会回调结果,我由于在这里触发了页面的刷新代码,致使了卡顿的发生。
其实在我熟知的安卓中,默认行为都是在滑动结束后才去加载页面数据。因此按照这个思路处理,调整一下加载策略。
修改代码以下:
NotificationListener<ScrollNotification>( onNotification: (ScrollNotification notification) { if (notification.depth == 0 && notification is ScrollEndNotification) { final PageMetrics metrics = notification.metrics; final int currentPage = metrics.page.round(); if (currentPage != _lastReportedPage) { _lastReportedPage = currentPage; _onPageChange(currentPage); } } return false; }, child: PageView.builder(), )
咱们在PageView.builder
上添加一个NotificationListener
,同时修改ScrollUpdateNotification
为ScrollEndNotification
。这样就自定义了咱们的滑动监听事件,经过错峰加载保证了UI的流畅。
PS:在Flutter 1.17的重要改动中就有一条:在高速滚动时推迟图像解码。这也是运用了错峰加载的策略。
避免将一些耗时计算放在UI线程,咱们能够把耗时计算放到Isolate
去执行(多线程)。
举一个Flutter源码中的例子:
Future<String> loadString(String key, { bool cache = true }) async { final ByteData data = await load(key); if (data == null) throw FlutterError('Unable to load asset: $key'); if (data.lengthInBytes < 10 * 1024) { // 10KB takes about 3ms to parse on a Pixel 2 XL. // See: https://github.com/dart-lang/sdk/issues/31954 return utf8.decode(data.buffer.asUint8List()); } return compute(_utf8decode, data, debugLabel: 'UTF8 decode for "$key"'); } static String _utf8decode(ByteData data) { return utf8.decode(data.buffer.asUint8List()); }
由于utf8.decode
方法处理10KB数据大约须要3ms的时间(手机Pixel 2 XL),因此在超过10KB的数据就使用了compute
方法将耗时计算放到Isolate
。这里根据数据大小选择不一样的方式,是由于Isolate
的建立使用也是有空间和时间上的消耗,因此Isolate虽好,可不要滥用哦!
一样的,咱们项目中的json解析操做也能够这样处理,以保证在一些性能较差的机子上能够不形成UI的卡顿。具体实现能够看:在后台处理 JSON 数据解析
这里我简单说明一下缘由:Flutter应用中的Dart
代码执行在UI Runner
中,而Dart
是单线程的,咱们平时使用的异步任务Future
都是在这个单线程的Event Queue
之中,经过Event Loop
来按顺序执行。(这个单线程模型和js是同样的)
也就是说即便咱们是异步执行这段计算代码,但因为这段代码耗时过长,那么这段时间内线程没有空闲(能够理解为任务代码都是插空执行?),也就是线程过载了。致使期间Widget的layout等计算迟迟没法执行,那么时间越长,卡顿的现象就越明显。
所以使用Isolate
来处理耗时计算,利用多线程来作到代码的并行执行。
可能这里你会有疑问,那我网络请求也是Dart代码并且有时也挺耗时的,怎么不见页面卡顿?其实这是由于网络请求在io线程,不会占用ui线程。且实际的网络请求也并非在Dart层作的,Dart代码部分只是一层封装,真正的请求是由底层的操做系统去实现的。
上面的几点大都是关于UI线程的优化。其实在观察Performance Overlay
时,咱们发现有时UI很流畅,可是GPU却会很耗时。这里主要是绘制上的压力比较大(GPU Runner
)致使的,可能包括对Skia
的saveLayer
、clipPath
等耗时函数调用。
saveLayer
会在GPU中分配一块新的绘图缓冲区(离屏渲染),切换绘图目标,这些操做是在GPU中很是的耗时,尤为在比较老的设备上。使用
clipPath
会影响接下来每个绘图指令。尤为这个Path比较复杂的时候都须要和这个复杂的Path作相交操做,并且把Path以外的部分剔除掉。
在Flutter源码中搜索canvas.saveLayer
能够发现一些须要注意的:
Text
的overflow
属性为TextOverflow.fade
,且文字超出范围时,会调用saveLayer
。Clip.antiAliasWithSaveLayer
做为剪切行为时,会调用saveLayer
(听说早期Flutter版本中大都使用这一方式)。建议优先使用Clip.hardEdge
和Clip.antiAlias
。这部分属性通常在ClipRect
、ClipOval
和ClipPath
等裁剪功能Widget中用到。RawChip
的isEnabled
属性,触发enableAnimation
动画时,会调用saveLayer
。而对于clipPath
,相对没有saveLayer
耗时。但须要注意对于裁剪行为。优先考虑使用BoxDecoration
的borderRadius
属性来解决。好比Inkwell
的borderRadius
属性就能够裁剪它的外形,若是borderRadius
实在不能知足,可使用customBorder
属性(使用clipPath
)。
到这里你可能会很庆幸,你说的这些我都没有用到。其实。。。
除过上面所说的显式调用耗时方法,还存在部分隐式调用的(Opacity
、ShaderMask
、ColorFilter
、PhysicalModel
、BackdropFilter
等)。
好比在Opacity
的文档注释中有如下描述:
该类将其子组件绘制到中间缓冲区中,而后将子组件混合回透明的场景中。 对于0和1之外的不透明度值,该类相对昂贵,由于它须要将子组件绘制到中间缓冲区中。对于opacity
为0,根本不绘制子组件。对于opacity
为1.0,将当即绘制子组件,而不使用中间缓冲区。
因此使用Opacity
且opacity
属性不为0和1时,须要注意。若是真的须要使用它,能够先看可否使用替换方案:
AnimatedOpacity
实现。color
属性实现,而不是包裹一层Opacity
。例如:Image.network( 'https://xxxx.jpeg', color: Color.fromRGBO(255, 255, 255, 0.5), colorBlendMode: BlendMode.modulate )
PS:虽然看似许多Widget存在必定性能问题,可是具体场景具体对待。这里只是提醒你们使用前三思,尽可能寻找替代方案,并非彻底不让使用。就好比用BackdropFilter
实现高斯模糊效果,CupertinoAlertDialog
和CupertinoActionSheet
就用到了它,咱们不可能所以就不使用了。
虽然有了上述的经验,可是监测发现问题的手段仍是须要掌握,下面简单说明一下,详细的能够看深刻了解 Flutter 的高性能图形渲染。
在MaterialApp
中添加 checkerboardOffscreenLayers: true
来检查是否使用了 saveLayer
(包含显式或隐式调用),若是使用了会有一个"棋盘网格"覆盖在上方。不过很遗憾,目前我只发现对于BackdropFilter
的使用能够经过这个直接检查到。下图是使用CupertinoActionSheet
的效果:
既然checkerboardOffscreenLayers
受限,那么可使用timeline
查看Flutter
对 Skia
的调用。这里以CupertinoActionSheet
的弹出过程举例。
首先profile
模式运行:
flutter run --profile --trace-skia
安装成功后会有“观测台”的连接:timeline
表现以下:
图中的Sk
开头就是Skia
的函数, 能够看到调用了saveLayer
方法。不过这样看起来并不直观,显得也很复杂。因此能够经过捕捉 SKPicture
来分析每一条绘图指令。
继续运行如下命令:
flutter screenshot --type=skia --observatory-uri=uri
这个uri就是“观测台”的连接。
这里会生成一个skp
格式的文件在你的项目根目录,而后上传文件到 https://debugger.skia.org/
(需fq)进行分析。
这个分析工具包含播放暂停逐条的绘图指令、查看Clip区域、指令调用次数统计等强大的功能。
图中能够看到调用了saveLayer
方法以及调用次数。利用这个分析工具,能够详细了解页面的绘图过程,便于咱们去除没必要要的绘制部分,提高性能。
FlatButton
等复杂Widget的使用。**举例**:deer中的订单列表Item中有三个按钮,因此一开始就用`FlatButton`实现了,结果发现页面滑动时有点卡顿。就用`timeline`检测了一下:
发现最多的时候一个FlatButton
就用了1.5ms,平均一个1ms。可是由于一屏通常显示3个Item,这累积起来不卡顿才怪。缘由呢也是FlatButton
这个Widget功能过多,层级复杂,致使了Widget build耗时。
那么就用GestureDetector
+ Container
+ Text
本身去实现一个这样的按钮去替换。再次看下效果:
修改后,build所用时间大大的减小了(平均0.3ms)。能够看到层级也简单了不少。因此使用`FlatButton`没有问题,可是**要注意它的复杂度,合理使用**。
StatelessWidget
,而不是用StatefulWidget
。Layout
计算。好比ListView
的itemExtent
使用。尽可能避免更改子树的深度或更改子树中Widget的类型。由于这一操做会从新构建、布局和绘制整个子树。
若是须要更改深度,能够考虑给子树的公共部分添加GlobalKey
。
若是须要修改Widget的类型,好比显示隐藏的需求,可使用Visibility
。顺便想一下下面这三种方式的区别:
Column( children: <Widget>[ if (_visible) const Text('1'), _visible ? const Text('2') : const SizedBox.shrink(), Visibility( visible: _visible, child: const Text('3'), ), ], )
Curves
曲线动画(先快后慢)。这样在相同的时间内,视觉上会比线性动画显得快,让人以为流畅。前几天Flutter 1.17.0稳定版也发布了,这其中也看到了大量的性能优化,甚至Container
的一个color
实现都包含在内,相信将来Flutter体验会更上一个台阶。
这篇断断续续写了一周,暂时就整理和想到了这么多,后面有补充也会更新在这里。若是你也有好的优化实践,欢迎讨论!
最后,能够点赞收藏支持一波!同时也多多支持一下个人Flutter开源项目flutter_deer。好了,下个月见~