Flutter 如何评价卡顿变化 Dart SDK
使用 Flutter 技术构建的应用程序一直以其高性能和流畅性而闻名。但随着应用复杂度的增加,Flutter 会遇到某些页面流畅度明显低于 Native 的情况,甚至某些情况下会出现这种情况。很多情况下,卡顿发生在互联网上,即使获取了用户的行为路径,也很难重现。如果我们有一个卡顿监控系统,帮助我们捕获卡顿表现的堆栈,我们就可以找到卡顿表现中的某个特定特征,导致卡顿解决这些问题。
既然要设计一个卡顿的追踪系统,首先要解决两个问题:
如何评估卡顿
既然要抓Flutter的卡顿堆栈,首先要找到一种判断的方法Flutter App 是否发生在卡顿身上?为此,我们首先简单回顾一下 Flutter 的渲染原理。 Flutter的UI Task Runner负责执行Dart代码,Flutter的渲染管道也运行在UI Task Runner中。每次Flutter应用程序界面需要更新时,框架都会通过应用程序通知引擎。然后引擎注册一个 Vsync 信号回调。当下一个 VSync 信号到达时,引擎运行 Flutter 的渲染管道并回调框架。渲染管道中的构建、布局和绘制一次执行一个,生成最新的图层树。最后,图层树被发送到引擎端,并传递给GPU Task Runner进行光栅化和显示。
我们可以定义卡顿的阈值,开始计时并在 处设置检查点。如果渲染管线执行时间过长,超过了卡顿阈值,那么我们就可以判定卡顿发生了。
如果我们等到确定卡顿现在正在发生,再堆叠就太晚了。因此,我们需要每隔很短的时间创建一个新的isolate并收集根Isolate堆栈。然后,当我们决定卡顿出现时,我们所要做的就是把从卡顿开始到卡顿结束所收集的堆栈加起来。 ,你就会发现卡顿是用什么方法造成的。
例如,如果我们将卡顿阈值设置为100ms,然后每5ms收集一次卡顿堆栈,假设从开始到结束需要200ms,其中foo方法需要160ms,strip方法需要30ms,其他方法需要160ms需要10毫秒。所以这段时间我们一共可以收集到40个栈,其中32个栈的栈顶有foo方法,6个栈的栈顶有strip方法。折叠堆栈后,我们看到 foo 方法大约需要 160ms,而 strip 方法大约需要 30ms。
这个方案看起来比较简单,总体思路是基于Android 卡顿检测框架BlockCanary。那么这个解决方案是否可行呢?我们需要解决的第一个问题是如何将一个根隔离区堆叠到另一个隔离区中。
第一个堆栈收集解决方案:修改Dart SDK
在Dart中,我们可以通过调用来获取当前的Isolate调用堆栈这能帮助我们从卡顿时代恢复过来吗?可惜他不能这么做。
例如:假设我们有一个名为 foo 的方法,该方法大约需要 300 毫秒,并且在 root 隔离中执行。显然,这种做法引起了卡顿的注意。并且不能帮助我们找到那个耗时的 foo 方法。我们需要一种方法将根隔离堆栈收集到另一个隔离中。
官方相关问题
我们并不是唯一面临这一挑战的人。 Google 学生提出了一个问题:添加一个 API 来查询 flutter 存储库下的主 Isolate stacktrace #37204。此版本的总体要点是我们希望 Dart 能够提供一个 API 来收集另一个隔离中的主隔离堆栈。当前问题仍然悬而未决。
主题 这个提案提出已经有一年了。为什么这个API还没有实现?其实这个API的实现本身并不困难,只是官方有一些考虑。其中之一是它可能会产生安全问题:Dart Isolate 应该彼此分离。如果添加了这个API,可能就会有黑客的攻击。多次调用API获取大量堆栈信息,然后比较这些堆栈之间的差异,从而发起加密密钥定时攻击。看来短期内官方不会提供这个API,所以我们可以先尝试通过修改Dart SDK来实现类似的功能。
通过修改SDK获取API
我们先看看如何获取堆栈
我们可以看到方法中修饰符是external,也就是说是外部函数,在Dart中External function的意思是该函数的声明和实现是隔离的。这里只是声明,实现在另外一个地方。实现如下:
该实现有一个native关键字,native关键字是Dart Native Extension关键字,意思是这个方法是用C/C++实现的。 Native Extension 与 Java 中的 JNI 非常相似。
我们终于找到了CurrentStackTrace的实现,通过观察发现它的第一个参数是一个线程。可以看到,CurrentStackTrace方法获取到的栈是基于线程的,那么这是否意味着,如果我们将根Isolate对应的线程作为参数传递给另一个isolate中的CurrentStackTrace方法,就会得到对应的栈作为结果。隔离根? ?
为了验证我们的想法,我们添加了两个新方法: 和 ,我们调用 root Isolate 并使用静态变量 rootIsolateThread 保存 root Isolate 线程对象。对应的C++实现如下
然后我们新建一个Isolate。在这个新的 Isolate 中,我们调用 root 来获取 Isolate 堆栈。对应的C++实现是:当然,上述改动主要是为了可行性检验。如果你真的想采用 Dart SDK 修改方案,还有很多其他事情需要考虑。
这种更改 Dart SDK 的方案会显着增加后期的维护成本。是否有一个可能的解决方案,不改变 Dart SDK 并且仍然获取堆栈?
堆栈获取方案二:AOT 模式(暂停线程)收集堆栈
在不修改 Dart SDK 的情况下获取堆栈听起来是一个不可能完成的任务。但有时候,当我们遇到问题时,我们会通过改变思维来找到答案。
AOT模式和符号表
一起看看我们的要求吧。首先,我们为卡顿设计了一个在线监控解决方案。此场景的 Dart 代码是基于 AOT 构建的,其产品在 iOS 端是,在 Android 端也是。基于AOT,这意味着Dart代码(包括SDK和您自己的)被编译为特定于平台的机器代码。
那么,Dart的AOT编译创建的可执行程序和C语言编译创建的可执行程序有什么区别吗?从操作系统的角度来看,没有显着差异。操作系统只关心可执行程序如何加载和执行。程序是用 C 还是 Dart 编译的并不重要。
我们首先关注iOS Profile模式下的Dart代码产品。从iOS的角度运行,是一个内置的框架。我们可以使用 nm 命令导出这个符号表。以下是部分符号表:
我们惊喜地发现这些符号与箭头功能几乎一一对应。例如符号PrecompiledElementupdate_260,可能就是对应的Dart函数。
对于这个符号表来说,这意味着如果我们能够收集到对应线程的隔离根的native stack,我们就可以通过符号化来还原当时Dart函数的调用堆栈。而且我们不再需要找到一种方法从另一个 Isolate 获取 Isolate 的 Dart 堆栈的根。因此,我们需要能够仅检索与另一个线程中的根隔离相对应的线程的原始堆栈。
堆栈收集计划
堆栈帧收集计划的总体思路如下:
- 获取当前进程的所有线程,找到Flutter UI TaskRunner对应的线程。 使用线。并获取线程当前线程寄存器值,重点关注PC和FP
- 根据栈帧回溯算法,获取栈
- 让线程继续运行
- 象征当前进程或远程端
实现解决方案
连接 让我们看看如何应用此解决方案。为了说明该方案的实现,我们以 iOS 端为例:
在 iOS 端,我们可以通过 API task_threads
获取所有线程。代码如下:
我们可以通过比较线程名称找到UI Task Runner对应的线程。如果是Flutter单引擎方案,UI任务启动器对应的线程名称应该是“”。
我们需要在收集堆栈之前停止这个线程。
停止线程后,我们可以使用命令thread_get_state
来获取该线程寄存器的当前值。帮助我们回溯栈帧的两个寄存器是pc和fp。我们这里的代码以arm64为例。对于实际产品,还有其他架构需要考虑:
拿到pc和fp后,就可以做栈帧跟踪了。至于栈帧回溯,我们将在下一节中单独解释。收集完栈帧后,我们需要让线程继续运行:
以上是iOS端栈收集方案的一般实现。 Android端想要实现这个方案,思路也类似。无论是查找所有线程、查找UI任务启动器对应的线程,还是停止和恢复线程,都可以找到解决方案。唯一棘手的部分是当另一个线程挂起时获取注册表值。这部分可以使用 ptrace 来完成,但它需要一个单独的进程来运行。
堆栈帧跟踪原理
如上所述,我们已经获取了pc和fp寄存器的值。如何进行堆栈帧跟踪?
这里我们以ARM64堆栈帧布局为例(即上图)。每个函数调用在调用栈上维护一个独立的栈。每个堆栈都有一个 FP(帧指针)指向前一个堆栈帧的 FP,FP 旁边的一个 LR(链接寄存器)保存函数的返回地址。即我们根据FP找到前一个FP,与FP相邻的LR对应的函数就是栈帧对应的函数。回溯算法如下
完成堆栈收集后,我们只需要将收集到的堆栈进行符号化即可。
第三种堆栈获取方案:AOT模式下堆栈获取(通过信号)
执行瓶颈
上述方案可能会对性能有所影响。回溯堆栈本身并不耗时。真正耗时的部分是停止和恢复线程。线程挂起后,线程进入阻塞状态。当线程恢复时,该线程不会立即启动,而是进入待机模式,等待内核调度程序为其分配CPU时隙。所以在这个解决方案中,意味着每次线程的堆栈被收集时,线程的状态都可以从运行变为阻塞再变为就绪。
有没有更轻量级的收集栈解决方案?
信号机制原理
信号是事件发生过程的通知机制。这有时称为软件中断。一般来说,发送给进程的信号通常是由内核产生的,例如访问非法内存地址、除以0等。当然,一个进程可以向另一个进程或自身发送信号。如果进程注册了一个信号处理程序(Signal Handler),当接收到信号时,正在运行的程序被中断,调用信号处理程序。信号处理程序结束后,当前程序从中断处继续。申请。
实施新的解决方案
首先我们为采集堆栈注册一个信号处理器。接下来,我们仍然启动收集线程,偶尔向 UI 任务处理程序发送信号。当接收到信号时,UI Task Runner对应的线程被中断,并执行信号处理器程序来收集堆栈。入栈后,程序从断点处继续执行。
让我们看看如何实施这个解决方案。这次我们以Android端为例:
首先,我们注册一些信号处理程序,用于在接收到信号时收集堆栈
然后,每隔一段时间,我们将信号发送到对应的线程UI 任务运行器。 。
信号到达后,线程会中断当前正在运行的程序,然后调用信号处理程序来收集堆栈。 SignHandler实现如下
其实FaceBook性能监控方案的Profiler和Dart VM CPU Profiler都是用这个方案来收集堆栈的。
堆栈采集方案对比
我们来对比一下上面提到的三种方案。它们的区别如下图所示:
可以看到,方案三不需要修改SDK,维护成本更低。而且其性能损失是三种方案中最低的。最后,我们决定采用第三种方案作为我们的堆叠解决方案。摘要 该解决方案目前正在生产中。作为一个高性能的跨平台解决方案,Flutter 的渲染性能理论上不会比原生差。同时,Flutter相比native还有很多渲染经验有待探索。让我们不忘初心,继续朝着这个方向前进。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。