深入 Android 线上稳定性监控体系全链路:从异常采集 SDK 设计到 APM 性能看板的工程质量保障实践

去年年底,我们 App 的 Google Play ANR 率从 0.3% 突然跳到 1.2%,团队排查了三天才定位到是一个第三方 SDK 在特定机型上触发了主线程文件 I/O。那次之后我下了决心:内部自建一套完整的异常采集和监控体系,不再完全依赖平台的滞后数据。

异常采集 SDK 的核心设计

线上异常分两类:Java 层崩溃Native 层崩溃。两类异常的捕获机制完全不同,但都应该被 SDK 统一接管。

Java 崩溃拦截

Java 层的拦截相对简单,用 UncaughtExceptionHandler 就能兜住所有未捕获异常:

public class CrashCollector implements UncaughtExceptionHandler {
    private final UncaughtExceptionHandler originalHandler;

    public CrashCollector() {
        this.originalHandler = Thread.getDefaultUncaughtExceptionHandler();
    }

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        // 收集当前线程调用栈 + 主线程状态
        String crashData = collectStackTrace(e) 
            + collectMainThreadState();
        // 写入文件而非内存,防止二次崩溃丢失数据
        persistToFile(crashData);
        // 交还给系统默认处理器
        originalHandler.uncaughtException(t, e);
    }
}

SDK 自身的 UncaughtExceptionHandler 必须保存原始 handler 并确保最终调用——这个细节漏掉的话,SDK 会吞掉异常,App 在用户看来就是”闪退后什么都没发生”,系统 ANR/崩溃统计根本不会计入,监控覆盖直接失效。

Native 崩溃捕获

Native 层我用的是 Google 的 Breakpad,它在客户端生成 minidump 文件,体积可控、跨平台兼容性好。通过 signal handler 注册接入:

#include <client/linux/handler/exception_handler.h>

static bool dumpCallback(const MinidumpDescriptor& descriptor,
                         void* context, bool succeeded) {
    if (succeeded) {
        // 将 minidump 路径写入队列,等待 Java 层读取上报
        enqueuePendingDump(descriptor.path());
    }
    return succeeded;
}

MinidumpDescriptor descriptor("/data/data/com.yourapp/files/dumps");
ExceptionHandler eh(descriptor, nullptr, dumpCallback, nullptr, true, -1);

核心原则:Native 崩溃捕获后,信号处理函数里不做任何耗时操作。信号处理器跑在崩溃线程上下文里,任何 malloc 或文件 I/O 都可能因为堆损坏导致二次崩溃。把文件路径入队列后立刻返回,剩下的交给后台线程处理。

ANR 检测的双通道方案

ANR 监控没有标准 API,最常见的方案是主线程 Looper 埋点——在每次 Message 分发前后打时间戳:

Looper.getMainLooper().setMessageLogging(new Printer() {
    private long startTime = 0;
    
    @Override
    public void println(String x) {
        if (x.startsWith(">>>>> Dispatching")) {
            startTime = SystemClock.uptimeMillis();
        } else if (x.startsWith("<<<<< Finished")) {
            long duration = SystemClock.uptimeMillis() - startTime;
            if (duration > THRESHOLD_MS) {
                reportAnr(duration, collectMainThreadStack());
            }
        }
    }
});

这个方案只能抓到主线程卡顿超过阈值的场景,但系统 ANR 的触发条件还包括广播超时、Service 超时等。所以我在实际落地时用的是双通道策略:主线程 Looper 埋点作为实时感知层,配合 /data/anr/traces.txt 的定时采集(5.0 以上通过 ActivityManager.getProcessesInErrorState() 获取)作为兜底确认层。两者交叉验证后上报,误报率能降一个数量级。

数据上报架构设计

采集只是第一步,上报链路的可靠性决定监控质量。

我设计了三级缓冲上报模型

// 内存缓冲 → 磁盘文件 → 网络上报
class ReportPipeline {
    private final BlockingQueue<ReportData> memoryQueue;
    private final File diskCache;
    
    public void schedule() {
        // 1. 高频写入内存队列,避免磁盘 I/O 阻塞
        // 2. 每 30 秒批量刷入磁盘
        // 3. 网络可用时从磁盘读取并上报
    }
}

数据压缩不能省。一条完整的 ANR trace 可能超过 200KB,直接上报消耗用户流量且有超时风险。上报前用 GZIP 压缩,压缩比通常能到 85% 以上。

上报协议我放弃了自定义 TCP 长连接,直接用 HTTPS + Protobuf。判断依据是:

  • 大多数 App 本身就有 HTTPS 链路,复用现有 OkHttp 实例,零额外连接成本
  • Protobuf 序列化体积比 JSON 小 60% 左右,对异常数据(大量堆栈文本)的传输优势明显
  • 长连接维护成本高,移动网络切换时断连重试逻辑复杂,ROI 不划算

APM 看板中的异常聚类

服务端如果只做计数统计,这套体系就没有价值。真正有用的是异常聚类与版本归因

聚类策略我用的是堆栈指纹(Stack Fingerprint)。将崩溃堆栈的顶部 3 层方法签名拼接后做 SHA256,相同指纹的异常归为一类:

def calc_fingerprint(stacktrace):
    lines = stacktrace.strip().split('\n')
    # 取堆栈顶部 3 行(实际崩溃点)
    top_frames = [l.strip() for l in lines[:3] 
                  if l.strip() and 'at ' in l]
    signature = '|'.join(top_frames)
    return hashlib.sha256(signature.encode()).hexdigest()[:16]

这个策略看着粗暴,生产线上的准确率却相当高。唯一的盲区是混淆后的同类异常会被映射到不同指纹,需要配合服务端的混淆 mapping 反解后再跑一遍聚类。

看板设计上,我最看重的不是数据多漂亮,而是对比能力:版本维度、机型维度、系统版本维度的交叉筛选。一个典型的排查路径——看到 ANR 率曲线突然上扬 → 切到「新增异常」tab → 对比上一版本 → 定位到首次出现的指纹 → 下钻具体机型 → 排查适配问题。

实践中踩过的坑

这套体系跑了大半年,稳定性和覆盖率都符合预期,但有三个坑值得记下来。

第一个坑:文件权限与 SELinux。Android 10 以后 /data/anr/traces.txt 的读取需要 root 或系统签名,普通 App 根本读不到。可以走 ACCESSIBILITY_SERVICE 或申请 READ_LOGS 权限(需 ADB 授予),两个都不完美。我最终在用户主动触发反馈时走 DropBoxManager API 获取系统级异常记录,作为辅助数据源。

第二个坑:多进程上报冲突。App 有多进程时,每个进程都启动 SDK 的话,crash 数据文件必须按进程隔离命名,否则文件锁竞争直接导致数据丢失。

第三个坑:异常采集本身引发异常。Native 崩溃处理器里如果访问了被损坏的数据结构,进程会直接 SIGABRT。我在 SDK 初始化时预分配了缓冲区,异常处理路径上只读不写,规避 malloc 失败的风险。

衡量这套体系的三个指标

做线上监控别贪大求全,我盯三个核心指标:

  • 捕获覆盖率:SDK 拦截数 /(SDK 拦截数 + 各渠道用户反馈的遗漏异常数)。目标 > 95%。
  • 上报成功率:受网络和时机(App 即将退出)影响,首次上报很难 100%。我的基准是 72h 内至少成功上报一次。
  • 聚类收敛率:Top 3 异常指纹占总量的比例。这个值越高,线上问题越集中,修复 ROI 越高。

三个指标跑通,线上稳定性监控才算真正落地。剩下就是跟着业务迭代持续打磨,没有什么银弹。