成文较早,仅供参考

公司业务线在 Flutter 的道路上越走越远,随着主流程逐渐替换成 Flutter,整个业务线的体验也极速下滑...性能问题终于到了不得不重视的地步,Flutter 本身是自建的绘制引擎,系统的 FPS 并不能实际体现 Flutter 的卡顿程度,需要针对 Flutter 的特点单独去监控页面的流畅率。
本文为曾经开发工具的介绍...仅作为自己的总结
- TTI 页面可交互时间监控
- (本文)Flutter FPS 监控
- 颜色标尺
- 组件抓手(待填坑)
- 录屏分析
- 调试工具重构
卡顿是什么
在聊卡顿之前。先谈谈 FPS(Frame Per second),即“每秒帧率”。日常的电影、游戏都是一张张图片,大脑借助其强大补帧功能使其理解为连续状态,帧率就可以理解为每秒刷新了多少张图片、也可唤做“刷新率”。
电影:24 fps
游戏:30 fps
当连续的图像媒介在运行中卡在了某帧上超过一定时间,或帧率突然降低,尤其在交互期间,会感觉到明显的卡顿。卡顿也是APP应用最重要的用户体验指标,能极大的提升用户使用APP的舒适度,提升留存率。
核心指标
在开工之前,需分析下卡顿的场景,确立监控的核心指标,这里以与 APP 可交互特点类似的游戏的30fps为标准,*30fps即每秒30帧,平均到每帧是33毫秒。当一秒低于30fps即可标记为“卡顿”。那么主要的指标还有哪些?
冻结帧
表示某一帧大幅度超时,这类帧这种情况可直接判定为出现了卡顿,阈值根据策略而定,谷歌对这种帧的标准是超过700ms,苹果是100ms。
连续低速帧
当一帧超过33ms大脑可能并不会察觉, 可当连续多帧都超过33ms,大脑就会有明显感知(可简化为低于30FPS,即1s内超过半数的帧耗时大于理论耗时)
FPS
FPS 本身也可表示卡顿、表示一个时间段(1s)内的综合指标,会弱化某一帧的卡顿问题,是非常重要的参考指标。
*流畅率
在实际的监控中,可能需要1秒、一段时间、甚至页面整体的指标,用来分析不同时间,不同版本之间的差异... FPS 指标会受到各方面的影响、如高刷、动态刷新率等等的影响,而 Flutter 在这方面会受到平台或APP的限制。可以参考 FPS 的机制来计算出当前设备的流畅率:流畅率 = 实际帧数 / 理论帧数。
Flutter 中如何获取刷新信息
Flutter 提供了一个获取最近一段时间内所有帧耗时信息的方法:
WidgetsBinding.instance.addTimingsCallback((timings) { });
这里 timings 参数的类型为 FrameTiming, 其结构下包含了所需的所有帧耗时信息:
factory FrameTiming({
required int vsyncStart, // 同步开始
required int buildStart, // 构建开始(CPU)
required int buildFinish, // 构建结束(CPu)
required int rasterStart, // 光栅化开始(GPU)
required int rasterFinish, // 光栅化结束(GPU)
required int rasterFinishWallTime,
int frameNumber = -1,
})
通过上述属性来计算单帧总耗时:totalSpan = rasterFinish - vsyncStart
需要注意的是 TimingsCallback 方法回调的是“一个时间段内”的帧集合,每个回调的时间段之间并非连续。
测试小工具
有了帧耗时信息就可以做个小工具了

