写点什么

Flutter 卡顿问题的监控与思考

  • 2020-06-30
  • 本文字数:4864 字

    阅读完需:约 16 分钟

Flutter卡顿问题的监控与思考

背景

使用 Flutter 技术构建的应用,一直以高性能高流畅度著称。但是随着应用复杂度越来越高,Flutter 会出现一些页面流畅度明显低于 Native 的情况,甚至可能发生一些卡顿。而很多时候卡顿都发生在线上,即使获得了用户的操作路径,也难以重现。如果我们有一套卡顿监控系统,能够帮助我们捕获到卡顿时的堆栈,那么在发生卡顿的时候,我们就可以定位到具体是哪个函数引起的卡顿,从而解决这些问题。


既然想要设计一个卡顿监控系统,那么我们就需要先解决两个问题:


  • 如何判断当前发生了卡顿。

  • 如何在卡顿时获取堆栈。

如何判断卡顿

既然我们希望能够抓取 Flutter 的卡顿堆栈,那么首先我们得先有办法判断 Flutter App 是否发生卡顿。为此,我们先来简单回顾一下 Flutter 的渲染原理。Flutter 的 UI Task Runner 负责执行 Dart 代码,而 Flutter 的渲染管线也是在 UI Task Runner 中运行的。每次 Flutter App 的界面需要更新时,Framework 会通过 ui.window.scheduleFrame 通知 Engine。然后 Engine 会注册一个 Vsync 信号的回调,在下一个 VSync 信号到来之际,Engine 会通过 ui.window.onBeginFrame 和 ui.window.onDrawFrame 回调给 Framework 来驱动 Flutter 渲染管线,渲染管线中的 Build、Layout、Paint 一一被执行,生成了最新的 Layer Tree。最后 Layer Tree 通过 ui.window.render 发送到了 Engine 端,交给 GPU Task Runner 做光栅化与上屏。



我们可以定义一个卡顿阈值,在 ui.window.onBeginFrame 开始计时,在 ui.window.onDrawFrame 做好卡口,如果渲染管线的执行时间过长,大于卡顿阈值,那么我们就可以判断发生了卡顿。


如果等到我们判断出了当前发生了卡顿,再去采集堆栈,为时已晚。因此,我们需要另外起一个 Isolate,每隔一小段时间就去采集一次 root Isolate 的堆栈,那么当我们判断出现卡顿时,只要将从卡顿开始到卡顿结束的这段时间采集到的堆栈做一次聚合,就能知道是哪个方法引起了卡顿了。


举个例子,如果我们定义的卡顿阈值为 100ms,然后每隔 5ms 采集一次卡顿堆栈,假设 ui.window.onBeginFrame 开始到 ui.window.onDrawFrame 结束总共耗时 200ms,其中 foo 方法耗时 160ms,bar 方法耗时 30ms,其余方法耗时 10ms。那么在这段时间,我们一共能采集到 40 个堆栈,其中有 32 个堆栈的栈顶为 foo 方法,6 个堆栈的栈顶为 bar 方法。在堆栈聚合后,我们就能发现 foo 方法的耗时大约为 160ms,而 bar 方法的耗时大约为 30ms。


这个方案看上去比较简单,整体思路上也是借鉴了 Android 端的卡顿检测框架 BlockCanary,那么这个方案是否可行呢?我们需要解决的第一个问题,就是如何在另一个 Isolate 去采集 root Isolate 的堆栈。

堆栈采集方案一:修改 Dart SDK

在 Dart 中,我们可以通过调用 StackTrace.current 来获取当前 Isolate 的调用栈。那么它能否帮助我们获取卡顿时候的堆栈呢?非常可惜,它做不到这一点。


举个例子:假设我们有一个名叫 foo 的方法,耗时大概 300ms,在 root Isolate 中执行,很明显,这个方法会引起卡顿。而 StackTrace.current 并不能获取帮助我们定位到这个耗时的 foo 方法。我们需要的,是在另一个 Isolate 中采集到 root Isolate 堆栈的方法。

官方的相关 issue

并不是只有我们有这个诉求,google 的同学在 flutter 的 repo 下,提了 Issue:Add API to query main Isolate’s stack trace #37204。这个 Issue 的大致内容是说,希望 Dart 能够提供一个 API,用于在另一个 Isolate 去采集 main Isolate 堆栈,当前这个 Issue 还是 open 的状态。


