flutter 卡顿侦测
Flutter卡顿侦测
前言
在使用以及开发flutter的过程中,往往会遇到很多的不同的问题。其中,卡顿问题尤为突出,其原因是,如果在某些场景下,造成了卡顿,对用户的使用体验,影响非常大。因此,需要一定的方法,能够在flutter发生卡顿的时候,捕获到flutter的卡顿信息(最好是当前的 main isolate 在干什么),之后便着手进行卡顿的优化项。
如何感知到flutter发生了卡顿?
Flutter单线程模型
回想一下flutter的相关知识:flutter是一个单线程模型的语言,他的所有的任务,在没有特意开启 other isolate 的情况下,都是运行在main isolate 中的。并且,flutter是基于消息的队列的形式进行处理任务的。
根据上图中可以得知,flutter的运行过程中,会产生两种消息:
- microtask
- event
并且可以得知,flutter在处理的时候,是优先处理microtask任务的。
什么是卡顿?
说实话,卡顿其实是人眼觉得屏幕掉帧了,并且感觉到不流畅,就认为是卡顿了。(其实是一个很笼统的概念。- . - )
在Android中,如果屏幕的刷新率是60hz的,那么在主线程中,如果每一个帧超过了 16.66ms, 那么认为该app在运行的过程中,发生了丢帧的行为,也就是发生了卡顿。这个时候,如果突然间卡了几秒中,就会出现了 Android 开发者都遇到过的 ANR 的行为了。
Android中如何侦测卡顿?
在Android的侦测卡顿中,它是侦测 Looper 中的函数执行时间的,而这个 Looper ,是 main looper。如何做? 在Looper执行的消息循环里面,取出每一个消息,之后记录下执行前的时间,随后,在处理完成这个消息的时候,记录了下执行后的时间。如果执行前的时间与执行后的时间的差值是大于了某一个阈值的,那么任务该函数方法执行过长,将会导致卡顿的发生。
public static void loop () {
...
for (;;) {
Message msg = queue.next();
if (msg == null) {
return;
}
final Printer logging = me.mLogging;
if (logging != null) {
logging.println("...");
}
...
if (logging != null) {
logging.println("...");
}
}
}
因此,在Android中,完全可以通过侦测 dispatchMessage(msg) 的执行时间,计算到函数的执行时间差值,判断是否发生了卡顿。
到达了这个时间阈值之后,我们便可以读取当前的线程的堆栈信息,之后便能得到了当前的卡顿数据。之后爱怎么搞就随你啦~
Flutter中如何侦测卡顿?
根据Android的卡顿侦测方法推演,那么,是否可以在flutter的框架下,侦测到flutter的代码执行流程?
貌似真的可以。
我们在main isolate 中打开一个worker isolate,并且在worker isolate中不断的发送一些microtask信息到消息队列中。这个时候,如果main isolate要是有空的话,会优先执行 microtask 的信息,之后再执行 event。因此,可以做一个简易版本的 ping - pong形式的卡顿侦测。因为这样只能侦测到 event 事件执行所带来的卡顿,并不能检测到 microtask 所带来的卡顿数据。(这是简易版本,也是目前我所做的东西,后面再升级优化吧.....)
通过这样的形式,就能做一个简单的flutter的卡顿侦测工具了。并且能够简单的感知到flutter的卡顿了。
如何获取堆栈信息?
堆栈?
什么是堆栈?为什么称程序执行的函数叫做堆栈信息?
翻开小本本,它上面写道:
- 堆栈是计算机中广泛运用的技术,通常用于保存中断断点、保存子程序调用返回点、保存CPU现场数据等,也用于程序间传递参数。
- 堆(Heap)
- 主要用来存放对象,为栈提供数据存储服务。堆是动态分配和释放的,因此可以用来存储需要动态分配和释放的数据。例如,在函数调用中,如果函数中有局部变量,这些变量通常会在栈上创建。而如果在函数中动态分配了内存(例如通过malloc函数),这些内存通常会在堆上创建。
- 栈(Stack)
- 主要用来执行程序。栈上的数据是有限的,大小通常由操作系统设定。当一个函数被调用时,会在栈上为其分配一块内存,用来存储局部变量和函数调用的上下文信息。当函数调用结束时,这块内存会被自动释放。因此,栈可以用来存储需要在函数间共享的数据,但是需要注意避免栈溢出的问题。
简而言之就是
- 堆
- 控制一段自己的存储空间,叫堆空间
- 程序运行时动态分配的,一般是程序运行时,请求操作空间分配给自己内存
- 栈
- 操作系统建立某个进程或者线程时,为线程建立的存储空间
- 会存储着函数的执行顺序
- 特性,栈顶进入,栈顶出
CPU是如何执行代码的?
其实,在使用任何一门语言时,在程序执行的过程中,机器都会一条条的执行你的命令。CPU会首先读取【程序计数器PC】的值,之后CPU的【控制单元】操作【地址总线】,访问这个【程序计数器】的内存地址,紧接着通知内存准备数据。数据准备好之后将【数据总线】的指令传给CPU,之后CPU收到数据,传入【指令寄存器】中。
CPU执行完成了指令之后,【程序计数器】自增,指向代码片段的下一条指令。自增是由CPU的架构所决定的。一般情况下,32位的CPU自增4(需要4个内存地址存放),64位CPU自增8(需要8个内存地址存放).
看下面的cpp代码,以及cpp代码汇编形式,来说明程序的执行流程。
#include <iostream>
int functionB(int a, int b)
{
return a + b;
}
int functionA(int a, int b)
{
int c = a + b;
int d = a - b;
return functionB(c, d);
}
int main()
{
int a = 1, b = 2;
functionA(a, b);
return 0;
}
这是他的汇编代码。
- 请注意,我是在MacOS Sonoma 14.7,系统上进行编译的,并且我的晶片是:Apple M2 Pro,如下代码隶属于arm64的代码汇编。
- 可以使用
g++ -S -o demo.s demo.cpp
进行从.cpp编译出.s汇编语言
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 14, 0 sdk_version 14, 2
.globl __Z9functionBii ; -- Begin function _Z9functionBii ; functionB 的开始
.p2align 2 ; 对齐4字节边界
__Z9functionBii: ; @_Z9functionBii
.cfi_startproc ; 开始函数的CFI指令
; %bb.0:
sub sp, sp, #16 ; 局部变量分配16字节的栈空间
.cfi_def_cfa_offset 16 ; 更新CFA偏移
str w0, [sp, #12] ; 保存第一个参数 w0 到 sp 栈指针偏移12个字节的地方
str w1, [sp, #8] ; 保存第二个参数 w1 到 sp 栈指针偏移8个字节的地方 ; 这个存储位置,可以看出这个数据是占据4个字节的
ldr w8, [sp, #12] ; 从 sp 栈指针偏移 12个字节,加载第一个参数到 w8 寄存器
ldr w9, [sp, #8] ; 从 sp 栈指针偏移 8个字节,加载第一个参数到 w9 寄存器
add w0, w8, w9 ; w0 = w8 + w9
add sp, sp, #16 ; 恢复栈指针
ret ; 返回
.cfi_endproc
; -- End function
.globl __Z9functionAii ; -- Begin function _Z9functionAii
.p2align 2
__Z9functionAii: ; @_Z9functionAii ;方法 functionA
.cfi_startproc ; 开始函数的CFI指令
; %bb.0:
sub sp, sp, #32 ; sp指针分配32空间,为局部变量分配空间。
.cfi_def_cfa_offset 32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill ; 将x29和x30寄存器的值,保存在栈中
add x29, sp, #16 ; 更新x29为当前栈顶指针,也就是fp
.cfi_def_cfa w29, 16 ;
.cfi_offset w30, -8
.cfi_offset w29, -16
stur w0, [x29, #-4] ;
str w1, [sp, #8]
ldur w8, [x29, #-4]
ldr w9, [sp, #8]
add w8, w8, w9
str w8, [sp, #4]
ldur w8, [x29, #-4]
ldr w9, [sp, #8]
subs w8, w8, w9
str w8, [sp]
ldr w0, [sp, #4]
ldr w1, [sp]
bl __Z9functionBii
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #32
.cfi_def_cfa_offset 32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
mov w8, #0
str w8, [sp] ; 4-byte Folded Spill
stur wzr, [x29, #-4]
mov w8, #1
str w8, [sp, #8]
mov w8, #2
str w8, [sp, #4]
ldr w0, [sp, #8]
ldr w1, [sp, #4]
bl __Z9functionAii
ldr w0, [sp] ; 4-byte Folded Reload
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.subsections_via_symbols
在汇编代码很容易能够看出,使用 [ sp ] 这个东西,基本上整段代码程序都有。因此,在进行堆栈还原时,如何获取 sp 这个值,以及获取存储 sp 的值的寄存器 fp,显得尤为重要。简单归类一个流程就是,
透漏一下:
SP: 堆栈寄存器的指针。SP的作用就是指示当前要出栈或入栈的数据,并在操作执行后自动递增或递减。
至于是入栈递增还是入栈递减,由CPU的生产厂家确定。CPU执行命令的过程
计算机每执行一条指令,大体上可以分为三个阶段:
取指令 -> 分析指令 -> 执行指令
- 取指令
- 根据程序计数器PC中的值,从程序存储器中读出指令。送到指令寄存器。
- 分析指令
- 将指令寄存器的指令取出后,进行转码,分析指令性质。
- 执行指令
- 根据对应的指令性质,执行指令内容。之后重复上述操作。
FP: ...
LR:...
在了解SP指针的作用下,我们又了解了CPU的指令执行形式,并且认清了程序执行的过程中,其指令的执行形式之后,我们便可以开始堆栈获取了。