likes
comments
collection
share

Flutter 线上卡顿检测方案实践(附代码)

作者站长头像
站长
· 阅读数 21

背景

Flutter 以其高效的渲染机制和热重载功能赢得了开发者的青睐。然而,即使是使用 Flutter 开发的应用,也难免会出现性能瓶颈,尤其是在复杂的 UI 交互或数据处理场景下。能主动感知并解决线上性能问题显得尤为重要。这一切的前提是能实现线上应用卡顿检测并能还原卡顿现场。

在本文中,我们将深入探讨 Flutter 卡顿检测方法。我将介绍如何利用新的方法、技术来监控 Flutter 应用的性能,识别卡顿的根源。无论你是 Flutter 的新手还是资深开发者,本文都将为你提供有价值的指导和启示,帮助你打造更加流畅、高效的移动应用。

一个完备的卡顿检测系统应该包含两个部分:

  1. 如何准确感知卡顿的存在
  2. 卡顿存在时如何还原卡顿现场

我们先来讨论第一个问题:如何感知卡顿?

感知卡顿

在开发过程中 Flutter 提供了 Performance/CPU Profile 工具来帮助开发者排查卡顿,通过柱状图来识别卡顿所在帧,通过代码火焰图来分辨耗时函数。(虽然使用起来确实比较方便,但 Performance/CPU Profile 工具真的比较吃性能,项目一大就很容易卡死甚至退出,所以我个在实际开发中用的也不是很多)

 Flutter 线上卡顿检测方案实践(附代码)

 Flutter 线上卡顿检测方案实践(附代码)

更遗憾的是这两个工具只能在开发阶段使用,原因是 Flutter DevTools 下的所有工具都依赖 VM Service,而 VM Service 只在 Debug/Profile 模式下存在,Release 模式无法使用 VM Service。

Flutter 提供的检测工具在开发阶段确实能解决一部性能问题,但用户端的机型千差万别我们很难在办公室内覆盖到用户端所有机型。另外,随着业务快速迭代,业务场景会急剧膨胀,开发阶段的简单性能测试也很难覆盖到每个业务场景。因此我们需要的是一个能在用户端进行检测的卡顿系统,利用用户端数量优势,找出 App 内的每一个卡顿场景。

帧率感知卡顿

谈到用户卡顿,首先想到的可能是帧率检测。确实,Flutter 不仅提供了 Debug/Profile 下的帧率检测工具,针对 Release 模也提供了对应的 API 来让用户实际帧率检测。SchedulerBinding 中提供这样的 API。

SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> timings) {})

TimingsCallback 回调会返回 Engine 层的帧信息列表,列表由 FrameTiming 构成。FrameTiming 包含了每一帧的开始时间与结束时间,每过计算实际帧数与理论帧的比值即可得到帧率数据。

但通过帧率数据能反映卡顿点吗?

肯定是不能的,率帧是一段时间(1 秒)内的性能表现,卡顿可能发生在这一段时间内的任意时间点,等计算出帧率,卡顿点早已错过,因此使用帧率感知卡顿行不通。

 Flutter 线上卡顿检测方案实践(附代码)

Ping-Pong 感知卡顿

还有没有其它方法?我们参考原生的卡顿检测思路,iOS 检测主线程卡顿有两种方法。一种是监听主线程 Runloop 的运行状态,当 Runloop 停在某一状态超过一定时间就意味着发生了卡顿。

 Flutter 线上卡顿检测方案实践(附代码)

另一种是使用子线程 Ping 的方式,在子线程中给主线程发送一个 Ping 消息后让子线程进入一段时间的睡眠,线程从睡眠中唤醒后没有收到主程线的返回的 Pong 消息就意味着发生的了卡顿,并且卡顿时长至少为子线程睡眠时长。

 Flutter 线上卡顿检测方案实践(附代码)

虽然 Flutter 也有类似 Runloop 的事件循环机制(Eevent Loop),但没有监听事件循环运行状态的 API,要想监听就需要改动 Engine 源码,改动源码对后续版本升级带来不可控因素。因此监听事件循环状态的思路在 Flutter 端不可行,那子线程 Ping 的思路可行吗?