Issue 提出到现在大概已经过去一年时间了,为什么这个 API 还是没有实现呢?其实,实现这个 API 本身并不困难,只是官方有一些自己的考量,其中之一就是这可能会引入安全性问题:Dart Isolate 之间本应该相互隔离,如果添加了这个 API,那么可能会有黑客通过多次调用该 API 来获取大量的堆栈信息,再通过比对这些堆栈的差异来对加密秘钥发起定时攻击等。看来官方短期之内是不会提供这个 API 了,那么我们是不是可以先试试通过修改 Dart SDK 来实现类似的功能。

通过修改 SDK 获取 API

我们先来看看 StackTrace.current 是如何获取堆栈的吧



我们可以看到,StackTrace.current 方法的修饰符中有一个 external,这代表了这是一个 external 函数,Dart 中的 external 函数意味着这个函数的声明和实现是分开的,这里只是声明,实现在另一个地方,其实现的地方如下:



从 StackTrace.current 的实现中有一个 native 关键字,native 关键字是 Dart 的 Native Extension 的关键字,意味着这个方法是 C/C++实现的。Native Extension 与 Java 中的 JNI 非常的相似。



我们终于找到了实现,CurrentStackTrace,通过观察发现,它的第一个参数是一个 thread。可见 CurrentStackTrace 方法获取的堆栈是基于 thread 的,那么是不是说,如果我们在另一个 Isolate 中,将 root Isolate 对应的 Thread 作为参数,传入到 CurrentStackTrace 方法里,就能获得 root Isolate 对应的堆栈了呢?


为了验证我们这个想法,我们新增了两个方法:StackTrace.prepare 和 StackTrace.root,我们在 root Isolate 中调用 StackTrace.prepare,将 root Isolate 的 thread 对象使用静态变量 rootIsolateThread 保存起来。StackTrace.prepare 对应的 C++实现如下



然后我们新开一个 Isolate,在这个新的 Isolate 中,我们调用 StackTrace.root 来获取 root Isolate 的堆栈,StackTrace.root 对应的 C++实现如下



经过验证发现,通过这个方案,的确能在另一个 Isolate 中获取 root Isolate 的堆栈。当然上面的修改主要还是为了验证可行性,如果真的要采用修改 Dart SDK 的方案,还有非常多的地方需要考虑。


修改 Dart SDK 的这个方案大大增加了后期的维护成本,有没有可能存在一种不修改 Dart SDK,还是能获取到堆栈的方案呢?

堆栈采集方案二:AOT 模式下采集堆栈(暂停线程)

在不修改 Dart SDK 的前提下获取堆栈,听上去感觉是一个不可能完成的任务。但是有时候我们遇到了问题,或许转变一下思路,就能找到答案。

AOT 模式与符号表

让我们一起来梳理一下我们的诉求,首先我们设计的是一个线上卡顿监控方案,这个场景下的 Dart 代码是基于 AOT 编译的,在 iOS 端其产物为 App.framework,在 Android 端则为 libapp.so。基于 AOT,也就意味着 Dart 代码(包括 SDK 和你自己的)会被编译成平台相关的机器码。


那么 Dart 语言 AOT 编译生成的可执行程序与 C 语言编译生成的可执行程序,是否有区别呢?从操作系统的角度来看,它们并没有什么本质区别。操作系统只关心这个可执行程序如何加载,如何执行,至于程序是从 C 语言还是 Dart 语言编译过来的,它并不关心。


我们先来把目光聚焦到 Dart 代码在 iOS 端 profile 模式下的产物 App.framework。从 iOS 的视角触发,这是一个 Embedded Framework。我们可以使用 nm 命令导出其符号表,以下是符号表的一部分:



我们惊喜地发现,这些符号与 Dart 函数几乎是一一对应。比如符号 PrecompiledElementupdate_260,很明显对应的 Dart 函数为 Element.update。


有了这份符号表,也就意味着,如果我们能采集到 root Isolate 对应线程的 native 的堆栈,我们就可以通过符号化来还原出当时 Dart 函数的调用栈。而且我们也不再需要去寻找从另一个 Isolate 获取 root Isolate 的 Dart 堆栈的方法了。与之对应的,我们只需要能够在另一个线程获取 root Isolate 对应的线程的 native 堆栈即可。

