写点什么

Flutter 性能监控实践

  • 2021 年 11 月 08 日
  • 本文字数:4985 字

    阅读完需:约 16 分钟

Flutter性能监控实践

前言

移动端 APP 作为与用户交互的工具,用户体验是衡量应用优秀与否的重要指标,其中性能尤为重要。在 Google 推出 Flutter 跨平台方案后,贝壳也将 Flutter 接入到多个 APP 中. 在此之前贝壳已经在原生监控中实现了页面加载、帧率与卡顿等监控。随着 Flutter 在贝壳各种业务场景的使用, 随之而来的问题就是 Flutter 性能怎么样,用户体验如何。

首先可以明确的是在不影响 APP 性能的情况下,更多的性能数据能够辅助我们改进缺陷,优化以提升 APP 的使用体验。因此在基于数据采集与性能损耗的可行性初步调研后,我们将 Flutter 监控功能聚焦在页面加载、帧率、卡顿这三个点上。接下来将从技术调研与论证、架构设计、实现、以及实践效果分别介绍。

技术调研与论证

概念概述

通常我们说 Flutter 中一切皆 Widget,描述的是页面模块都是用 Widget 来表示。Widget 是页面的一个不可变的描述,也是 Flutter 框架的核心要素。其中 Navigator(使用堆栈规则来管理一组‘页面’的 Widget)通过移动小部件从一个 Widget 页面可视化地切换到另一个 Widget 页面。当我们打开一个 Widget 页面,实际是将 Route 页面添加到 Navigator 堆栈中,然后由 Navigator 调度显示栈顶的 Route 页面。Route 页面是 Widget 页面的抽象描述(包含基础数据,透明,动画属性等),起到连接 Widget 与 Navigator 作用,也避免相互的耦合。下图为 Flutter 页面显示的时序图:

页面唯一性

对于页面数据采集,首先要解决的是如何将多类性能数据关联到某一个具体的页面。对于原生来说, 通过页面名称就能做到:

   iOS(ViewController):在一个可执行文件中,ViewController 名称是唯一的;

   Android(Activity、Fragment):同一个包名下 Activity 和 Fragment 类名是唯一的。

但是在 Flutter 中没有直接获取页面唯一标识方式(如 getPackageName),并且 Widget 类名是可以重复的,也就无法精确定位页面唯一类。那么我们该如何给一个页面定义唯一标识?

经过调研我们找到两种方式如下表:

结合 iOS 瘦身的效果与编译时插入包体对比,以及未来对 Dart 代码瘦身混淆的考量,我们选择了后者。

页面定义:与 Route 关联的页面顶级 Widget 作为我们的统计单元。

页面唯一标识:使用页面所在的文件 importUri(Dart 文件的唯一标识) + ClassName

页面生命周期

对 Flutter Widget 来说, 本质上没有生命周期这一概念,因为 Widget 树只是不断重建的过程。Flutter Framework 提供两个大类 Widget:stateful 和 stateless widgets,其创建及调用时机如下图:


我们发现,对比 StatelessWidget 和 StatefulWidget 可以看出两者存在的差异。StatelessWidget 没有可变状态,没有合适的监控点。而如果在 StatefulWidget 的 build、createState、didUpdateWidget 或 didChangeDependencies 等中打点, 会因 widget 树状态重建频繁导致打点过多,对页面本身性能产生影响。

那么页面首帧与页面绘制完成应该在什么时机获取呢?结合页面加载过程(architectural-overview - building-widgets)分析,我们最终通过 Navigator 对 Route 的管理作为页面生命周期监控 hook 点。对于页面首帧我们采用在 Route buildPage 后增加一个 PostFrameCallback。而对于页面加载完成,我们给 TransitionRoute 的 AnimationController 设置 statusListener, 并监听 AnimationStatus.completed 状态来确定动画结束时机,以此确定页面加载完成的时间点。

帧率与卡顿

帧率:通常指每秒绘制的帧数(frames per second)

丢帧:因系统负载导致帧率过低所造成的画面出现停滞现象,也叫跳帧或者掉帧。

卡顿:一般来说指丢帧的另一个概念,指画面出现停滞现象比丢帧更明显。

Flutter 官方有提供一套基于 SchedulerBinding.addTimingsCallback 回调实现的帧率方案。从源码中可以看出,当 flutter 页面有视图绘制刷新时, 系统吐出一串 FrameTiming 数据 (与 Android dumpsys gfxinfo 中的 frameStats 类似)。并且其对性能的影响也可以忽略不计(官方数据 iPhone6s:对 60fps 的设备每帧增加 0.1ms 的负载,每秒 CPU 占用 0.01%)