虽然 Isolate 依赖线程池进行调度,但在 Flutter 中底层的 Engine 在启动 Root Isolate 时指定了专门的 UI 线程,因此 Root Isolate 在整个生命周期内所在的线程保持不变。

// 引擎源码可知 Dart 代码指定运行在 UI 线程
std::unique_ptr<AutoIsolateShutdown> RunDartCodeInIsolateOnUITaskRunner() {
 /**/
}

Flutter 框架中一帧画面的渲染需要 UI 线程与 Raster 线程协同共同完成。UI 线程负责组件的 Build、Layout、Paint、Composite,最终提到一个 Layer Tree 来交给 Raster 线程进行 GPU 渲染,并且这一过程全都是同步任务。UI 线程除了渲染任务,还会负责与界面展示相关其它业务逻辑处理,比如网络数据编解码、界面数据的计算、特定业务逻辑的处理等任务。因此渲染的卡顿往往容易出现在 UI 线程,而对于 Raster 线程开发者不能直接影响它,因此只需要对 UI 线程进行卡顿检测。

在实践之前再补充一点细节: Root IsolateWorker Isolate 在进行消息通信时也需依赖各自己的事件循环(Event Loop)机制。当 Worker IsolateRoot Isolate 发送一条消息,消息会保存到Root Isolate 对应的 FIFO 任务队列中,Root Isolate 执行完在此之前的任务后才会真正接收到Worker Isolate 传过来的消息数据。也就是说 Isolate 消息机制不会打断当前正在执行的任务,这是使用 Worker Isolate 检测 Root Isolate 是否卡顿的必要条件。

Ping-Pong 卡顿检测实现

从以上分析来看在 Flutter 中使用子线程 Ping UI 线程来感知卡顿的方案理论基础都有了,接下来看具体实现。

import 'dart:async';
import 'dart:isolate';
import 'dart:ffi';

const int ping = 0x55;
const int pong = 0xAA;

late Isolate _workerIsolate;

late ReceivePort _mainReceivePort;

void main() async {
    // 创建 worker isolate
    ReceivePort _mainReceivePort = ReceivePort();
    _workerIsolate = await Isolate.spawn(_workerIsolateFun, _mainReceivePort.sendPort);

    SendPort? workerSendPort;
    // 监听 worker isolate 消息
    _mainReceivePort.listen((data) {
      // 接收 Worker Isolate 的 SendPort,用来回复 Pong 消息
      if (data is SendPort) {
        workerSendPort  = data;
        return;
      }
      // 接收 Ping 消息后回复 Pong
      if (data is int && identical(data, ping)) {
        workerSendPort?.send(pong);
        return;
      }
    });
    
    runApp(const MyApp());
}

void _workerIsolateFun(SendPort mainSendPort)  async {
  final workerReceivePort = ReceivePort();
  // 回传一个 SendPort 来向当前 Isolate 回传 Pong 消息
  mainSendPort.send(workerReceivePort.sendPort);

  late Completer syncCompleter; 

  // 等待接收 Pong 消息
  workerReceivePort.listen((p) {
    if (p is int && identical(p, pong)) {
      syncCompleter.complete();
    }
  });
  
  while (true) {
    syncCompleter = Completer();
    // 发送 Ping 消息
    mainSendPort.send(ping);
    // 让 worker 休眠 25ms,时长可调整 
    await Future.delayed(const Duration(milliseconds: 25));
    // 唤醒后检查是否收到了 Pong 消息,没有收到则代表卡顿
    if (!syncCompleter.isCompleted) {
      print('卡住了~');
      // 卡顿后需要等当次卡顿结束再进行下一轮检测,避免卡顿线程消息堆积扩大卡顿影响 
      await syncCompleter.future;
    }
  }
}