堆栈采集的方案

栈帧采集的方案整体思路如下:


  1. 获取当前进程中的所有线程,并找到 Flutter UI TaskRunner 对应的线程

  2. 暂停该线程,并获取该线程当前的寄存器的值,重点为 PC 和 FP

  3. 根据栈帧回溯算法,获取堆栈

  4. 让该线程继续运行

  5. 在当前进程或者远端做符号化

方案实现

接下来我们来看看如何实现这个方案,我们以 iOS 端为例子,来说明如何实现这个方案:


在 iOS 端,我们可以通过 API task_threads来获取所有的线程,代码如下:



我们可以通过比对线程名字来定位到 UI Task Runner 对应的线程,如果是 Flutter 单 Engine 方案,那么 UI Task Runner 对应的 Thread 的名字应为"io.flutter.1.ui"。



在采集堆栈前,我们得先暂停这个线程。



暂停线程后,我们就可以通过 thread_get_state去获取这个线程此时此刻的寄存器的值了,其中能够帮助我们做栈帧回溯的两个寄存器分别是 pc 和 fp,我们这里的代码是以 arm64 为例子的,在实际的产品中,还需要考虑到其他的架构:



获取 pc 和 fp 后,就可以进行栈帧回溯了。至于如何进行栈帧回溯,我们会在下一个小节单独说明。栈帧采集完之后,我们需要让线程继续运行:



以上就是 iOS 端堆栈采集方案的大体实现了。Android 端想实现这个方案,思路上大同小异,无论是找到所有的线程,定位到 UI Task Runner 对应的线程,还是线程的暂停和恢复,都能找到解决方案。唯一比较麻烦的地方在于如何获取另一个线程暂停时的寄存器的值,这部分可以使用 ptrace 来完成,不过这个需要起一个独立的进程。

栈帧回溯原理

上文说到,我们获得了 pc 和 fp 寄存器的值,该如何做栈帧回溯呢?

这里我们以ARM64栈帧布局为例子(也就是上图)。每次函数调用,都会在调用栈上,维护一个独立的栈帧,每个栈帧中都有一个FP(Frame Pointer),指向上一个栈帧的FP,而与FP相邻的LR(Link Register)中保存的是函数的返回地址。也就是我们可以根据FP找到上一个FP,而与FP相邻的LR对应的函数就是该栈帧对应的函数。回溯的算法如下

堆栈采集完毕后,我们只需要将采集到的堆栈进行符号化即可。


堆栈采集方案三:AOT 模式下采集堆栈(通过信号)

性能瓶颈

上面的这个方案可能会对性能造成一些影响,堆栈回溯本身并不耗时,真正的耗时在于线程的暂停和恢复。线程暂停后,线程就会进入阻塞状态,而去恢复线程时,线程并不会立即执行,而是会进入就绪状态,等待内核调度为其分配 CPU 时间片。所以在这个方案,每一次采集线程堆栈,都意味着这个线程的状态可能会从运行态到阻塞态再到就绪态。



那么有没有更为轻量级的采集堆栈的方案?

信号机制原理

信号(Signal)是事件发生时对进程的通知机制,有时候也称之为软件中断。一般发给进程的信号,通常是由内核产生的,比如访问了非法的内存地址,或者被 0 除等等,当然,一个进程可以向另一个进程或者自身发送信号。如果进程注册了信号处理器(Signal Handler),那么当收到信号后,就会中断当前程序,开始调用信号处理器程序,等信号处理器程序执行完成后,当前程序会在被中断的位置继续执行。


新方案的实现

我们先注册一个信号处理器,用于采集堆栈。接着,我们还是启动一个采集线程,每隔一段时间,向 UI Task Runner 发送一个信号。当收到信号后,UI Task Runner 对应的线程就会被中断,执行信号处理器程序来采集堆栈,堆栈采集完后,程序会从中断点回复执行。


我们来看看这个方案具体如何实现,这次我们以 Android 端为例子:


首先我们先注册一些 signal handler,用于在收到信号时采集堆栈



接着我们每隔一段时间,就向 UI Task Runner 对应的线程发送一个信号。



