Bootstrap

Flutter性能监控实践

开源地址

  • 贝壳 aop库开源地址:

前言

移动端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树状态重建频繁导致打点过多,对页面本身性能产生影响。

那么页面首帧与页面绘制完成应该在什么时机获取呢?结合页面加载过程()分析,我们最终通过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上的论文 他们选择了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中。

涉及的内容如下:

/// lib/src/widgets/navigator.dart
Future push(Route route) {}
void pop([ 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...
void install() {//增加 }
/// lib/src/material/page.dart
/// lib/src/cupertino/route.dart
@override
Widget buildPage(BuildContext context,Animation animation,
Animation 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() {
  //...
}
  @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结束时间点');
          // ...
        });
      }
    });
  }
/// @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点:

对于FPS计算,目前腾讯比较高得影响力,其中说到衡量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的性能也是我们的终极目标。就像一样能帮助解决实际卡顿问题。