使用 Ping 方案的卡顿检测核心逻辑在 Worker Isolate 中,用一个 while 循环发送 Ping 消息,发送消息后立即进入睡眠 25ms,唤醒后检查是否收到 Pong 消息,没有收到则意味着至少在这 25ms 内 Worker Isolate 都在执行某个耗时的任务。这里的睡眠时长就是检测周期,也是抽样频率。

我们用计数器的例子来验证一下效果,在点击回调中执行一个耗时任务,看看 Ping 方案是否能达到目的。

void _incrementCounter() {
    setState(() {
      _counter++;
    });

    final watch = Stopwatch()..start();
    watch.start();

    int sub = 0;
    for(int i = 1; i < 200; i++) {
      for(int j = 1; j < 10000; j++) {
        sub +=  log(i + j + sub).toInt();
      }
    }

    watch.stop();
    print('running time:  ${watch.elapsedMilliseconds} ms');
}

运行项目,点击计数器,观察日志。

 Flutter 线上卡顿检测方案实践(附代码)

通过日志可以发现,每次点击按钮导致的耗时计算任务都被检测了出来。事实证明通过 Ping 来检测卡顿方案全完可行,并且不依赖任何黑魔法,安卓、iOS 都可用,兼容现在及以后的任意 Flutter 版本。但这里需要注意 Worker Isolate 睡眠时长的设定有一个隐藏的逻辑:

如果需要稳定检测超过 S ms 的耗时任务,则检测周期不应大于 S/2,否则不保证每个耗时都会被检测出来

 Flutter 线上卡顿检测方案实践(附代码)

如上图所示,设定周期为 35ms,若检测是从耗时任务的第 20ms 开始,由于剩余时间不足 35ms 那此耗时任务不会被检测出来。

以上逻辑在数字信号处理中有一个专有名称:叫采样定理,又称香农采样定理、奈奎斯特采祥定理,是信号处理学中一个重要基本理论。

采样定理指出,只有采样频率高于信号带宽的两倍,原来的连续信号才可以从采样样本中完全重建出来。也就是说采样频率必须至少是信号中最大频率分量的两倍,否则就不能从信号采样中恢复原始信号。

卡顿检测本身应该对 App 自身的性能影响尽可能的少,Worker Isolate 虽然在不停的发送接收消息但整体大部分时间都处于睡眠状态,因此对 CPU 时间片的占用也比较少。在 iPhone7 上的静态测试对比如下:

 Flutter 线上卡顿检测方案实践(附代码)

使用计数器默认工程,25ms 的卡顿检测周期,开启前后的 CPU 使用率差距约为 2-3% ,并且理论上检测周期越长、机型越高端,影响越小,因此使用 Ping 方案对性能的影响完全可接受。

获取卡顿堆栈

仅仅是发现卡顿是没有用的,还需要能知道哪些代码导致了卡顿,因此感知卡顿后还应获取卡顿时的堆栈,只有堆栈信息才能准确找到卡顿代码。由于发现卡顿是在 Worker Isolate,而卡顿发生在 Root Isolate,我们没法在 Worker Isolate 中直接使用 StackTrace.current 来获取Root Isolate的堆栈(正在执行耗时任务),因此只能从线程的角度来获取堆栈。

由于业务代码直接运行在 Root Isolate,而 Root Isolate 一直运行在 UI 线程。因此只需要在 Worker Isolate 所在的线程中暂停 UI 线程通过栈回溯来获取 UI 线程堆栈。获取堆栈的步骤如下:

  1. 获取当前进程中的所有线程,并找到 Flutter UI TaskRunner 对应的线程
  2. 暂停该线程,并获取该线程当前的寄存器的值,重点为 LR 和 FP
  3. 根据栈帧回溯算法,获取堆栈
  4. 获取完堆栈后让 UI 线程继续运行
  5. 在当前应用中或服务端完成符号化

先说堆栈回溯原理。

堆栈回溯原理

堆栈回溯原理我们以 ARM64 处理器为例。

 Flutter 线上卡顿检测方案实践(附代码)

