自 iOS 问世以来,官方从来没有提供工具让第三方开发者监控线上 App 的运行情况。特别地,如果开发者想要统计分析崩溃和卡顿,那就只能“自食其力”。而这一切从 iOS 14 开始有了转机,因为 Apple 推出的性能诊断框架 MetricKit 终于囊括异常情况的分析数据:卡顿、崩溃等的堆栈信息。“得益”于 Apple 对用户隐私的保护,它只能作为常规手段的一个补充,而非主力选手。此外,MetricKit 能捕捉到诸如 Watchdog timeout 之类的漏网之鱼,让它不至于全然如鸡肋一般,食之无味,弃之可惜。

餐前白开水:数据测量

事实上,我们可以不做任何事情就可以看到 MetricKit 生成的数据报告,它在「Xcode」-「Window」-「Orginizer」-「Metrics」一栏中,包含如下几个维度的信息:

  • Battery Usage
  • Disk Writes
  • Hang Rate
  • Launch Time
  • Memory
  • Scrolling
  • Terminations

它本身是以 App 版本来划分的,并且与 App 上线时间与使用量强相关,因此如果你看到是“白板”也不要感到奇怪。所以为了能够更高效的使用这些数据,你还是得自建数据平台并解析下面这样的 JSON 数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 采样时间
timeStampBegin = "2022-05-19 11:55:04";
timeStampEnd = "2022-05-19 11:55:04";

// iOS 14+
animationMetrics = {
// scrollHitchRate,统计标准不明(平均值?还是中位数?)
scrollHitchTimeRatio = "1000 ms per s";
};

// iOS 14+
applicationExitMetrics = {
// App 前台退出次数及原因
backgroundExitData = {
// 正常退出
cumulativeNormalAppExitCount = 1;

// 异常退出
cumulativeAbnormalExitCount = 1;

// 看门狗退出
cumulativeAppWatchdogExitCount = 1;

// 后台任务超时退出
cumulativeBackgroundFetchCompletionTimeoutExitCount = 1;
cumulativeBackgroundTaskAssertionTimeoutExitCount = 1;
cumulativeBackgroundURLSessionCompletionTimeoutExitCount = 1;

// 野指针
cumulativeBadAccessExitCount = 1;

cumulativeCPUResourceLimitExitCount = 1;
cumulativeIllegalInstructionExitCount = 1;

// 内存问题退出
cumulativeMemoryPressureExitCount = 1;
// OOM
cumulativeMemoryResourceLimitExitCount = 1;

cumulativeSuspendedWithLockedFileExitCount = 1;
};

// App 后台退出
foregroundExitData = {
cumulativeAbnormalExitCount = 1;
cumulativeAppWatchdogExitCount = 1;
cumulativeBadAccessExitCount = 1;
cumulativeCPUResourceLimitExitCount = 1;
cumulativeIllegalInstructionExitCount = 1;
cumulativeMemoryResourceLimitExitCount = 1;
cumulativeNormalAppExitCount = 1;
};
};

// 太多了,省略省略。如果你想试试,连接真机,点击「Xcode」-「Debug」-「Simulate MetricKit Payloads」即可。

如果我们将用户共享数据给开发者视为天然采样机制的话,对于业务至上的小团队而言,MetricKit 无疑是最好的从多个维度评判 App 运行情况的解决方案。像是 OOM、卡顿之类的数据统计,如果是自己造轮子的话,决计不是这么简单的事情。

正餐:堆栈信息(iOS 14+)

对于开发者而言,肯定都希望所有的线上问题都能定位到函数调用堆栈。但单从上文中说到的 MetricKit,貌似只告诉了你:“您家 App 有问题,具体问题暂时没法告诉您,反正就是有问题。”。好在 Apple 的工程师“意识”到了 MetricKit 这种尴尬的境地,在第二个版本中增加了问题诊断:

  • CPU Exception:采样时间内 CPU 使用时间超过 50% 就会触发,诊断负载中包含了 totalSampledTime、totalCPUTime。
  • Disk Write Exception: 24 小时内写入磁盘的数据量超过 1GB 就会触发,诊断负载中包含了该堆栈写入的数据量。
  • Hang:卡顿,主线程超过 250ms 无响应就会触发,诊断负载中包含了卡顿时间 hangTime。
  • App Launch: iOS 16+,启动就会触发,诊断负载中包含了启动时间 launchDuration。
  • Crash: 崩溃就会触发,诊断负载中包含了 signal、exceptionType 等数据。特别地,对于 Watchdog timeout 问题,terminationReason 格式为:code:0x8BADF00D explanation:scene-update watchdog transgression:xxxxx。