Flutter spends less than 0.1ms every 1 second to report the timings (measured on iPhone6S). The 0.1ms is about 0.6% of 16ms (frame budget for 60fps), or 0.01% CPU usage per second.

我们可以直接使用这种方式获取监控所要采集的 FPS 数据源。

既然有了帧率数据源,我们如何用数据衡量页面性能?如何为业务开发同学提供一个客观的指标来评判性能,以及如何验证优化效果?

通常来说 FPS 是衡量页面流畅度的指标,如何计算 FPS 得出大家都认可的参考标准呢?

在经过调研与实践后,我们为卡顿阈值找到一个具有说服力的衡量指标。列举如下:

卡顿阈值的选取

首先我们认为卡顿本身是一个很主观的东西,就好像有人觉得打王者荣耀玩流畅模式(30FPS)好像也还算流畅,有人会觉得不开高帧率(60FPS)就没法玩。那有没有什么较为客观一点的标准?

在网上查阅了查阅了大量研究资料后,我们找到了一篇发表在 ICIP 上的论文 Modeling the impact of frame rate on perceptual quality of video他们选择了 6 类视频,在不同帧率下进行了测试,实验结果如下图所示:


该图反映了帧率和人眼主观感受之间的关系。6 个测试序列分别使用 6 张图表示。每张图 x 坐标代表帧率,y 坐标代表人眼主观感受(MOS),红色虚线代表 CIF(352×288)分辨率序列的拟合曲线,蓝色虚线代表 QCIF(176×144)分辨率序列的拟合曲线。主观感受取值范围 0-100,数值越大代表主观感受越好。

据此结果,实验设计模型通过标准因子将六种场景的得分标准化,并绘制到一个坐标下

从该图可以看出,当帧率大于 15 帧的时候,人眼的主观感受差别不大,基本上都处于较高的水平。而帧率小于 15 帧以后,人眼的主观感受会急剧下降,人眼会立刻感受到画面的不连贯性。

要达到 15FPS,单帧耗时不能超过 16.7ms * 4。最终,我们选择了 16.7ms * 4 (60Hz 设备)作为 Flutter 页面卡顿的阈值。

架构设计

下图列出前端、后端、原生、Flutter 的侧重点:

后端的能力在数据处理这块,原生 APM 监控体系中也比较成熟。

前端页面展示主要包含如下三个方面:

l 页面通用功能:版本的数据, 版本维度数据对比

l 页面加载功能包括:访问数,首次渲染时间,二次渲染时间,页面生命周期

l 页面帧率功能包括:FPS 平均值,平均卡顿次数。(FPS 最差值,丢帧平均值,丢帧峰值,这三个值作为参考数据来统计)

FlutterPlugin 在客户端主要聚焦在如下方面:

l 页面唯一性标识获取

l 页面生命周期监控点

l 生命周期与 Platform 映射规则

l 页面加载采集方案实现

l 页面加载本地计算与统计

l 帧率数据源如何采集

l 帧率如何计算

l 卡顿标准如何确定

l 上传模块的实现

监控 SDK 实现

概览图

Hook 时机点如上图(后面详细介绍),由 MonitorService 分发事件:

l pageChange: 页面的起始点可以考虑在 Navigator.push 调用,但在 1.22 容器打开的首个页面并不会触发 push, 我们在新版本将 hook 点放到 Route install 函数中。

l buildPage: 页面构建时间耗时= buildPageEnd - buildPageStart

l firstFrame: 页面第一帧绘制完成时机 (Route buildPageEnd + postFrameCallback)

l animatorEnd: 页面绘制并且动画完成时间点 (Animation Completed + postFrameCallback)

编译时 Hook 能力(AOP)