如上图所示每次函数被调用,都会生成一个新的栈帧,每个栈帧中都有一个 FP(Frame Pointer),每个 FP 指向上一个栈帧的 FP,而与 FP 相邻的 LR(Link Register)中保存的是上一个函数的返回地址,注意 LR 代表的是上一个函数返回的地址并不是当前栈桢返回后的地址。所以我们可以根据 FP 找到上一个FP,而与 FP 相邻的 LR 对应的函数地址就是该栈帧对应的函数。由于这个地址并不是函数的起启地址,因此在进行符号还原时只能通过不断对比符号表中函数的开始地址,与符号表开始最接近的栈帧地址即为函数对应的符号。

栈回溯算法如下:

typedef struct FrameEntry
{
  // 上一个栈帧
  struct FrameEntry* previous;
  
  // 当前栈帧返回地址
  uintptr_t return_address;
} FrameEntry;

int64_t depth = 0;

// 获取FP(X29)寄存器的栈顶指针,强转为 FrameEntry 指针
FrameEntry *f = (struct FrameEntry*)(_mcontext->__ss.__fp);

// 函数地址
intptr_t stackAddress[128] = {0};
int64_t maxDepth = sizeof(stackAddress) / 8;

// 栈顶函数返回地址
stackAddress[depth++] = _mcontext->__ss.__lr & 0x0000000fffffffff;

// 栈回溯核心逻辑
while (depth < maxDepth - 1 && f != 0 && f->return_address != 0) {
   stackAddress[depth++] = f->return_address;
   f = f->previous;
}

这里有一个小技巧:对于 ARM 64 每个 FP 所指向的地址是另一个栈帧的 FP 与相邻的 LR,因此可以用一个 FrameEntry 结构体来表示 FP、LR,每个 FP 指针都可以强制转换为 FrameEntry 指针。

 Flutter 线上卡顿检测方案实践(附代码)

如何找到与暂停目标线程

在 Dart 侧感知到卡顿到需要通过线程还原堆栈,而线程状态的读取使用 C,这就涉及到两种语言的相互调用,好在 Dart FFI 技术已生产可用。可以通过 FFI 从 Dart 侧直接同步的调用到 C,在 C 侧完成堆栈读取与补步的符号化。FFI 不是本文重点,这里就不多说了。

通过前文已知道了如何进行堆栈回溯,而第一步是先找到目标线程。Flutter 中的线程模型中有 4 个固定线程,并且都有对应的名称。以 UI 线程为例,它的名称固定为: io.flutter.*.ui。因此可以通过线程名找到 UI 线程。

bool suspendUIThread(thread_t *UIThread)
{

   thread_act_array_t allThreads = NULL;
   mach_msg_type_number_t numThreads = 0;
   
   kern_return_t kr;
   const task_t thisTask = mach_task_self();
   const thread_t thisThread = (thread_t)_thread_self();
   
   if((kr = task_threads(thisTask, &allThreads, &numThreads)) != KERN_SUCCESS)
   {
       return false;
   }
   
   // 遍历当前进程中的所有线程
   for(mach_msg_type_number_t i = 0; i < numThreads; i++)
   {
       thread_t thread = allThreads[i];
       
       char name[256];
       pthread_t pt = pthread_from_mach_thread_np(thread);
       int rc = pthread_getname_np(pt, name, sizeof name);
       if (rc != 0 || name[0] == 0) 
       {
           continue;
       }
       
       // 比较线程名,当前线程名称同时包含 io.flutter 与 ui 关键词即为 Flutter UI 线程
       if(thread != thisThread && strstr(name, "io.flutter") && strstr(name, "ui"))
       {
           if((kr = thread_suspend(thread)) == KERN_SUCCESS)
           {
               *UIThread = thread;
               return true;
           }
       
       }
   }
   return false;
}

找到 UI 线程后立即暂停它,接下来就是用上面介绍的栈回溯原理获取 UI 线程的堆栈。但在此之前还需要获取到可执行镜像的地址范围,通过对比地址范围确定堆栈所在镜像,之后再进行格式化处理即可得到如下堆栈:

  镜像名              堆栈绝对地址    =    镜像开始地址 + 相对镜像的偏移 