信号到达后,该线程就会中断当前执行的程序,然后调用 signal handler 采集堆栈,其中 signalHandler 的实现如下



实际上,FaceBook 的性能监控方案 profilo,以及 Dart VM 的 CPU Profiler,均使用了这个方案来采集堆栈。

堆栈采集方案对比

我们来对比一下上面提到的 3 个方案,它们的区别如下图所示:



我们可以看到,方案三无需修改 SDK,所以维护成本较低,并且在三个方案中它的性能损耗是最低的。最终我们决定采用方案三来作为我们堆栈采集的方案。

总结

本文主要介绍了我们在设计 Flutter 卡顿监控系统的一些思路,给出了如何判断卡顿跟如何获取堆栈的思考和探索,目前这个方案的产品化正在进行当中。Flutter 作为高性能的跨平台方案,其渲染性能从理论上来说,可以做到不弱于原生。同时 Flutter 在性能体验方向上,和原生相比,还有非常多值得探索的地方,让我们一起不忘初心,继续朝着这个方向前进。


本文转载自公众号闲鱼技术(ID:XYtech_Alibaba)。


原文链接


https://mp.weixin.qq.com/s/-BTEkHYeh_tHqJY2UNI_xw


2020-06-30 10:004087

评论

发布
暂无评论
发现更多内容

LeSS敏捷框架高效生产力实践

俞凡

敏捷开发 大厂实践

将老人拉出无声的世界,AI是怎么做的?

脑极体

AI医疗

国内AGV调度系统到底是什么水平?

申扬科技

调度系统 AGV

统一观测丨使用 Prometheus 监控 E-MapReduce,我们该关注哪些指标?

阿里巴巴中间件

阿里云 云原生

一天吃透Git面试八股文

程序员大彬

git 面试

在前端领域摸爬滚打7年,我终于掌握了这些沉淀技巧

小鑫同学

订单超时怎么处理?我们用这种方案

阿里巴巴中间件

阿里云 云原生

Matlab常用图像处理命令108例(二)

timerring

图像处理

从混乱到完备:我的研发流程之路

SkyFire

研发流程

极氪汽车 APP 系统云原生架构转型实践

阿里巴巴中间件

阿里云 云原生

巧用GenericObjectPool创建自定义对象池

京东科技开发者

京东云 API 编排 对象池 京东物流 企业号 3 月 PK 榜

大型供应链物流企业的数字化转型方法论

明道云

JavaScript异步编程的深入理解,使用回调函数实现异步编程

兴科Sinco

JavaScript 前端 前端开发 异步编程

如何设计一个优秀的 Go Web 项目目录结构

江湖十年

Go 设计 后端 项目 Web Service

前端学习路径

阡陌r

Orika JavaBean映射工具使用

京东科技开发者

JAVA开发 京东云 JavaBean 企业号 3 月 PK 榜

【分布式技术专题】「分布式技术架构」一文带你厘清分布式事务协议及分布式一致性协议的算法原理和核心流程机制(上篇)

码界西柚

分布式 2PC 3PC 原理分析 分布式协议

架构实战 8 - 消息队列MySql表格设计

架构实战营 「架构实战营」

使用OpenAI接口释放ChatGPT API 的力量

devpoint

React nextjs ChatGPT

Kubernetes容器状态探测的艺术

俞凡

Kubernetes 云原生

强强联合:Neovim+ChatGPT | 社区征文

SkyFire

ChatGPT neovim

架构训练营-模块9秒杀系统

张Dave

AutoCompleteTextView的基本使用

芯动大师

android 控件 AutoCompleteTextView

前端学习

阡陌r

工作一年,我重新理解了《重构》

阿里巴巴中间件

阿里云 云原生 重构

28岁小公司程序员,无车无房不敢结婚,要不要转行?

程序员晚枫

程序员 收入

初识大热的ChatGPT的几点思考|社区征文

穿过生命散发芬芳

ChatGPT

经验分享:高德地图如何短时间快速完成春节出行备战工作?

阿里巴巴中间件

阿里云 云原生 函数计算

阿里云消息队列 Kafka 生态集成的实践与探索

阿里巴巴中间件

kafka 阿里云 云原生 消息队列

Flutter卡顿问题的监控与思考_文化 & 方法_皓黯_InfoQ精选文章