前阵子有个读者在后台问我,线上用户反馈App启动后界面一直转圈、卡得不行,可自己拿真机连上 Instruments 反复跑了几十遍,启动路径都是稳稳的三百毫秒以内。这种“本地复现不了、线上一堆人骂”的情况,做过移动端质量的人应该都不陌生。后来我让他接上 MetricKit 跑了一周,第一份 payload 拉下来之后,问题立刻浮出水面:一批老设备在冷启动阶段出现持续的主线程挂起,而且退出原因里 watchdog 占比明显偏高。原因不是代码本身变慢了,而是某个版本对旧设备 Cache 写入逻辑不兼容,导致用户锁屏后再冷启时系统被拖死。
这就是 MetricKit 的价值所在。它是苹果从 iOS 13 开始内置的一套性能指标采集框架,不需要集成第三方 SDK,不需要你满代码打点,只需要注册一个订阅接口,系统自己会把启动耗时、主线程挂起、掉帧、内存峰值、CPU、网络传输、耗电相关数据,以及异常退出和崩溃诊断,统一整理成 payload 分批派发给你。本文我会从接入流程、数据模型拆解、工程落地实践,到 Signpost 自定义性能信号的进阶玩法,完整讲一遍我的使用经验。
1. MetricKit 到底解决什么问题:线上性能盲区与端侧系统级采集
1.1 传统性能监控为什么有盲区
先说我踩过的一个典型对比。以前项目里接的是第三方的 APM SDK,崩溃、卡顿、网络请求这些确实能监控到,但有几个点始终覆盖不了:
第一是系统级终止原因。App 被 watchdog 杀掉、因为内存资源超限被 jetsam 清理、后台长时间占用 CPU 被系统强制终止,这类事情发生在进程之外,应用内任何 SDK 都只能靠猜。第三方 SDK 能拿到的是崩溃日志,但“崩溃”和“被系统清理”是两码事。后者往往连异常堆栈都没有,用户感知就是“切后台回来,App 不见了”。
第二是采样开销与数据可信度。APM 工具为了拿到卡顿堆栈,通常要自己挂一个线程去监控主线程 RunLoop,采样本身就占 CPU,有些性能差的旧机型,监控代码本身反而成了拖慢 App 的元凶之一。做性能监控的人最怕的,就是监控器变成了被测对象。
第三是模拟器和真机环境差距太大。本地调试时,你的手机连着 Mac,CPU 调度、网络环境、电源状态都和用户真实使用完全不同。很多线上问题只在“锁屏后”“后台恢复时”“弱网下”出现,这些场景靠常规手段根本复现不出来。
MetricKit 不一样,它跑在系统层面。系统在 App 运行的时候以极低的开销记录聚合指标,不打扰 App 自身,然后选择合适时机把数据打包给你。它能拿到的信息,包括 App 自己根本没有权限观测的启动耗时、具体退出原因、调用栈信息,因为它本身就是操作系统的一部分。
1.2 MetricKit 的采集边界与隐私设计哲学
想要把 MetricKit 用好,首先得理解它的设计出发点。苹果对它的定位不是“用户级排障工具”,而是“版本质量体检工具”。
系统收集指标时做了大量匿名化、聚合化处理。拿网络传输指标来说,系统只告诉你蜂窝网络和 WiFi 下分别用了多少流量,不会暴露用户访问的具体域名、IP、应用层协议内容。拿定位指标来说,只给不同精度档位的累计时长,不会暴露用户去过哪里。拿启动指标来说,给的是一个直方图分布,而不是某次启动的精确时间戳。所以你在 MetricKit 里永远看不到“某个具体用户今天 8 点 05 分启动慢了 2 秒”这种数据。
这是它的隐私设计哲学:宁可模糊,不可泄露。这意味着 MetricKit 不适合用来排查某个用户的具体问题,但非常适合用来回答“这个版本发布之后,整体性能质量是恶化还是改善了”。它的数据以天为单位形成 payload,通常在设备充电、空闲、有网络连接时派发,默认情况下用户不需要做任何操作。
1.3 你该在什么阶段接入
我的建议是:App 一旦进入稳定功能迭代阶段,就应该接上 MetricKit,不要等真的出了线上事故再补。
原因有两个。一是 MetricKit 需要建立基线。一个版本接入了,如果它自身的性能就有问题,后面收到的数据里你分不清是版本引入的劣化还是本来就存在的历史问题。先从某个版本开始,连续收集两到三周,拿到当前代码基线的 P50、P95 指标,之后每次发版就能直接做版本对比。
二是 MetricKit 的数据接收是单向的、延迟的。它不是实时通道,今天接上,最快明天早上才能拿到昨天的数据。如果等事故发生了才接入,等你看到数据时,出事的那批用户可能已经升级到了下一个版本,现场早就过了“保鲜期”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最小接入链路:注册、代理回调与首次拿到数据
2.1 注册时序:在启动入口订阅
MetricKit 的接入方式非常简单,你只需要做两件事:向 MXMetricManager 注册订阅者,实现 MXMetricManagerSubscriber 协议的回调方法。
注册时机建议放在 application(_:didFinishLaunchingWithOptions:) 的最前面,越早越好。系统在派发 payload 时,如果你的订阅者还没有注册,那这份数据就可能被跳过。
swift复制import MetricKit
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
subscribeMetricKit()
return true
}
private func subscribeMetricKit() {
MXMetricManager.shared.addSubscriber(self)
}
}
注意 MXMetricManager 是单例,addSubscriber(_:) 可以多次调用,但同一个对象重复添加只会注册一次。你可以在 AppDelegate 里实现协议,也可以建一个专门的 PerformanceMonitor 单例来承接所有 MetricKit 逻辑,我比较推荐后者,职责清晰,后续扩展 Signpost 和上传逻辑时也好维护。
2.2 代理回调与数据类型区分
实现协议时,你会看到两个同名方法,它们参数类型不同,一个收指标数据 MXMetricPayload,一个收诊断数据 MXDiagnosticPayload:
swift复制extension AppDelegate: MXMetricManagerSubscriber {
// iOS 13+:每日性能指标
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// 这里拿到的是聚合指标
}
}
// iOS 14+:诊断数据
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
// 这里拿到的是崩溃、卡顿、掉帧等诊断信息
}
}
}
这两个方法区分得很清楚。MXMetricPayload 是每日统计量,包含了启动耗时分布、内存峰值、CPU 使用量这种“汇总数据”。MXDiagnosticPayload 是事件级数据,比如某次崩溃的调用栈、某次卡顿持续了多长时间。如果 App 支持 iOS 13,需要把诊断方法用 @available(iOS 14.0, *) 标记一下。
另外,从 iOS 14 开始,MXMetricManager 还提供了 pastPayloads() 和 pastDiagnosticPayloads(),可以主动拉取最近一段时间的历史数据,这个后面专门说。
2.3 首次收到数据的真实等待时间
很多人接入后最困惑的问题就是:为什么一天了啥也没收到?
我先给个预期:从注册订阅到收到第一份 payload,通常需要 24 到 48 小时。系统并非常驻实时上报,而是把 App 运行期间记录的数据暂存在端侧,等设备进入充电、锁屏、连接 WiFi 的状态后,再批量派发给 App。如果你的设备白天一直插着电在调试,数据可能当晚就能派发;如果设备常年不在充电状态,那数据派发就会相应延迟。
所以,验证接入是否成功,最简单的做法是:
- 用真机运行 App,至少跑 20 到 30 分钟,期间随便操作一下界面。
- 锁屏,接上电源,连上 WiFi。
- 等一晚上。
- 第二天打开 App,在
didReceive方法里打日志,或者把收到的 payload 存到本地文件里查看。
我实测下来,只要用户没有关闭系统中的“分析与改进”相关开关,基本第二天都会收到第一份数据。
2.4 从 pastPayloads 里抢救刚过去的数据
如果你的 App 不是 7x24 小时前台运行,didReceive 被调用的时机可能在 App 启动后不久,此时系统会主动把已生成的过去数据推给你。但如果你因为某些原因错过了回调,或者 App 在收到回调前就进程被杀,那也不用担心,pastPayloads() 和 pastDiagnosticPayloads() 可以帮你主动拉取。
swift复制if #available(iOS 14.0, *) {
let pastMetrics = MXMetricManager.shared.pastPayloads()
let pastDiagnostics = MXMetricManager.shared.pastDiagnosticPayloads()
for payload in pastMetrics {
handleMetricPayload(payload)
}
for payload in pastDiagnostics {
handleDiagnosticPayload(payload)
}
}
这个方法非常适合放在 App 冷启动时调用一次,相当于给 MetricKit 的数据接收加了一道“补拉”保险。但需要注意,这些历史 payload 是有保质期的,苹果文档里说系统只会保留较短时间,实际经验中大致是一周左右,超过期限的旧数据会被系统清理掉。所以补拉代码要尽快执行,而且只做一次,避免每次启动都重复处理同样的 payload。
3. 数据模型拆解:一个 payload 里到底藏着什么
3.1 指标类数据:从启动耗时到退出原因
我第一次完整打印 MXMetricPayload 的 JSON 时,确实被它的信息量惊到了。我把指标类数据整理成了表格,方便你对照着看:
| 数据字段 | 含义 | 主要内部指标 |
|---|---|---|
applicationLaunchMetrics |
App 冷启动 / 热启动耗时 | histogrammedTimeToFirstDrawKey |
applicationResponsivenessMetrics |
主线程挂起时长分布 | histogrammedApplicationHangTime |
applicationTimeMetrics |
前后台累计时间 | cumulativeForegroundTime、cumulativeBackgroundTime、cumulativeBackgroundAudioTime、cumulativeBackgroundLocationTime |
applicationCPUUsageMetric |
CPU 累计使用时间 | cumulativeCPUTime |
applicationMemoryMetrics |
峰值内存 | peakMemoryUsage |
applicationTerminationMetrics |
前后台退出原因统计 | foregroundExitData、backgroundExitData |
displayMetrics |
掉帧 / 滚动卡顿 | histogrammedApplicationHitchTimeRatio、histogrammedScrollHitchTimeRatio |
animationMetrics |
动画时长分布 | histogrammedAnimationDuration |
networkTransferMetrics |
蜂窝 / WiFi 上下行流量 | cumulativeCellularDownload、cumulativeCellularUpload、cumulativeWifiDownload、cumulativeWifiUpload |
locationActivityMetrics |
不同精度的定位累计时长 | cumulativeBestAccuracyTime、cumulativeHundredMetersAccuracyTime 等 |
audioSessionMetrics |
音频会话中断与媒体服务重启 | audioSessionInterruptionDuration 等 |
这里我重点说一下几个“含金量”最高的字段。
启动耗时:applicationLaunchMetrics 里的 histogrammedTimeToFirstDrawKey 是一个字典,key 是时间区间,value 是对应的启动次数。它表示的不是某一次启动的精确耗时,而是一个直方图分布。通过这个分布,你可以算 P50、P95,判断大多数用户启动是否流畅。
退出原因:applicationTerminationMetrics 是 MetricKit 独有的“王牌指标”,分为前台退出和后台退出两组。里面包含 cumulativeNormalAppExitCount(正常退出)、cumulativeMemoryResourceLimitExitCount(内存触发限额)、cumulativeBadAccessExitCount(野指针)、cumulativeAbnormalExitCount(异常退出)、cumulativeWatchdogExitCount(看门狗超时)、cumulativeCPUResourceLimitExitCount(CPU 超限)等。这些字段我第一次看的时候特别兴奋,因为它回答了很多 APM 工具回答不了的问题——“App 到底是怎么没的”。
掉帧数据:displayMetrics 里的 histogrammedScrollHitchTimeRatio 表示滚动过程中发生卡顿的时间占比。如果这个值偏高,说明列表滑动体验差,往往和主线程在做耗时任务有关,可以和 applicationResponsivenessMetrics 里的主线程挂起时长分布互相佐证。
3.2 诊断类数据:系统级异常与调用栈
MXDiagnosticPayload 是 iOS 14 引入的,它提供的不是统计量,而是“事件详情”。同样列个表:
| 诊断字段 | 含义 | 关键内容 |
|---|---|---|
crashDiagnostics |
崩溃诊断 | terminationReason、exceptionType、signal、exceptionCode、virtualMemoryRegionInfo |
hangDiagnostics |
主线程卡死诊断(iOS 15+) | hangDuration |
hitchDiagnostics |
卡顿诊断 | hitchDuration、hitchTimeRatio |
diskWriteDiagnostics |
磁盘写入异常诊断 | totalWritesCaused |
cpuExceptionDiagnostics |
CPU 资源超限诊断(iOS 15+) | totalCPUTime、callStackTree |
appLaunchDiagnostics |
启动异常诊断(iOS 16+) | 启动卡顿相关调用栈 |
诊断数据最大价值在于:它给了你系统视角的调用栈。比如 MXCrashDiagnostic 的 callStackTree 里会带上系统内核判断的崩溃路径,virtualMemoryRegionInfo 会告诉你崩溃地址对应的虚拟内存区域。这些信息在崩溃日志里常常被忽略,但结合代码,往往能帮你定位到某类内存踩踏问题。
但要注意,MetricKit 给的是“诊断摘要”,不是完整可符号化的崩溃日志。想拿到完整的堆栈符号和登录链路,还是要和 Crashlytics 这类工具配合。这一点后面延展。
3.3 自定义 Signpost 指标(iOS 15+)
从 iOS 15 开始,MetricKit 支持读取你的自定义 Signpost 数据。这是个非常实用的能力,相当于给 MetricKit 装了一个“自定义埋点通道”。
你的业务代码里可以用 os_signpost 标记一段耗时操作,例如首页数据加载、图片解码、某一笔网络请求。系统会把这段操作的时间区间聚合到 MetricKit 的 payload 里,下发后你就可以在 signpostMetrics 中读取每个自定义指标的平均耗时、耗时分布和最大耗时。
具体用法:
swift复制import os.signpost
// 定义统一的 OSLog,建议放在全局常量
let performanceLog = OSLog(
subsystem: "com.example.yourapp",
category: "Performance"
)
// 标记开始
let signpostID = OSSignpostID(log: performanceLog)
os_signpost(.begin, log: performanceLog, name: "FeedLoad", signpostID: signpostID)
// 中间的耗时操作
// 标记结束
os_signpost(.end, log: performanceLog, name: "FeedLoad", signpostID: signpostID)
在 didReceive 中读取:
swift复制for signpostMetric in payload.signpostMetrics {
guard let name = signpostMetric.signpostName,
let intervalData = signpostMetric.signpostIntervalData else {
continue
}
print("自定义指标:\(name),平均耗时:\(intervalData.duration.average)")
}
这里有个小坑:signpostName 对应的是 os_signpost 的 name 参数。这个 name 建议用常量字符串,不要拼接动态内容,否则系统会为每一个不同的 name 单独建立一条指标,数据量会膨胀得很快。
3.4 版本兼容与字段命名差异
MetricKit 从 iOS 13 到 iOS 16,每个版本都在增加新字段,但也改过一些字段的嵌套结构。我遇到过最典型的情况是:iOS 14 的 jsonRepresentation() 里某个 key 是 _applicationTimeMetrics 开头的内部表示,到 iOS 15 又变成了另一套。
所以我的建议是:序列化时先保存原始 JSON,再做字段映射,不要直接拿着字典写死解析逻辑。原始 JSON 可以原样存档到后端,既能应对未来版本字段变化,也能在排查问题时回放。
4. 数据落地工程实践:从 JSON 到后端可查询
4.1 序列化与字段映射
MXMetricPayload 和 MXDiagnosticPayload 都提供了 jsonRepresentation(),可以获取 JSON 格式的 Data。你可以直接用 JSONSerialization 转成字典:
swift复制func handleMetricPayload(_ payload: MXMetricPayload) {
do {
let jsonData = try payload.jsonRepresentation()
let jsonObject = try JSONSerialization.jsonObject(with: jsonData)
// 方案一:原样保存
// backendLogger.uploadRawData(jsonData)
// 方案二:转为字典,提取核心字段后再上传
if let dict = jsonObject as? [String: Any] {
let coreFields = extractCoreFields(from: dict)
backendLogger.uploadCoreFields(coreFields, meta: meta)
}
} catch {
// 处理序列化失败
}
}
我的实际做法是“双写”:原始 JSON 原样存一份,便于日后回溯;核心指标抽出来建索引,便于快速查询和告警。核心指标至少包括:App 版本、iOS 版本、设备型号、时间戳、启动耗时分布、主线程挂起分布、内存峰值、退出原因统计、掉帧分布。
4.2 上传策略:本地缓存、重试与去重
MetricKit 的数据派发机制有一个重要特点:每一份 payload 只会被派发给 App 一次。如果你在 didReceive 里处理失败,之后系统不会再补发同一份数据。所以上传逻辑必须做到“先落盘,再上传”。
我的推荐流程:
- 收到 payload 后,先把 JSON 写入本地缓存目录。
- 启动一个后台任务,把缓存文件上传到你的后端。
- 上传成功后才删除本地文件。
- 如果上传失败,文件保留,等下次启动时重试。
swift复制private func cachePayloadData(_ data: Data) {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let fileName = "metrickit_\(Date().timeIntervalSince1970).json"
let fileURL = directory.appendingPathComponent(fileName)
try? data.write(to: fileURL)
}
private func uploadPendingPayloads() {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
for file in files where file.pathExtension == "json" {
// 上传并成功后删除
}
}
如果你使用后台上传任务,要记得把 MetricKit 的数据量估算进后台任务的执行时间里。一份每日 payload 通常几十 KB,诊断 payload 会大一些,但整体来说都不会太大,控制在几 MB 以内。只要网络正常,几秒钟就能传完。
4.3 后端表现层的查询维度
数据上了后端之后,我建议按照这几个维度做查询和展示:
- 按 App 版本聚合:对比 v1.0 和 v1.1 的启动耗时、掉帧率、退出原因占比,这是发布质量的核心指标。
- 按 iOS 大版本聚合:同一个 App 在 iOS 15 和 iOS 16 上的表现很可能差异巨大。
- 按设备档次聚合:将设备按芯片代际、内存容量分档,能快速发现低端机上的性能恶化。
- 按日期看趋势:观察某个版本发布后一周内的指标变化,看是否有突发上涨。
我用得最多的是“版本 + 日期”双维度趋势图,可以迅速定位一个版本是否引入了性能回归。举个实际例子,某个版本发布后 cumulativeWatchdogExitCount 明显上涨,但崩溃率没有变化,很多人会忽略这个信号。后来核对代码发现,是某个后台任务改了并发锁的持有方式,导致主线程长时间等待,系统判定主线程无响应触发 watchdog。这正是 MetricKit 能发现、而传统崩溃监控发现不了的问题。
4.4 用 Xcode Organizer 交叉验证
如果你是通过 TestFlight 或 App Store 发布的应用,Xcode 的 Organizer 窗口里会多出一个 “Metrics” 标签页。它会展示已授权匿名分享数据的用户的性能统计数据,包括启动耗时、卡顿率、内存使用、耗电等。
这个免费能力很适合做交叉验证。你可以拿自己后端的数据曲线和 Organizer 里的曲线对比,如果趋势一致,说明你的上传链路没有丢数据;如果明显不一致,那就要检查一下是不是派发机制或上传逻辑出了问题。
5. 我踩过的坑位清单(附排查路径)
5.1 模拟器收不到,真机也收不到,先查网络与时间
我见过不少人在模拟器上运行,等了一整天只得到一个空数组。这里明确:MetricKit 在模拟器上不受支持,必须在真机上运行。
如果真机也收不到,按以下顺序排查:
- 检查系统版本。指标回调 iOS 13+,诊断回调 iOS 14+,系统太老没有。
- 检查设备网络。数据派发依赖网络连接,而且在蜂窝网络下系统通常会更保守,建议先连 WiFi。
- 检查设备时间。如果设备时间被手动调乱,系统可能认为还没到派发时机,数据会一直压在端侧。
- 检查“分析与改进”开关。设置 -> 隐私与安全 -> 分析与改进,确保“共享 iPhone 分析”和“增强诊断数据”没有关闭。
这里多说一句:用户在系统设置里关掉共享分析开关后,MetricKit 数据会受到直接影响,这是苹果的设计。你无法绕过用户的隐私选择,所以线上数据覆盖率天然不会到 100%,这是正常现象,在做聚合分析时要有心理预期。
5.2 didReceive 没触发,但你确实开了开关
有些场景下开关都正常,代码也注册了,但 didReceive 一直没被调用。我排查过多次,最后定位到两种原因:
第一种是注册时机太晚。如果你的 App 在启动过程中发生异常崩溃,可能根本没走到 didFinishLaunching 后面的注册代码,那自然收不到。解决办法是把 MXMetricManager.shared.addSubscriber 尽量前置,甚至可以放在 init 方法里,或者做成一个专门在极早期初始化的单例。
第二种是数据和诊断的回调混在一起时只处理了其中一个。有些人实现了 didReceive(_ payloads: [MXMetricPayload]),但诊断数据包也有自己的回调,两个方法都要实现。如果只实现指标回调,诊断数据不会通过指标回调给你。
还有一种容易被忽略的情况:MXMetricManagerSubscriber 是弱引用的。如果你把订阅者对象临时创建后马上释放,系统就没法调用了。所以订阅者对象必须是常驻的,最好挂在一个存活周期等于 App 生命周期的对象上。
5.3 jsonRepresentation 在系统版本间 key 不一致
这是后端接入时最常见的问题。不同 iOS 版本返回的 JSON 字段名不完全一致,甚至同一个字段在不同主版本下嵌套层级都不同。
我在 iOS 14 设备上收到的正常 JSON,到 iOS 15 设备上会有个别 key 前缀变成以 _ 开头,或者某些原本作为字符串的字段变成了字典。苹果在系统版本更新时调整数据结构不是一次两次了。
所以后端解析时,不要依赖一个刚性字段表。最好的方式是把 payload 的原始 JSON 存储为 jsonb 或 text 字段,同时提取一小部分你真正关心的字段做索引。这样即使字段变更,原始数据还在,可以事后重新解析,不会造成数据丢失。
5.4 诊断数据缺失、旧数据只有 8 天保质期
pastDiagnosticPayloads() 虽然能补拉数据,但它拿到的历史数据窗口很短。苹果文档说得很含蓄,只说“limited time”,我的实测是大概一周左右,超过这个时间,系统会把旧数据丢弃。所以“先缓存在本地、过几天再上传”的方案在 MetricKit 上是不可行的,数据必须在派发给你的第一时间就处理并上传。
另外,诊断数据不是每个用户每天都会产生。只有发生了崩溃、卡死、掉帧等异常事件,才会生成对应的诊断 payload。如果你的测试机当天没有产生任何异常事件,那 didReceive(_ payloads: [MXDiagnosticPayload]) 收到空数组甚至不回调,都是正常的。
5.5 不要把崩溃诊断当完整堆栈
MXCrashDiagnostic 的 callStackTree 和系统崩溃日志里的完整线程堆栈不是一回事。它更侧重于提供崩溃路径的“摘要”,包括终止原因、异常类型、信号值、虚拟内存区域信息。能帮你确认崩溃方向,但要做函数级符号化定位,还是得依赖 Crashlytics 这类专门做崩溃捕获的 SDK 提供的完整堆栈。
我的组合方式是:MetricKit 负责系统级信号和批量趋势,Crashlytics 负责单次崩溃的完整上下文和用户路径。两者结合,基本能覆盖“这个版本到底行不行”和“这个用户到底碰到了什么”这两个问题。
6. 进阶:结合 Signpost 与后端告警,把 MetricKit 变成版本体检工具
6.1 自定义性能信号的使用
前文已经演示了 os_signpost 的基本写法。这里补充一个实际场景:监控首页首屏的关键路径耗时。
假设你的首页需要依次完成网络请求、JSON 解析、图片解码、布局渲染四件事,你可以分别给这四个阶段打上 Signpost:
swift复制let startID = OSSignpostID(log: performanceLog)
os_signpost(.begin, log: performanceLog, name: "HomeFetchData", signpostID: startID)
// 网络请求
fetchData { ... os_signpost(.end, log: performanceLog, name: "HomeFetchData", signpostID: startID) }
os_signpost(.begin, log: performanceLog, name: "HomeDecodeJSON", signpostID: startID)
// JSON 解析
os_signpost(.end, log: performanceLog, name: "HomeDecodeJSON", signpostID: startID)
系统会把同名的 Signpost 做一个跨设备、跨版本的聚合统计。第二天你收到 payload 后,在 signpostMetrics 里就能看到 HomeFetchData 的平均耗时、总调用次数、单次最大耗时,非常方便。
为了便于后端分析,我建议给 Signpost 名称加一个前缀,比如 Home_、Feed_、Image_,这样在服务端筛选时可以直接按前缀分组。
6.2 与 Crashlytics 等 APM 的分工
MetricKit 适合回答“整体质量如何”,APM 适合回答“具体问题在哪里”。我在项目里的分工方式是:
| 能力 | MetricKit | 第三方 APM |
|---|---|---|
| 性能趋势聚合 | 强,系统级自动采集 | 中,依赖自身采样 |
| 单用户实时追踪 | 弱,明显有延迟 | 强,可全链路追踪 |
| 系统退出原因 | 强,系统视角 | 弱,通常只能看到崩溃 |
| 自定义埋点 | 一般,需要 Signpost | 强,自由埋点 |
| 崩溃完整堆栈 | 弱,只有摘要 | 强,可符号化 |
| 采样开销 | 极低 | 相对较高 |
两者不是替代关系,是互补关系。MetricKit 负责“体检”,APM 负责“专项检查”。
6.3 阈值与版本对比的落地建议
怎么把 MetricKit 数据真正用起来?我建议先不要上来就做告警,而是先用两周时间收集基线。基线稳定后,按以下方式配置告警:
- 启动首帧耗时:P50 超过 1.2 秒,或 P95 超过 2.5 秒
- 主线程挂起次数占比:单日挂起超过 50ms 的事件占比超过 5%
- 滚动掉帧比例:hitch time ratio 超过 1%
- 峰值内存:P95 超过 600MB(视 App 复杂度调整)
- 异常退出占比:
cumulativeBadAccessExitCount或cumulativeWatchdogExitCount单日涨幅超过 100%
这些阈值没有绝对标准,需要结合你自己 App 的类型(工具类还是游戏类)、目标机型、用户量级来调。但有一条原则可以通用:先看 P50,再看 P95,最后看单日涨幅。P50 反映的是大部分用户的体验,P95 反映的是劣化边界,单日涨幅则是“是不是昨天发版出了问题”的最快信号。
另外,建议把 MetricKit 的版本号和 App 的版本号分开打标记,因为 MetricKit 框架本身没有提供专门的版本字段,只是 iOS 系统内部的一部分。后端分析时统一用 App 构建版本号来归类,确保跨版本对比准确。
在真正把一套 MetricKit 数据链路跑通之后,你会发现最重要的其实不是那几个 API 调用,而是改变了对线上质量的定义方式。以前你可能只会问“崩溃率是多少”,现在你会问“启动慢了多少”“是什么原因让系统杀了我的 App”“主线程在哪里挂了”。MetricKit 把这些问题从“猜”变成了“可量化”,这是我接入它之后体会最深的一点。