0 App                0x0000000107933b74 0x1078ec000+293748
1 App                0x0000000107b0cad4 0x1078ec000+2230996
2 App                0x00000001079017b0 0x1078ec000+87984
3 App                0x0000000107a2d89c 0x1078ec000+1317020

获取到堆栈之后就可以进行符号解析还原了。

符号解析

符号表还原步骤

上面得到的堆栈信息也仅仅只是一串内存地址,没有包含任何语义,需要把无意义的内存地址转换为可读的代码信息。在原生中应用内堆栈地址还原的骤如下:

  1. 获取当前进程所有可执行镜像(当前应用可执行文件、所有动态库)在虚拟内存中地址范围
  2. 读取一个堆栈内存地址,通过对比镜像的开始与结束范围确定堆栈内存地址所在镜像
  3. 找到目标镜像后,遍历镜像中所有符号,将堆栈地址与符号地址对比,找到与堆栈地址最近的符号地址
  4. 找到目标符号信息后即可读取出对应的符号名称

如果你直接使用原生的方法在应用内进行符号还原可能会得到如下格式堆栈:

0 App                0x00000001030e98b0 _kDartIsolateSnapshotInstructions
1 App                0x00000001030e9870 _kDartIsolateSnapshotInstructions
2 App                0x00000001032158e8 _kDartIsolateSnapshotInstructions
3 App                0x0000000103187a8c _kDartIsolateSnapshotInstructions

原因是 Flutter 1.17 以后为减小包体积提供了移除符号信息的能力,移除符号表之后 App.framework 内将不再包含 Dart 相关的符号信息。使用 nm 命令查看 App.framework 符号信息:

App.framework 包含 Dart 代码的动态库,Flutter.framework 包含引擎的动态库

 Flutter 线上卡顿检测方案实践(附代码)

移除符号信息后,App.framework 中只包含了两个符号信息:

  • Dart 虚拟机指令与数据(_kDartVmSnapshotInstructions+_kDartVmSnapshotData)
  • Dart 代码指令与数据(_kDartIsolateSnapshotInstructions+_kDartIsolateSnapshotData)

通过上面还原过后的堆栈可得到结论:

所有的 Dart 代码都在 _kDartIsolateSnapshotInstructions 符号下,因此 _kDartIsolateSnapshotInstructions 符号所指的地址即为 Dart 代码的开始地址

记住这个结论,后面会有用。

打 Release 包时可通过 split-debug-info 移除符号表并指定符号表保存地址。

flutter build ipa --release --split-debug-info=./symbol_file/

如果你的项目没有主动移除符号表,其实下面的部分可以不用再看了,使用上面原生的方式去进行符号表还原即可得到卡顿堆栈。没有移除符号信息时使用 nm 查看 App.framework 可以得到完整的符号信息:

 Flutter 线上卡顿检测方案实践(附代码)

如何还原 Dart 符号表

移除符号信息后使用 StackTrace.current 获取到堆栈信息是如下格式:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
pid: 4054, tid: 140704710071872, name Dart_Initialize
os: macos arch: x64 comp: no sim: no
build_id: '46d89a0192b549c9621ec05d0e9609e4'
isolate_dso_base: 10bfd0000, vm_dso_base: 10bfd0000
isolate_instructions: 10c00ac00, vm_instructions: 10c004000
    #00 abs 000000010c040f8b _kDartIsolateSnapshotInstructions+0x3638b
    #01 abs 000000010c040f6a _kDartIsolateSnapshotInstructions+0x3636a
    #02 abs 000000010c04cf44 _kDartIsolateSnapshotInstructions+0x42344
    #03 abs 000000010c041382 _kDartIsolateSnapshotInstructions+0x36782
    #04 abs 000000010c04d256 _kDartIsolateSnapshotInstructions+0x42656
    #05 abs 000000010c0139ad _kDartIsolateSnapshotInstructions+0x8dad

上面的堆栈正常情况下可以使用 Flutter 提供的符号化工具进行还原:

