自 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" ; animationMetrics = { scrollHitchTimeRatio = "1000 ms per s" ; } ;applicationExitMetrics = { backgroundExitData = { cumulativeNormalAppExitCount = 1 ; cumulativeAbnormalExitCount = 1 ; cumulativeAppWatchdogExitCount = 1 ; cumulativeBackgroundFetchCompletionTimeoutExitCount = 1 ; cumulativeBackgroundTaskAssertionTimeoutExitCount = 1 ; cumulativeBackgroundURLSessionCompletionTimeoutExitCount = 1 ; cumulativeBadAccessExitCount = 1 ; cumulativeCPUResourceLimitExitCount = 1 ; cumulativeIllegalInstructionExitCount = 1 ; cumulativeMemoryPressureExitCount = 1 ; cumulativeMemoryResourceLimitExitCount = 1 ; cumulativeSuspendedWithLockedFileExitCount = 1 ; } ; foregroundExitData = { cumulativeAbnormalExitCount = 1 ; cumulativeAppWatchdogExitCount = 1 ; cumulativeBadAccessExitCount = 1 ; cumulativeCPUResourceLimitExitCount = 1 ; cumulativeIllegalInstructionExitCount = 1 ; cumulativeMemoryResourceLimitExitCount = 1 ; cumulativeNormalAppExitCount = 1 ; } ; } ;
如果我们将用户共享数据给开发者视为天然采样机制的话,对于业务至上的小团队而言,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" : [ ...
无疑,在符号化堆栈信息之前,需要将其格式化为我们所喜闻乐见的样子:
结合手动符号化堆栈的指令 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 { let name: String ? let uuid: String ? let instructionAddress: UInt 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 } var isInApp: Bool { let appImageName = (Bundle .main.infoDictionary? ["CFBundleName" ] as? String ) ?? "" return appImageName == name } }
一眼望去,我们貌似找不到 ImageAddress,这实际上是 Apple 挖的坑。通过大量的数据分析,ImageAddress 有两种情况:
ImageAddress = offset
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 - (void )makeOutOfMemeory { while (true ) { p[allocatedMB] = malloc (1024 * 1024 ); memset (p[allocatedMB], 0 , 1024 * 1024 ); allocatedMB += 1 ; } }
翻看mach/exception_types.h
可以发现:
1 2 3 4 #define EXC_RESOURCE 11
即当 App 因 OOM 发生崩溃时,理论上 MetricKit 会生成对应的崩溃诊断报告,其对应的 exceptionType 为 11(EXC_RESOURCE)。但分析实际数据发现,当测量数据中 App 存在因为 OOM 发生的退出时,没有收集到上述类型的崩溃诊断。换言之,到目前为止,MetricKit 无法监控 OOM 引起的 App 崩溃。
写在最后 不难看出,想要更好地使用 MetricKit 核心点在于用于分析数据的平台。而如果只是使用 Xcode 的数据看板,由于众所周知的原因在国内经常会下载失败🤣。对于白嫖怪, Sentry 或许是个很不错的选择,因为它可以 Self—Hosting:买台服务器,将服务(开源)部署上去即可。回到诊断本身,相比自建轮子,MetricKit 显然更可靠、成本更低、性能更好。只要走通了前序流程,后面新特性便可以即开即用了。躺在大树下好乘凉,不外如是,嘿嘿~
Reference Apple Documentation: MetricKit-MXCallStackTree