借助 beike_aspectd (文章链接编译时代码注入能力,将监控库的函数编译时注入到 app.dill 中。

涉及的内容如下:

  1. navigator hook 点: push 和 pop 的时机

/// lib/src/widgets/navigator.dartFuture<T?> push<T extends Object?>(Route<T> route) {}void pop<T extends Object?>([ T? result ]) {}
复制代码

在 Navigator 2.0 后命令式 API 变更为声明式 API,Navigator initState 中 push 逻辑被移除掉了,转由 initialRoute 的创建 route add 触发,因此我们将 Page Change 的时机调整到 Route install()方法中。即 TransitionRoute:

/// lib/src/widgets/routes.dart/// abstract class TransitionRoute<T>...void install() {//增加 }
复制代码
  1. buildPage 点: 获取 Widget 的类名,页面唯一性的一部分

/// lib/src/material/page.dart/// lib/src/cupertino/route.dart@overrideWidget buildPage(BuildContext context,Animation<double> animation,Animation<double> secondaryAnimation,) {}
@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute", "-buildPage", lineNum: 87)@pragma("vm:entry-point")void routeBeforePage() { //...}
@Inject("package:flutter/src/material/page.dart", "MaterialPageRoute", "-buildPage", lineNum: 97)@pragma("vm:entry-point")void routeAfterPage() { //...}
复制代码
  1. 动画结束时机

  @Inject("package:flutter/src/widgets/pages.dart", "PageRoute",      "-createAnimationController",      lineNum: 41)  @pragma("vm:entry-point")  void createAnimationController() {    Object controller; //Aspectd Ignore    AnimationController animationController = controller;    // 这里要注意1.12.13和2.x版本差异    animationController.addStatusListener((state) {      if (state == AnimationStatus.completed) {        WidgetsBinding.instance.addPostFrameCallback((duration) {          Logger.devLog('AnimatorEnd结束时间点');          // ...        });      }    });  }
复制代码
  1. Widget 注入方法获取 importUri

/// @Add是beike_aspectd提供的编译时注解@Add("package:.+\\.dart", ".*", isRegex: true, superCls: 'Widget')@pragma("vm:entry-point")dynamic importUri(PointCut pointCut) {// 获取 importUri 	return pointCut.sourceInfos["importUri"];}
复制代码

页面加载

页面加载主要是在对 Route 加载显示页面的流程。 主要采集内容如下:

帧率与卡顿

因为帧率的数据源是动态数据,所以用单帧时间换算 FPS 的计算原则来统计。以单帧的绘制效率(结合 vsync 信号时间)评估 1 秒能够绘制的帧数。我们称之为: 单帧 FPS 。以下以 60Hz 设备举例说明其计算:

[单帧 FPS] = 1000 / Math.max(单帧时间, 16.7 * Math.ceil(单帧时间 / 16.7))

主要采集内容如下:

综上所诉, 我们对一个页面从打开到退出的关键生命周期进行 hook,计算对应的首帧耗时、平均 Fps、卡顿次数等数据。在页面退出后,获取到页面的唯一标识(包名+类名),以及对应的性能数据,并将其上传到远端.

实践效果

线下实时 FPS 展示面板

在 profile/debug 模式下,FPS 展示面板可以直观的评估页面流畅度。可以查看当前设备最近 100(可配置)帧的表现情况:(如下图)

除了实时的 FPS 查看,我们还将性能监控库中的数据进行了本地展示(下图)。以此掌握当前页面在不同设备上的性能表现,进行更精确的优化。

线上数据采集

下图为线上采集的数据,结合线下 FPS 工具里采集的数据可以帮助业务方更快看到优化效果。


实际端上还采集了页面丢帧数据,但没有显示在网页上,因为我们认为这并不能很好的衡量实际使用过程中渲染性能。从目前资料来看影响的因素有 2 点:

  1. 帧率不能过低, 并且保持稳定:如持续低于 30fps 时,动画连贯性受到影响.

  2. 帧率稳定: 如 fps 是 60,20,60,30,... 等不均匀速率, 容易产生的视觉上的卡顿.

对于 FPS 计算,目前腾讯PrefDog比较高得影响力,其中说到衡量 FPS 的要点:

1) Avg(FPS):平均帧率(一段时间内平均 FPS)   

2) Var(FPS):帧率方差(一段时间内 FPS 方差)  

3) Drop(FPS):降帧次数(平均每小时相邻两个 FPS 点下降大于 8 帧的次数)

所以单从 FPS 平均值数据的说服力不足以佐证上述第一点内容,因此我们将 FPS 平均值、FPS 最差值作为参考值呈现在网页上,待后续完善。目前我们在 Flutter 帧率上主要采用卡顿指标(即上面的第二个因素帧率是否稳定)来评估页面渲染性能。

总结

本文主要介绍贝壳早期在 Flutter 1.12.13 (1.22、2.0 已适配)性能监控实践过程中的一些思路和实现的方式。APP 监控需要深入挖掘运行机制,更深层次的机制原理之后的文章会详细描述(如详细的流程,渲染原理等)。此外 Flutter 版本迭代很频繁,一些监控时机很可能在下一个稳定版就不适用,这就需要开发者去找到更合理的点。

最后,页面加载、页面帧率、页面卡顿等性能数据帮助了我们优化提升了 APP 的使用体验。如何更精准获取数据、更合理的处理数据, 并驱动改进提升 APP 的性能也是我们的终极目标。就像KeFrame流畅度优化组件一样能帮助解决实际卡顿问题。


发布于: 2021 年 11 月 08 日阅读数: 158
用户头像

还未添加个人签名 2019.03.15 加入

还未添加个人简介

评论

发布
暂无评论
Flutter性能监控实践