flutter symbolize -d=./app.arm64.symbols -i=./crashes/stack_trace.err

而我们得到的原生的堆栈格式是:

0 App                0x0000000107933b74 0x1078ec000+293748

flutter symbolize 无法还原生的堆栈格式,所以需要格式转换,将原生的堆栈转换为 Flutter 符号化工具能识别的格式。

序号 镜像名              堆栈绝对地址    =    镜像开始地址 + 相对镜像的偏移 
0   App                0x0000000107933b74 0x1078ec000+293748
👇
序号     堆栈绝对地址  =    Dart 代码段起始地址 + 相对开始地址的偏移(isolate_offset)
#00 abs 0000000107933b74 _kDartIsolateSnapshotInstructions+0x????????

这个转换关系的重点其实在 _kDartIsolateSnapshotInstructions,前面提到 _kDartIsolateSnapshotInstructions 符号所指的地址即为 Dart 代码段的开始地址,知道了这个开始地址即可算出 isolate_offset

原生进行符号化还原时得到的符号表信息中已经包含了对应符号(_kDartIsolateSnapshotInstructions)在代码段的起始地址(_kDartVmSnapshotInstructions同理)

// Mach-O 符号表数据结构
struct nlist_64 {
    union {
        uint32_t  n_strx; /* index into the string table */
    } n_un;
    uint8_t n_type;        /* type flag, see below */
    uint8_t n_sect;        /* section number or NO_SECT */
    uint16_t n_desc;       /* see <mach-o/stab.h> */
    uint64_t n_value;      /* 当前符号对应的起始地址 */ 
};

// Dart 代码段起始绝对地址                 =   当前符号地址             +  随机偏移
void *_kDartIsolateSnapshotInstructions = (void *)(symbol->n_value + appImageVMSlide);

因此对于一个原生堆栈地址,要计算相对偏移(isolate_offset)就很简单了;

相对开始地址的偏移(isolate_offset) = 堆栈绝对地址  -  Dart 代码段起始地址(_kDartIsolateSnapshotInstructions)

将整个堆栈地址都按上面的方式进行计算,然后调整格式即可使用 flutter symbolize 进行还原。

但是,一般情况下不会去使用 flutter symbolize 工具进行符号解析,原因请继续看下个章节。

服务端符号化

安装包中移除符号信息后,收集到的异常或卡顿堆栈往往选择在服务端进行符号化解析,但服务端不可能去依赖整个 Flutter 的开发环境来进行符号化,因此需要将 Flutter 的符号化工具抽离出来。

通过分析源码可知,Flutter 符号化解析依赖了一个官方库 native_stack_traces;

这个库就是 flutter symbolize 内部进行符号化解析的底层库,它不仅支持按文件进行符号化解析还支持按行解析。 native_stack_traces 本质上是个命令行工具,因此可以将其编译为独立的命令行工具部署在服务器上,而不需要依赖 Flutter 开发环境甚至 Dart 开发环境。

// 将 native_stack_traces 编译可独立运行的命令行工具
dart compile exe ./bin/decode.dart -o dart_symbol_decode.exe

之后部署在服务器上便可正常使用

./dart_symbol_decode.exe -h

Usage: decode <command> [options] ...

Commands:
dump
help
find
translate

Options shared by all commands:
-h, --help    Print usage information for this or a particular subcommand

按「行」进行符号化解析使用 find 命令,通过指定下面四个参数,即可完成符号化解析。

  • isolate_start:Dart 代码段开始地址
  • vm_start: VM 代码段开始地址
  • l: 堆栈地址
  • d: 符号表文件
  • a:CPU 架构

其中 isolate_start 即为 _kDartIsolateSnapshotInstructions,通过应用内符号表得到, vm_start 即为 _kDartVMSnapshotInstructions,同样也是通过应用内符号表得到;

