随着项目规模的扩大以及用户需求的不断增长,特别是在应用程序的稳定性方面,我们需要更加严谨和细致的监控手段来保障用户的良好体验。Dart语言作为Flutter开发中的核心,负责应用程序的逻辑和业务实现。然而,由于Dart的灵活性和动态特性,我们很容易在代码中引入各种潜在的错误和异常。这些异常可能会导致应用程序的功能异常,严重影响用户的使用体验和满意度。
因此,为了提高应用程序的稳定性和铲平用户满意度,我们专门支持了在Flutter项目中引入友盟+U-APM的异常监控功能的能力。友盟+U-APM是一款专业的移动应用性能监控工具,可以帮助我们实时监测应用程序中的异常情况,并及时采取相应的措施进行处理。
友盟+U-APM在Flutter Dart异常监控方面提供了自动捕获并记录未处理的异常信息,帮助我们快速定位问题并进行修复;它还可以分析异常出现的版本、时间、路径、设备等关键信息,帮助我们深入了解异常的产生原因和规律。此外,友盟+U-APM还提供了即时的告警功能,可以在异常发生时及时通知开发团队,以便他们快速响应和解决问题。
在这篇文章中,我们将深入探讨友盟+U-APM在实践中的应用,并分享一些我们的经验。希望通过我们的实践,能够帮助更多的开发者在Flutter项目中顺利引入异常监控功能,并构建更加稳定可靠的移动应用程序。
1 ► SDK简易架构图
Flutter 异常监控 SDK 依赖 Native APM SDK 时,可以通过 Native APM SDK 来采集设备信息和云配信息,并在初始化完成后发送一个信号给 Flutter 异常监控 SDK。
一、设备信息采集:
Native APM SDK 可以获取设备的基本信息,例如设备型号、操作系统版本、CPU 架构等。这些信息可以帮助开发者更好地了解用户的设备环境,在异常分析和排查时提供参考。
二、云配信息采集:
云配信息是指通过远程配置的方式动态更新应用的配置参数。Native APM SDK 可以从远程服务器获取云配信息,并将这些信息提供给 Flutter 异常监控 SDK 使用。例如,可以通过云配信息来配置异常监控的开关、采集策略等。
三、初始化完成信号:
当 Native APM SDK 完成初始化后,可以发送一个信号给 Flutter 异常监控 SDK,通知其可以开始启动采集和上报,用于确保 Flutter 异常监控 SDK 在适当的时机进行启动和工作。
通过依赖 Native APM SDK,Flutter 异常监控 SDK 可以获取更全面的设备信息和云配信息,提高异常监控的准确性和灵活性。同时,通过初始化完成信号的交互,可以确保两个 SDK 在协同工作时的顺畅和稳定。
Flutter 异常监控 SDK 简易架构图主要包括初始化、采集和上报三部分。
一、初始化:
SDK 的初始化是在应用启动时完成的。在初始化过程中,SDK 需要完成以下几个步骤:
1.配置参数:开发者需要提供一些必要的配置参数,例如Flutter SDK版本号、应用版本号等。
2.注册监听器:SDK 需要注册一些监听器,以便捕获处理异常事件和监听路由行为。
二、采集:
SDK 的采集模块负责收集应用发生的异常信息。它可以捕获dart异常情况,并将这些异常信息进行预处理和标准化,方便后续的上报和分析。
三、上报:
SDK 的上报模块负责将采集到的异常信息发送给服务器。在上报之前,SDK 可能会对异常信息进行一些加工处理,例如压缩、日志条数和大小条件判断等。
2 ► 后台数据支持
01
Dart异常
1.1 异常采集
Native的异常通常伴随着应用崩溃,而dart异常,提供了一种灵活的机制来捕获和处理异常,不会直接导致应用程序崩溃,常表现为页面白屏、执行操作卡死等,它们在稳定性的用户体验上并不是完全对等的。
App 异常:
就是应用代码的逻辑层异常,通常由未处理应用层其他模块所抛出的异常引起。
根据异常代码的执行时序,App 异常可以分为两类,即同步异常和异步异常。
Framework异常:
Flutter 框架自身的异常,如 widget tree 中的不一致、代码中的错误,如空指针异常、数组越界等导致渲染异常等;
Flutter 插件的异常,如与原生代码交互时出现的异常
捕获异常方式:
以上一些捕获异常的方式都可以用于在Flutter应用中捕获异常,不同的方式适用于不同的场景。
○ FlutterError.onError适用于全局异常处理
○ runZonedGuarded适用于在特定区域内捕获异常
○ PlatformDispatcher.instance.onError适用于处理底层平台异常 (Flutter SDK v3.3及以上支持)
○ try catch适用于捕获同步代码块中的异常
○ Future.catchError适用于捕获异步操作中的异常
1.2 异常级别
下面我们通过模拟数组越界的主动引发执行异常,可以看到同类型的错误出现的位置不一样导致的页面表现也不一致。
List<int> numbers = [1, 2, 3];
// 模拟数组越界异常
int fourthNumber = numbers[3];
同步初始化异常:
在执行入口main() 主动引发异常,因为同步的初始化执行被阻塞导致后续的页面无法成功渲染,表现为页面白屏。
未捕获的异步错误:
主动在Future.delayed中引发异常,因为异步任务不会阻塞主线程页面渲染,所以页面表现正常。
组件渲染错误:
绘制主动引发异常,引发framework异常,表现为白屏(dev下红屏)。
手势事件回调异常:
Flutter的事件处理是异步的,并且是在单独的线程中处理的,不会阻塞页面渲染,但是异常代码之后的执行逻辑将被阻塞无法响应。
1.2.1 异常定级
我们分成了 ErrorWidget、Error、Exception三个级别:
○ ErrorWidget属于页面组件渲染异常,会影响用户体验,通常表现为白屏,需要业务重点关注并解决。
○ Exception表示的是程序在运行过程中出现了一些意外的情况,导致程序无法正常执行。这些情况通常包括用户输入错误、网络连接中断、文件读取失败等。Exception类及其子类通常都可以被捕获并处理,以避免程序崩溃或出现其他问题。
○ Error表示的是程序在运行过程中出现了一些严重的问题,比如内存不足、栈溢出等。这些问题通常是由于程序本身的错误导致的,而不是由外部因素造成的。Error类及其子类通常无法被捕获,如果出现了这些错误,程序通常会崩溃。
ErrorWidget级别
FlutterError.onError = (FlutterErrorDetails details) {
final library = details.library;
final bool isWidgetsLibrary =
library is String && 'widgets library'.compareTo(library) == 0;
if (isWidgetsLibrary) {
print('发生渲染异常 定义为 ErrorWidget 通常表现为白屏');
}
}
Error和Exception级别
if (e is Exception) {
print('Caught an Exception: $e');
} else {
print('Caught an error: $e');
}
我们将自动采集到异常进行判定定级通过日志打标的方式帮助开发者在后台能够快速定位到紧急且高优先级需要处理的异常。
1.3 异常细类
Flutter 异常类型主要包括以下几种(不包含全部):
1. FlutterError:这是 Flutter 异常的基类,用于表示 Flutter 框架内部的错误。它提供了一个描述错误的消息和一个可选的错误详情。
try {
// ...
} catch (e) {
if (e is FlutterError) {
print('Flutter Error: ${e.message}');
print('Error Details: ${e.details}');
}
}
2. AssertionError:这是一个断言异常,用于在开发过程中发现错误或无效的操作。当断言失败时,会抛出此异常。
void divide(int a, int b) {
assert(b != 0, 'Division by zero is not allowed');
// ...
}
3. FormatException:表示由于格式错误而导致的异常。例如,将字符串转换为数字时,如果字符串的格式不正确,则会抛出此异常。
try {
int number = int.parse('abc');
} catch (e) {
if (e is FormatException) {
print('Invalid format: ${e.source}');
}
}
4. RangeError:表示由于超出范围而导致的异常。例如,当索引超出列表的范围时,会抛出此异常。
try {
List<int> numbers = [1, 2, 3];
int number = numbers[4];
} catch (e) {
if (e is RangeError) {
print('Index out of range');
}
}
5. NoSuchMethodError:表示尝试调用不存在的方法或访问不存在的属性时引发的异常。
try {
String name;
name.toLowerCase();
} catch (e) {
if (e is NoSuchMethodError) {
print('Method not found');
}
}
这些是一些常见的 Flutter 异常类型和相应的案例。根据项目的具体需求和实现细节,可能会遇到其他类型的异常。
SDK通过异常摘要的 runtimeType 来采集异常的异常细类,对于 CastError(类型转换错误)、RangeError、PlatformException、NoSuchMethodError、MissingPluginException 等多种错误类型,我们认为其是影响业务的,所以对 Flutter 的异常进行分类的处理。
1.4 后台展示
参考输出以下内容
· 错误级别:ErrorWidget、Error、Exception
○ 通过定义错误级别指引用户处理优先级)
· 错误类型:CastError、RangeError、PlatformException、NoSuchMethodError、MissingPluginException等
○ 通过错误类型进一步确定问题类型和处理优先级
· 用户视角体验:异常是否导致白屏
○ 从体验角度确定用户处理高优先级
02
异常摘要聚合
2.1 聚合目标
通过提取更准确的计算因子用于聚类标识计算从而解决 ① 摘要相同堆栈不同聚合导致不同的问题被掩盖无法发现并解决、 ② 相同的异常问题由于摘要存在变量导致问题分散(如:包含url,id、md5等离散值)导致问题数量分散无法及时发现问题。
2.2 异常聚类因子
// 摘要
NoSuchMethodError: Class 'int' has no instance method 'add'.
// 堆栈
pid: 10349, tid: 10382, name 1.ui
os: android arch: arm64 comp: yes sim: no
build_id: '349b9003b2c2867ab94ca373c1247263'
isolate_dso_base: 79d362b000, vm_dso_base: 79d362b000
isolate_instructions: 79d3700d00, vm_instructions: 79d36fb000
#0 Object.noSuchMethod (third_party/dart/sdk/lib/_internal/vm/lib/object_patch.dart:38:5)
#1 _objectNoSuchMethod (third_party/dart/sdk/lib/_internal/vm/lib/object_patch.dart:85:9)
#2 main.<anonymous closure> (/Users/tony/Desktop/project/apm-log-visual/lib/main.dart:17:12)
#3 _rootRun (third_party/dart/sdk/lib/async/zone.dart:1399:13)
#4 _rootRun (third_party/dart/sdk/lib/async/zone.dart:1390:1)
#5 _CustomZone.run (third_party/dart/sdk/lib/async/zone.dart:1301:19)
#6 _runZoned (third_party/dart/sdk/lib/async/zone.dart:1804:10)
#7 runZonedGuarded (third_party/dart/sdk/lib/async/zone.dart:1792:12)
因子名称 | 因子分类&处理规则 | 因子来源 |
异常等级 | ErrorWidget:页面渲染失败 | Flutter APM SDK 运行时分异常场景打标采集 |
Error:运行过程中出现内存不足、栈溢出等问题 | ||
Exception:运行时出现的意外(如引用错误等原因) | ||
异常类型 | RangeError | 通过运行时异常回调 (Exception).runtimeType方法获取 |
PlatformException | ||
NoSuchMethodError | ||
...... | ||
错误堆栈 | 堆栈前6行
| 通过运行时异常回调回值Stack采集 |
2.3 异常聚类特征点提取
2.3.1 特征点
业务特征 | 三方库特征 | Flutter引擎特征 |
非三方库非引擎 + lib/ | third_party/ | flutter/packages flutter/shell flutter/runtime ..... |
非三方库非引擎 + package:变量/ package:flutter_module | dart:变量/ | package:flutter flutter:shell flutter:runtime ..... |
2.3.2 堆栈类型一
提示:剔除符号文件打包下解析后输出的错误堆栈
2.3.3 堆栈类型二
提示:没有剔除符号文件打包下解析后输出的错误堆栈
2.4 堆栈关键异常点识别规则
关键异常点的提取逻辑如下,按照以下规则进行堆栈遍历,而异常点在遍历过程中按照以下优先级进行定位:
业务堆栈(含业务包名) > 三方库堆栈 > 引擎堆栈
详细逻辑如下:
参考2.3.1 系统库特征点, 默认情况下,如果堆栈全部都是系统库的,则默认取堆栈库的第一行
参考2.3.1 三方库特征点,如遇到第三方库则修改为第三方库的第一行,即第一次出现的业务堆栈
如果遇到业务堆栈,则修改为业务堆栈中的第一行,即第一次出现的业务堆栈02
03
符号表解析
在 Flutter1.17 以上的版本中,官方支持了对 Flutter 产物去除符号表的功能,考虑到集成 Flutter 产物的 app 的安全性和包大小问题,在打包系统中集成了这个功能,通过官方支持的打包命令就可以在打包期间分离符号表文件。
要混淆您的应用程序,请flutter build在发布模式下使用带有--obfuscate和 --split-debug-info选项的命令。该--split-debug-info选项指定 Flutter 输出调试文件的目录。在混淆的情况下,它输出一个符号映射。例如:
// Android
flutter build apk --obfuscate --split-debug-info=/<project-name>/<directory>
一旦你混淆了你的二进制文件,保存符号文件。如果您稍后想要对堆栈跟踪进行去混淆处理,则需要用到它。
所以也导致 Flutter 异常上报的堆栈是去符号化的,难以阅读理解,如下所示:
"Warning: This VM has been configured to produce stack traces that violate the Dart standard.
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
pid: 16540, tid: 6125465600, name beikeshareengine_0.1.ui
isolate_dso_base: 10b860000, vm_dso_base: 10b860000
isolate_instructions: 10b86a000, vm_instructions: 10b866000
#00 abs 000000010bc7d08b _kDartIsolateSnapshotInstructions+0x41308b
#01 abs 000000010bb0f037 _kDartIsolateSnapshotInstructions+0x2a5037
#02 abs 000000010bcc72e7 _kDartIsolateSnapshotInstructions+0x45d2e7
U-APM提供了上传Dart符号表功能,用户只要上传了对应版本的符号表文件,就可以实时对混淆的Flutter堆栈进行还原。那么,背后的技术是怎么实现的呢?
3.1 符号表文件解析
Flutter Dart异常的符号表文件是一种DWARF格式的文件,DWARF("Debugging With Attributed RecordFormats")文件包含了应用的调试信息,我们可以使用Linux平台下的dwarfdump工具,将其中的调式信息解析出来。
例如,以下命令可以将 my-flutter-app.symbols 这个符号表文件中的调试信息,以文本的形式转储到 debug.txt 文件中:
dwarfdump -a my-flutter-app.symbols > debug.txt
这个debug.txt文件就包含了我们还原堆栈所需要的地址映射信息。下面我们对这个文件的结构做一个简要的介绍。
3.1.1 编译单元
在 .debug_info 部分的开头,就是一个编译单元结构(COMPILE_UNIT),这个结构中我们重点获取 DW_AT_low_pc 的值,这是整个编译单元的起始地址偏移量,后面计算函数、行列的相对偏移量,都要基于这个参数。
3.1.2 函数信息
然后 DW_TAG_subprogram 部分就是函数的定义了,可以找到函数的id、函数名称、函数所在文件编号等信息。
注意到上面函数定义中没有函数起止地址信息,这在另外一种结构中,如下图所示,DW_AT_abstract_origin 指向了函数id,关联了 0x0000002d 这个函数
其中 [DW_AT_low_pc , DW_AT_high_pc) 即为函数的起止地址范围。通过将这两种结构关联,就可以同时找到函数的名称、起止地址范围等信息。注意,这里的起止地址要减去上面提到的编译单元的起始地址偏移量,转换成相对地址。
3.1.3 行列和文件信息
行列和文件信息可以在文件的 .debug_line 段落中找到。如下图所示:![9c1c1dce6aa667ac77fe51e8d9b12202.png](https://img-blog.csdnimg.cn/img_convert/9c1c1dce6aa667ac77fe51e8d9b12202.png)
其中pc是行的起始地址,正序排列。注意,每一行的起始地址同时也是上一行的结束地址。[lno,col] 是10进制的行列号,uri是行所在的文件路径,若某一行uri列是空的,那这一行的文件路径等同于前面最近一个出现的uri。这样,我们就能解析出所有行的行列号、所在文件路径、起止地址了。和函数部分一样,行的地址也要转换成相对地址。
3.2 堆栈还原
基于解析好的调试信息,是如何用于混淆堆栈还原的呢?
如下图所示,我们从混淆堆栈每一行的末尾,可以找到+0x开头的十六进制地址,这个就代表这一行堆栈的相对偏移量。
然后用堆栈的相对偏移量,在调试信息中的行地址范围、函数地址范围进行检索匹配,就可以找到对应的文件名、函数名、行列号等信息了,从而完成堆栈的还原。
还原的结果如下图所示:
以上就是友盟+U-APM对Dart符号表文件解析和堆栈还原的大致方法,实际系统处理过程中还会做一系列的结构化、压缩、去重和缓存等操作,以保证在生产环境下的高性能和高可用性。
在第一期中,我们提供Flutter Dart异常的监控功能,并试图帮助开发者解决应用稳定性问题。然而,我们并不满足于此,我们的未来规划中将进一步增加Flutter页面性能与帧率模块,以提升开发者应用程序的用户体验和性能表现。
如需了解更多具体的产品信息及活动内容,
点击下方【阅读原文】了解详情吧!