图中的帧耗时柱状图上还可继续将CPU与GPU按照不同颜色区分出来,指标也可进行细化,比官方自带的那个devtools帧率面板更好用 :)
扩展:FPS 的监控
FPS 数值的获取方案
根据刷新回调来计算也很容易。根据TimingsCallback 所获取到的帧信息来计算 FPS,严格来说应该叫做“理论FPS”。
上一节提到过“ 每个回调的时间段之间并非连续 ”,在计算前必须处理好这个间隔所带来的影响:
- 间隔时间是多久,是否规律?
- 空白区域如何处理?
经过测试可以发现:
- 每次回调为一段时间内的帧集合,每次回调相隔时间不固定。
- 当页面处于静止状态时,Flutter会变更为类似
低功耗模式,较长时间才会回调1帧(测试约2.5s),且帧耗时的流畅率为50%。
每次回调的间隔时间并不固定,最长的间隔达到了2.5s。这排除掉了直接记录“当前秒”帧数的方式。只能去计算“最近一秒”的“理论FPS”。
“最近一秒”与“当前秒”的区别在于最近一秒指最近帧耗时总共一秒的区间。基于此,要获取最近一秒的FPS,不仅要保留一定时间区间的帧数据,还需要在计算 FPS 时去截取对应长度的帧数据,也需要排除掉空白区间,这并不简洁,会带入误差,在某些场景下(如较长时间的帧饱和度),这样的误差随着时间逐渐积累起来后会明显的影响最终的整体统计数据,FPS 这样秒级时间跨度的统计可以忽略不计。
采用一个定长的队列(FIFO)来保留最近 N 帧数据,在需要获取 FPS 时,通过遍历队列来获取实际绘制所消耗的时间,并以此来计算“理论帧数”,最终的FPS计算通过:
理论FPS = (当前秒实际帧数 / 理论帧数)* 设备刷新率
在实际开发中需要兼容的问题不止一个“理论FPS”,仅设备最大刷新率就需要考虑:
- 设备是否支持高刷
- 当前获取的是否是设备实际最大刷新率
- 平台或APP配置的影响
- 动态刷新率的存在也让这个计算过程时时刻刻都需要获取设备实时最大刷新率
像平台特性,Flutter 官方也不能完全保证。官方调试模式自带的渲染监控面板也仅显示了所有的帧耗时信息。网络上大多数方案也都固定采用 Flutter 自身默认的 60fps,通过计算出来的实际帧数比率来计算出“理论FPS”。
回调间隔优化
回调间隔可以通过帧耗时总数来规避。利用“总耗时 / 理论单帧耗时”计算出理论帧数,可以是每次回调的计算、也可以细化到每帧。根据实际的计算策略参考“补帧”机制。
静止状态
静止状态存在回调为0帧或1帧的情况,一些方案中使用缓存队列来缓存最近60帧(设备实际刷新率),通过计算流畅率*设备刷新率来得出FPS。当处于静止状态时,随着帧缓存队列压入的低功耗帧越来越多,流畅率逐渐趋近于50%,最终的FPS也无限趋近于30。技术统计上没有问题,其本身就处在一个低功耗模式,符合预期。但在全时段统计上这样的FPS数值无法真实反应出页面刷新,同时对长时间静止后再次交互的首次计算也可能存在影响。
因要求必须用FPS来统计,笔者尝试过屏蔽静止状态(回调帧数 == 1 && 帧耗时 < 理论耗时 * 2),当静止状态时,显示为满帧,以此来保证整体FPS与实际流畅率的关联性。
扩展:流畅率
页面是否流畅是一个非常宽泛的概念,包含但不限于:
- 刷新卡顿
- 页面打开速度
- 白屏
- 接口超时
本节的流畅率监控仅针对前端渲染是否流畅,综合性的卡顿需要整合所有卡顿场景并赋予不同权重来计算出一个页面展示过程中的综合性指标,不仅受指标主观权重影响,也受各个参数所制定的标准影响。这里就不展开了。
页面流畅率指标
页面流畅率和FPS本质一样,区别在于页面流畅率指标的范围是整个页面生命周期内。页面的整个生命周期不可控因素太多,卡顿原因也各不相同。借鉴 FPS 的每秒刷新帧率来实现一个页面级的 FPS 是可行的:
页面流畅率 = (实际渲染帧数 / 理论渲染帧数)
以60帧/秒举例,即每帧最大 16.66ms。“卡顿”可简单的总结为监控低于30fps的场景,高刷屏本身就是用配置和能耗去换取更高的刷新率,在可稳定获取实时且准确的APP最大刷新率的前提下,60帧与120帧都属于流畅的范围,用户的感觉仅仅是流畅的程度不同,只有卡顿(FPS < 30)时,用户才会明显的感觉到卡顿,也可以作为阶段性的阈值,等性能优化的差不多了再兼容高刷。理论很简单,实际开发中发现需要注意的场景并不少。
补帧
理论渲染帧数根据 “实际渲染时间 / 理论满帧的单帧耗时” 来进行计算。Flutter的渲染回调是一个时间段内的帧集合。利用最后一帧的结束时间去减去第一帧的开始时间来获取到最接近当前渲染回调区间的总耗时。
void onlineFPSWatch(List<FrameTiming> timings) {
final startTime =
timings.first.timestampInMicroseconds(FramePhase.vsyncStart);
final finishTime =
timings.last.timestampInMicroseconds(FramePhase.rasterFinish);
// 渲染的总耗时
final totalTime = (finishTime - startTime) * 1.0 / 1000.0;
//........
}
这里需要注意,finishTime 是用的最后一帧的 rasterFinish,这就意味着我们计算出来的总耗时会比实际渲染时间要少, 少的部分就是最后一帧的 rasterFinish 到 rasterFinishWallTime 的时间。
那么这时候就会出现一个问题:
以仅1帧的回调场景举例,当一帧的耗时非常少时, 计算出来的总耗时甚至少于满刷新率1帧的耗时,这会导致计算出来的totalTime 是 0,或者少1帧。
个别文章计算 FPS 时直接进行暴力“ +1 ”操作,该操作本质是为了“补帧”,这并不会对计算结果造成严重影响,因为FPS本身就限定了影响范围在1s以内,暴力“+1”的操作仅在部分场景对 60fps 造成1帧的误差,对 FPS 本身的影响可以忽略不计。(注意有些计算方式是按照每帧超过理论耗时的+1帧,与此处的暴力+1不同,相比上面的补帧更直观,在较短时间段内没问题)
可到了页面级的流畅率指标上,这样的误差会随着时间逐渐放大,每一次回调都暴力+1的话, 数分钟后累积起来的就是数千帧了,并会随着时间的推进,理论帧数的偏差会越来越大,分母大了,最终的数据就会偏小。
规避这样的场景只需要将“+1”这样的补帧操作从秒级提升到页面级,即:整个页面的误差不超过1帧的耗时。要做到这样就需要稍微改一下补帧的逻辑
double _offset = 0.0;
void onlineFPSWatch(List<FrameTiming> timings) {
final startTime =
timings.first.timestampInMicroseconds(FramePhase.vsyncStart);
final finishTime =
timings.last.timestampInMicroseconds(FramePhase.rasterFinish);
final totalTime = (finishTime - startTime) * 1.0 / 1000.0;
// 补帧原则:前补后借
_offset = (totalTime + _offset) % perFrameTime;
var needCount = ((totalTime - _offset) / perFrameTime).ceil();
if (needCount < timings.length) {
_offset = totalTime - timings.length * perFrameTime;
needCount = timings.length;
}
//........
}
从上面可以看出,利用缓存变量来记录每一次回调的的时间偏移值,并带入到下一次回调, 秉持着前补后借的原则将整个页面的渲染时间误差控制在1帧,即16.66ms以内。
扩展:线上监控-滚动流畅率
在实际场景中,全时段监控并不合适,当渲染频繁时,时时刻刻都在进行流畅率统计的相关计算,长时间持续下对性能本身存在影响(发热),低功耗时也无需监控统计。
页面频繁渲染只会出现在“页面刷新”、“动画”、“列表滚动”。
页面整体的刷新表示数据发生重大变化页面需要整体的刷新,这种场景下会存在loading、骨架等告知用户正在刷新的阻塞性提示,就算刷新时fps低于30,感知程度也很低。
flutter动画性能并不高,动画的存在本身是页面内局部变化/转场的刷新,通常,可感知的卡顿出现在“动画+交互”时,例如动画时,正在滑动/拖动页面。flutter动画本身的性能主要集中在编码问题(例如阴影),这类问题在开发阶段极易发现,不可能带上线,那么重点也在交互上。
列表滚动时,用户对卡顿的感知最为明显,也规避了全时段监控的弊端,是线上监控方案较好的选择。
总结
在做线上监控时,监控本身没有什么难度,根据FrameTiming的属性、flutter的刷新回调机制设计符合业务需求的监控策略。
实际执行中最难的是如何将数据“好看”的呈现出来,又不失实际卡顿的体现,说多了都是泪 :(