第26章:线上监控:帧率采集、卡顿堆栈聚合与实时告警体系
线上监控这件事,说实话,是很多团队容易忽视的环节。
我见过不少项目,开发阶段跑得挺顺,一上线就各种卡顿。为什么?因为线下测试环境太干净了。用户手机上有几百个App在后台跑,系统版本五花八门,硬件配置参差不齐。你想想看,这种环境下,你的App能不能稳住60帧?
所以这一章,我们来聊聊线上怎么监控帧率、怎么抓卡顿堆栈、怎么把告警体系搭起来。我把自己踩过的坑和沉淀下来的方案,都摊开来讲。
帧率采集:不止是FPS那么简单
很多人一提到帧率监控,第一反应就是拿Choreographer.FrameCallback算FPS。嗯,这个思路没错,但线上环境光算FPS是不够的。
为什么?因为FPS是一个平均值。假设你1秒内前500ms卡了3帧,后500ms流畅跑了57帧,平均下来还是60帧。但用户感受到的,是那半秒钟的明显卡顿。
我个人习惯的做法是:同时采集FPS和掉帧数。掉帧数比FPS更能反映真实体验。
核心指标定义
- FPS:每秒实际渲染帧数,理想值60
- 掉帧数:单帧绘制耗时超过16.67ms的次数
- 卡顿率:掉帧帧数 / 总帧数 × 100%
- 严重卡顿:单帧耗时超过100ms
我在项目中遇到过一个问题:线上采集到的FPS看起来还行,但用户反馈说「滑动不跟手」。后来一查,发现是掉帧分布不均匀——大部分掉帧集中在列表快速滑动时。所以我又加了一个指标:连续掉帧次数。连续掉帧超过3帧,基本就是一次明显的卡顿事件。
采集方案上,我推荐用Matrix的帧率采集模块。它底层也是基于Choreographer,但做了很多优化:
// Matrix 帧率采集核心逻辑(简化版)
public class FrameTracer implements Choreographer.FrameCallback {
private long mLastFrameTimeNanos;
private int mDroppedFrames;
private static final long FRAME_INTERVAL_NANOS = 16666666; // 16.67ms
@Override
public void doFrame(long frameTimeNanos) {
if (mLastFrameTimeNanos == 0) {
mLastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
return;
}
long diff = frameTimeNanos - mLastFrameTimeNanos;
int dropped = (int) (diff / FRAME_INTERVAL_NANOS) - 1;
if (dropped > 0) {
mDroppedFrames += dropped;
// 上报掉帧事件
reportDropFrame(dropped, frameTimeNanos);
}
mLastFrameTimeNanos = frameTimeNanos;
Choreographer.getInstance().postFrameCallback(this);
}
}
小技巧:线上采集时,不要把每一帧的数据都上报。那样流量和性能开销都扛不住。我一般设置一个阈值——只有掉帧数超过3帧才触发上报。这样数据量能减少90%以上,而且抓到的都是真正有问题的帧。
卡顿堆栈聚合:从海量数据里捞金子
帧率数据只能告诉你「卡了」,但没法告诉你「为什么卡」。要定位根因,必须拿到卡顿时的堆栈信息。
这里有个难点:线上环境,你不能每帧都dump堆栈。那样App直接卡死。所以我们需要一种轻量级的采样策略。
我推荐的做法是:主线程Looper监控 + 堆栈采样。说白了,就是监听主线程的Looper,当一条消息执行时间超过阈值(比如200ms),就认为发生了卡顿,然后dump当前主线程的堆栈。
BlockCanary就是基于这个思路做的。但线上用BlockCanary有个问题——它每次卡顿都dump堆栈,如果卡顿频繁,堆栈数据量会爆炸。而且堆栈信息是文本,上传和存储成本都很高。
所以我们需要做堆栈聚合。把相似的堆栈合并成一条记录,只记录出现次数和首次/最近出现时间。
堆栈聚合的核心逻辑
- 对堆栈字符串做MD5或SHA1哈希,作为唯一标识
- 相同哈希的堆栈,合并计数+1
- 记录首次出现时间和最近出现时间
- 定期(比如每5分钟)将聚合后的数据上报
我曾经踩过一个坑:堆栈哈希碰撞。两个不同的堆栈,哈希值居然一样。后来我改用「堆栈前10层的方法名+行号」作为聚合key,碰撞概率就低多了。
// 堆栈聚合示例
public class StackAggregator {
private ConcurrentHashMap<String, StackTraceInfo> mStackMap;
public void onStackCaptured(StackTraceElement[] stackTrace) {
String key = buildAggregateKey(stackTrace);
StackTraceInfo info = mStackMap.get(key);
if (info == null) {
info = new StackTraceInfo(key, stackTrace);
mStackMap.put(key, info);
}
info.incrementCount();
}
private String buildAggregateKey(StackTraceElement[] stack) {
StringBuilder sb = new StringBuilder();
// 取前10层作为聚合key
int depth = Math.min(10, stack.length);
for (int i = 0; i < depth; i++) {
sb.append(stack[i].getClassName())
.append(".")
.append(stack[i].getMethodName())
.append(":")
.append(stack[i].getLineNumber())
.append("\n");
}
return sb.toString();
}
}
注意:堆栈采样本身也有性能开销。我建议采样频率不要太高——卡顿阈值设置在200ms以上,采样间隔至少1秒。另外,堆栈数据上报前一定要压缩,gzip压缩后体积能减少70%以上。
实时告警体系:别等用户骂了才知道
数据采集了,堆栈聚合了,然后呢?如果没人看,等于白做。
实时告警体系的作用,就是在问题刚冒头的时候,第一时间通知到对应的人。我见过太多团队,线上监控数据跑了大半年,一次告警都没配过。直到用户投诉到应用商店,才发现问题。
一个完整的告警体系,我觉得至少包含三层:
| 告警级别 | 触发条件 | 通知方式 | 响应时间 |
|---|---|---|---|
| P0(严重) | 卡顿率 > 5% 持续5分钟 | 电话 + 短信 + 群@所有人 | 15分钟内 |
| P1(重要) | 卡顿率 > 2% 持续10分钟 | 群消息 + 邮件 | 1小时内 |
| P2(一般) | 新版本卡顿率比上一版本上升50% | 日报邮件 | 24小时内 |
告警阈值怎么定?我的经验是:不要拍脑袋。先跑两周数据,看看正常波动范围是多少。比如你的App平时卡顿率在0.5%~1%之间波动,那阈值设在2%就比较合理。设得太低,告警轰炸;设得太高,漏报。
另外,告警一定要带上上下文信息。光说「卡顿率超标了」没用,得告诉开发人员:哪个版本?哪个页面?哪个机型?哪个网络环境?我习惯在告警消息里附上Top5的卡顿堆栈,这样值班的人一眼就能看出问题在哪。
避坑指南:我曾经把告警阈值设得太敏感,结果半夜三点被电话吵醒,一看是某个低端机型的卡顿率飙到了8%。后来一查,是那个机型系统版本有bug,跟我们的代码没关系。从那以后,我加了一个「机型过滤」——如果卡顿集中在某个特定机型,先标记为「已知问题」,不触发P0告警。
整体架构:一张图说清楚
上面讲了帧率采集、堆栈聚合、告警体系三个模块。它们不是孤立的,需要串联成一个完整的链路。我画了一张架构图,你看完应该就清楚了:
这张图里,我特别想强调反馈闭环这个环节。很多团队只做到了「发现问题→告警」,但没做到「修复后验证」。我建议每次修复卡顿问题后,都要在线上观察至少3天,确认卡顿率确实降下来了,才能关闭告警。
落地建议:从0到1怎么搭
如果你团队现在还没有线上卡顿监控,我建议分三步走:
- 第一步:先接入帧率采集,只采集FPS和掉帧数,不上报堆栈。跑一周看看数据量,确认服务端能扛住。
- 第二步:加上堆栈采样,但只对掉帧超过5帧的卡顿事件采样。堆栈做聚合后再上报。
- 第三步:配置告警规则,从P2开始配,逐步收紧到P0。别一上来就搞电话告警,先让团队适应一下。
最后提醒一句:线上监控是一把双刃剑。采集太细,App性能反而会下降;采集太粗,又抓不到问题。我个人的经验是:线上采集的性能开销,控制在App总CPU占用的1%以内。超过这个数,用户就能感知到了。
好了,这一章的内容就这些。帧率采集、堆栈聚合、告警体系,这三块搭好了,你的App线上卡顿问题基本就能做到「发现即定位,定位即修复」。下一章我们聊聊更进阶的话题——如何用工具链自动化分析卡顿根因。