bool findAppFrameworkSymbol(Dl_info *isolateSymbol, Dl_info *vmSymbol) {
    const uint32_t imageCount = _dyld_image_count();
    const char *imageName = NULL;
    uint32_t iImg = UINT_MAX;

    // 查找目标镜像
    for(iImg = 0; iImg < imageCount; iImg++)
    {
        imageName = _dyld_get_image_name(iImg);
        if (is_end_with(imageName, "App")) {
            break;
        }
    }
    if (iImg == -1 || imageName == NULL) {
        return false;
    }

    memset(isolateSymbol, 0, sizeof(Dl_info));

    const struct mach_header* header = _dyld_get_image_header(iImg);
    const uintptr_t appImageVMSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(iImg);
    const uintptr_t segmentBase = segmentBaseOfImageIndex(iImg) + appImageVMSlide;
    if(segmentBase == 0)
    {
        return false;
    }

    isolateSymbol->dli_fname = imageName;
    isolateSymbol->dli_fbase = (void*)header;

    memcpy(vmSymbol, isolateSymbol, sizeof(Dl_info));

    uintptr_t cmdPtr = firstCmdAfterHeader(header);

    if(cmdPtr == 0)
    {
        return false;
    }

    // 遍历 Load Command 查找符号表
    for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++)
    {
        const struct load_command* loadCmd = (struct load_command*)cmdPtr;
        if(loadCmd->cmd == LC_SYMTAB)
        {
            const struct symtab_command* symtabCmd = (struct symtab_command*)cmdPtr;
            const struct nlist_64* symbolTable = (struct nlist_64*)(segmentBase + symtabCmd->symoff);
            const uintptr_t stringTable = segmentBase + symtabCmd->stroff;

            // 遍历符号表找到目标符号
            for(uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++)
            {
               const struct nlist_64* symbol = symbolTable + iSym;
               char *symbolName = (char*)((intptr_t)stringTable + (intptr_t)symbol->n_un.n_strx);

               if (strcmp(symbolName, "_kDartIsolateSnapshotInstructions") == 0) {
                    isolateSymbol->dli_sname = symbolName;
                    // isolate_start
                    isolateSymbol->dli_saddr = (void *)(symbol->n_value + appImageVMSlide);
               }

               if (strcmp(symbolName, "_kDartVmSnapshotInstructions") == 0) {
                   vmSymbol->dli_sname = symbolName;
                   // vm_start
                   vmSymbol->dli_saddr = (void *)(symbol->n_value + appImageVMSlide);
               }
            }
            
            break;
        }
        cmdPtr += loadCmd->cmdsize;
    }

    return false;
}

需要注意,所有内存地址要么同时包含 ASLR 要么同时移除 ASLR,不可混用。按行进行符号化解析示例如下

./dart_symbol_decode.exe find -d ./symbol_file/app.darwin-x86_64.symbols -a x64 --isolate_start 0x1162a0300 --vm_start 0x116299180 -l 0x1163cf013

使用 native_stack_traces 进行单行解析时不需要复杂的堆栈格式转换,只需要提供三个内存地址即可完成解析,因此使用命令行工具 native_stack_traces 是更推荐的符号化解析方案。

最后还是以计数器工程为例,在 MacOS 平台进行符号化还原后与 Debug 模式堆栈对比几乎没有差别,能准确还原卡顿现场。

 Flutter 线上卡顿检测方案实践(附代码)

最后

虽然本文「基于 Ping-Pong 的卡顿检测及卡顿堆栈还原方案」的分析都是基于 iOS 平台,但思路对于 Android、Win、MacOS 等平台都适用,在这些平台上都能达到相同的效果。本文「服务端符号化解析」方案也适用于 Dart 异常符号化还原,基于此方案开发者可大胆通过移除符号表来减小包体积了。针对卡顿检测的部分仅提供了客户端的实现思路,工程化落地时还需要进一步完善相关细节(如:多平台兼容、客户端上报、服务端的堆栈聚合、服务端异步符号化解析等等)。最后希望通过本文所分享的方案能给大家的 Flutter 项目带来一亿点点的性提升。

Demo 在此

转载自:https://juejin.cn/post/7390620343014096948
评论
请登录