及其堆栈信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

{
"callStackTree" : {
"callStackPerThread" : true,
"callStacks" : [
{
"threadAttributed" : false,
"callStackRootFrames" : [
{
"binaryUUID" : "70B89F27-1634-3580-A695-57CDB41D7743",
"offsetIntoBinaryTextSegment" : 165304,
"sampleCount" : 1,
"binaryName" : "MetricKitTestApp",
"address" : 7170766264
"subFrames" : [
{
"binaryUUID" : "77A62F2E-8212-30F3-84C1-E8497440ACF8",
"offsetIntoBinaryTextSegment" : 6948,
"sampleCount" : 1,
"binaryName" : "libdyld.dylib",
"address" : 7170808612
}
]
}
]
},
{
"threadAttributed" : true,
"callStackRootFrames" : [
...

无疑,在符号化堆栈信息之前,需要将其格式化为我们所喜闻乐见的样子:

1
2
3
4
5
6
7
8
9
10
/* 一条典型的堆栈信息表示为:
0(A) UIKitCore(B) 0x00000002(C) 0x00000001(D) + 16(E)

A:栈帧序号
B:BinaryImage,可理解为可执行文件名称。
C:instruction address:当前函数执行完成后下一条指令地址
D:symbol address:函数地址/函数名
E:C = D + Hex(E)
*/

结合手动符号化堆栈的指令 atos -arch <ARCH> -o <PATH TO DYSM> -l <hex symbolAddress> <hex instruction address> 来看,我们还需要知道符号表路径。而对于线上 App 来说,我们只需要拿到 ImageAddress 和 UUID 就可以从整个符号表中找到我们所需要的那一部分。如果将 MetricKit 的堆栈模型化,能得到下面这样的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

struct CallStackTree: Decodable {
let callStacks: [CallStack]
}

struct CallStack: Decodable {
/// 崩溃或者异常是否发生在这个调用堆栈上
let threadAttributed: Bool?
let rootFrames: [AFrame]

private enum CodingKeys: String, CodingKey {
case threadAttributed
case rootFrames = "callStackRootFrames"
}
}

struct Frame: Decodable {
/// 栈帧关联的 binary/Image/镜像 名称,如 UIKitCore、CoreFoundation
let name: String?
/// 符号化栈帧的唯一标识
let uuid: String?
/// Instruction Address(十进制)
let instructionAddress: UInt
/// The offset of the symbol in the text segment of the binary of a stack frame.
let offset: UInt
let subFrames: [Frame]?

private enum CodingKeys: String, CodingKey {
case name = "binaryName"
case uuid = "binaryUUID"
case instructionAddress = "address"
case offset = "offsetIntoBinaryTextSegment"
case subFrames
}

/// 是否是 App 自身的调用堆栈。
var isInApp: Bool {
let appImageName = (Bundle.main.infoDictionary?["CFBundleName"] as? String) ?? ""
return appImageName == name
}
}

一眼望去,我们貌似找不到 ImageAddress,这实际上是 Apple 挖的坑。通过大量的数据分析,ImageAddress 有两种情况:

  1. ImageAddress = offset
  2. ImageAddress = address - offset

故而,为了能够适配这两种情况,我们得根据相同的 BinaryImage 的两条栈帧信息去动态计算,因为同一个 BinaryImage 的 ImageAddress 肯定是相同的。同时,栈帧顺序是不固定的(有时是从早到晚,有时是从晚到早),而非一定是我们所习惯的越晚调用的函数栈帧序号越小。下面给出 Sentry 自定义事件堆栈映射示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81

protocol ImageAddressProvidable {
func imageAddress(offset: UInt, instructionAddress: UInt) -> UInt
}

struct DefaultImageAddressProvider: ImageAddressProvidable {
func imageAddress(offset: UInt, instructionAddress: UInt) -> UInt {
instructionAddress - offset
}
}

struct OffsetAsImageAddressProvider: ImageAddressProvidable {
func imageAddress(offset: UInt, instructionAddress: UInt) -> UInt { offset }
}

extension UInt { public var toHex: String { String(format: "0x%016llx", self) } }

func upload() {
// ......
var _provider: ImageAddressProvidable?
let transaction = "MetricKit Diagnostic"
let transaction = "MetricKit Diagnostic"
let event = Event(level: $0.isCrash ? .fatal : .error)
event.message = SentryMessage(formatted: $0.name)
event.transaction = transaction

var uniqueBinaryImageFrames: [CallStackTree.CallStack.Frame] = []
$0.callStackTree.callStacks.flatMap { $0.frames }.forEach { frame in
if let contained = uniqueBinaryImageFrames.first(where: { $0.name == frame.name }) {
guard _provider == nil else { return }

let defaultProvider = DefaultImageAddressProvider()
let offsetProvider = OffsetAsImageAddressProvider()
if defaultProvider.imageAddress(offset: contained.offset, address: contained.address)
== defaultProvider.imageAddress(offset: frame.offset, address: frame.address)
{
_provider = defaultProvider
} else if offsetProvider.imageAddress(offset: contained.offset, address: contained.address)
== offsetProvider.imageAddress(offset: frame.offset, address: frame.address)
{
_provider = offsetProvider
}
return
}

uniqueBinaryImageFrames.append(frame)
}

let isEnabledImageAddressProvider = _provider != nil
let provider = _provider ?? DefaultImageAddressProvider()

event.debugMeta = uniqueBinaryImageFrames.map {
let result = DebugMeta()
result.type = "apple"
result.uuid = $0.uuid
result.name = $0.name
result.imageAddress = provider.imageAddress(offset: $0.offset, address: $0.address).toHex

return result
}
event.threads = $0.callStackTree.callStacks.enumerated().map {
let result = Thread(threadId: $0.offset as NSNumber)
result.crashed = ($0.element.threadAttributed ?? false) as NSNumber
result.stacktrace = Stacktrace(
frames: $0.element.frames.reversed().map {
let result = Frame()
result.inApp = $0.isInApp as NSNumber
result.package = $0.name
result.instructionAddress = $0.instructionAddress.toHex
result.imageAddress = provider.imageAddress(offset: $0.offset, address: $0.address).toHex

return result
},
registers: [:]
)
return result
}

// ......
}

MetricKit 在诊断总负载中包含了诊断生成的开始和结束时间,同时问题负载中也有 App 的版本信息,这让我们能够更方便追踪和回归问题,也算是为不实时上报问题做出的一点弥补。

关于 OOM

当 App 自身使用的内存资源超过限制(不同设备不同系统版本阈值不同)时,它会被系统强制终止运行,即 App 因 OOM(out of memory) 触发了崩溃。此类问题无法被崩溃监控工具(Bugly 等),原因此处不再赘述。在 Debug 环境下,我们可以通过如下代码触发 OOM:

1
2
3
4
5
6
7
8
9
10
11
12
// 在 Xcode 14.2(14C18) 中
// Edit Scheme -> Launch -> 勾选 Wait for the executable to be launched 才能触发断点信息提示
// 否则 App 直接闪退,然后 Xcode 弹窗提示 App 因为内存原因被终止。
- (void)makeOutOfMemeory {
while (true) {
// 1MB
p[allocatedMB] = malloc(1024 * 1024);
memset(p[allocatedMB], 0, 1024 * 1024); // Crash: EXC_RESOURCE RESOURCE_TYPE_MEMORY(limit=xxx MB, unused=0x0)
allocatedMB += 1;
}
}

翻看mach/exception_types.h可以发现:

1
2
3
4

#define EXC_RESOURCE 11 /* Hit resource consumption limit */
/* Exact resource is in code field. */

即当 App 因 OOM 发生崩溃时,理论上 MetricKit 会生成对应的崩溃诊断报告,其对应的 exceptionType 为 11(EXC_RESOURCE)。但分析实际数据发现,当测量数据中 App 存在因为 OOM 发生的退出时,没有收集到上述类型的崩溃诊断。换言之,到目前为止,MetricKit 无法监控 OOM 引起的 App 崩溃。

写在最后

不难看出,想要更好地使用 MetricKit 核心点在于用于分析数据的平台。而如果只是使用 Xcode 的数据看板,由于众所周知的原因在国内经常会下载失败🤣。对于白嫖怪, Sentry 或许是个很不错的选择,因为它可以 Self—Hosting:买台服务器,将服务(开源)部署上去即可。回到诊断本身,相比自建轮子,MetricKit 显然更可靠、成本更低、性能更好。只要走通了前序流程,后面新特性便可以即开即用了。躺在大树下好乘凉,不外如是,嘿嘿~

Reference

Apple Documentation: MetricKit-MXCallStackTree