4. Android 崩溃日志采集:Java 与 Native 崩溃捕获、Breakpad 集成、ANR 日志采集
稳定可靠地捕获崩溃日志是归因与修复的第一步。Android 平台同时运行 Java 虚拟机与 Native 代码,外加独立的 ANR(Application Not Responding)机制,三者需要不同的采集策略。本章依次讲解 Java 崩溃捕获、Native 崩溃捕获(通过 Google Breakpad 库)以及 ANR 日志采集,覆盖原理、集成步骤、代码实现、调试方法与最佳实践。
4.1 Java 崩溃捕获原理与实现
Android 应用层 Java 代码抛出而未捕获的 Exception 会导致进程终止。系统提供 Thread.setDefaultUncaughtExceptionHandler() 接口,允许设置全局异常处理器。当主线程或子线程抛出未捕获异常时,回调该 Handler,此时可收集堆栈、环境信息,然后写入本地文件,再通过 android.os.Process.killProcess() 结束进程。
实现流程
- 在
Application.onCreate()或最早执行点设置全局 Handler。 - 在
uncaughtException(Thread t, Throwable e)中获取异常栈Log.getStackTraceString(e)。 - 收集设备信息(Build, PackageInfo, 内存状态, 已加载线程快照)。
- 将日志写入指定文件(使用 JSON 格式或自定义格式)。
- 确保写入完成,然后终止进程(避免多次触发)。
public class CrashHandler implements Thread.UncaughtExceptionHandler {
private static final String CRASH_DIR = Environment.getExternalStorageDirectory().getAbsolutePath()
+ "/crash_logs/";
private final Thread.UncaughtExceptionHandler defaultHandler;
public CrashHandler() {
defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
Thread.setDefaultUncaughtExceptionHandler(this);
}
@Override
public void uncaughtException(Thread t, Throwable e) {
try {
File dir = new File(CRASH_DIR);
if (!dir.exists()) dir.mkdirs();
String fileName = "crash_" + System.currentTimeMillis() + ".txt";
FileOutputStream fos = new FileOutputStream(new File(dir, fileName));
OutputStreamWriter writer = new OutputStreamWriter(fos, "UTF-8");
writer.write("Time: " + new Date().toString() + "\n");
writer.write("Thread: " + t.getName() + "\n");
writer.write(Log.getStackTraceString(e));
writer.write("\nDevice: " + Build.MODEL + " " + Build.VERSION.SDK_INT);
writer.flush();
writer.close();
} catch (Exception ignored) {
} finally {
// 默认终止进程
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(1);
}
}
}
调试建议: 在开发阶段可在 Handler 内先打印日志到 Logcat,同时保留
defaultHandler 链式调用(比如调用 defaultHandler.uncaughtException()),方便与系统默认行为对比。常见错误与避坑
- 写入文件耗时阻塞进程:崩溃回调运行在最后一条线程上下文中,大日志写入可能被系统 kill 前未写完。建议使用
AtomicBoolean确保只执行一次,写入完成后立即 kill。 - 多次触发:如果 Handler 内部再次抛异常,会陷入无限递归。需要用 try-catch 包裹全部逻辑。
- 多线程场景:子线程异常也会调用全局 Handler,需区分线程名,避免重复创建目录。
- 权限问题:Android 10+ 对
Environment.getExternalStorageDirectory()访问受限,建议使用getFilesDir()或getCacheDir()。
4.2 Native 崩溃捕获与 Breakpad 集成
Native 代码(C/C++)崩溃由信号(SIGSEGV, SIGABRT 等)触发,无法通过 Java 层面捕获。Google Breakpad 是成熟的跨平台崩溃捕获框架:它注册信号处理器,在异常发生时生成包含可执行映像、完整寄存器上下文以及线程栈的 minidump 文件,后期用 minidump_stackwalk 工具解析为可读堆栈。
集成步骤(Android NDK + CMake)
- 将 Google Breakpad 源代码(或预编译 so)加入项目。
- 在 CMakeLists.txt 中添加 breakpad_client 库依赖。
- 在应用 Native 侧(JNI)初始化
google_breakpad::ExceptionHandler,指定 minidump 存储路径。 - 编译后,当 Native 发生崩溃时,自动在指定目录生成 .dmp 文件。
- 上报时将 .dmp 文件上传至服务器,使用 breakpad 的
minidump_stackwalk和符号文件解析。
// CMakeLists.txt 示例
cmake_minimum_required(VERSION 3.4.1)
project(nativecrash)
# 引入 Breakpad 客户端库
set(BREAKPAD_SRC ${CMAKE_SOURCE_DIR}/thirdparty/breakpad/src)
add_library(breakpad_client STATIC
${BREAKPAD_SRC}/client/linux/crash_generation/crash_generation_client.cc
${BREAKPAD_SRC}/client/linux/handler/exception_handler.cc
${BREAKPAD_SRC}/client/linux/minidump_writer/minidump_writer.cc
${BREAKPAD_SRC}/client/lightning_file.cc
# ... 更多源文件
)
target_include_directories(breakpad_client PUBLIC ${BREAKPAD_SRC})
target_link_libraries(nativecrash breakpad_client log)
// JNI 初始化 Breakpad ExceptionHandler
#include "google_breakpad/exception_handler.h"
#include
#include
static google_breakpad::ExceptionHandler* g_handler = nullptr;
extern "C" JNIEXPORT void JNICALL
Java_com_example_crashdemo_NativeCrash_initBreakpad(JNIEnv* env, jclass, jstring path) {
const char* dir = env->GetStringUTFChars(path, nullptr);
g_handler = new google_breakpad::ExceptionHandler(
std::string(dir),
0, // filter callback
[](const google_breakpad::ExceptionHandler::MinidumpDescriptor& descriptor,
void* context,
bool succeeded) -> bool {
__android_log_print(ANDROID_LOG_INFO, "Breakpad", "Minidump written: %s",
descriptor.path());
return succeeded;
},
nullptr, // callback context
true, // install crash handler
-1 // server fd, -1 means client only
);
env->ReleaseStringUTFChars(path, dir);
}
调试与常见错误
| 问题 | 原因 | 解决方案 |
|---|---|---|
| minidump 未生成 | Breakpad 初始化失败;存储路径不可写 | 检查 /data/data/PACKAGE/ 下目录权限,确认 ExceptionHandler 构造函数第二个参数为 true(安装信号处理器) |
| 符号解析后堆栈不全 | 未使用 Release 编译的 .so 对应的符号文件 | 编译时设置 -g -O2 生成带调试信息的 .sym,使用 dump_syms 提取符号 |
| 与 Java 层信号冲突 | Breakpad 可能覆盖其他信号处理函数 | 确保只安装一个 Native 崩溃捕获库,或使用 SetUnhandledExceptionFilter 链式处理 |
性能注意: Breakpad 注册后每次崩溃都会同步写入 minidump,可能耗时 50~200ms。在低端设备上建议启用子进程写入模式,避免阻塞崩溃线程导致系统直接重启。
4.3 ANR 日志采集
ANR 不是传统意义的崩溃(进程没有立即死亡),但同样严重影响用户体验。系统在 ANR 发生时会将当前线程以及进程所有线程的堆栈写入 /data/anr/traces.txt(或 OEM 自定义路径)。应用可以通过 FileObserver 监听该文件变化,在变化发生后读取新增内容,结合自己记录的上一次偏移量解析出本次 ANR 信息。
实现流程
- 在
Application.onCreate()中启动一个后台HandlerThread。 - 注册
FileObserver(File("/data/anr/traces.txt"), FileObserver.MODIFY)。 - 在
onEvent回调中读取文件,使用RandomAccessFile只读新增部分。 - 解析 ANR 头部(“ANR in…”, “PID: …” )以及首个线程(通常是主线程)的栈。
- 将结构化数据写入本地日志缓存,等待下次启动或定时上报。
// ANR 监听简单示例(需系统级权限,或通过 adb 读取)
// 注:Android 10+ 对 /data/anr/ 无直接读权限,需应用为系统签名或使用 FileObserver 不可行时改用 adb 轮询
// 以下仅为概念性代码
public class AnrWatcher {
private FileObserver observer;
public void startWatch() {
HandlerThread thread = new HandlerThread("AnrWatch");
thread.start();
observer = new FileObserver("/data/anr/traces.txt", FileObserver.MODIFY) {
@Override
public void onEvent(int event, String path) {
// 读取文件最后一个 ANR 块
readAnrFromFile();
}
};
observer.startWatching();
}
private void readAnrFromFile() {
// 实现细节:使用 RandomAccessFile 记录上次文件长度,读取增量
}
}
实际部署替代方案: 由于权限限制,建议使用
adb shell dumpsys activity processes 或监听系统广播 ANR(需厂商提供)。更简洁的做法是使用开源库 ANR-WatchDog,它通过定时检测主线程超时(如 5 秒)来发现 ANR,不依赖系统文件。常见错误
- 权限不足:非系统应用无法读取
/data/anr/traces.txt。可以尝试FileObserver但只在 root 或系统级应用可行。 - 文件解析竞争:系统可能同时写入,读取可能不完整。建议加上同步锁和超时重试。
- 内存占用:traces.txt 可能非常大,只保留最新 ANR 块,避免全量加载。
4.4 性能与稳定性优化
| 采集方式 | 性能开销 | 稳定性影响 | 推荐策略 |
|---|---|---|---|
| Java UncaughtException | 低(仅当崩溃时发生) | 写入文件可能阻塞原线程,极低概率丢失日志 | 异步写入(HandlerThread),写入超时后直接 kill |
| Native Breakpad | 中等(崩溃时同步写入 minidump) | 信号处理后生命周期极短,通常可靠 | 使用子进程写入(fork)避免阻塞,或合并多次写入 |
| ANR FileObserver | 低(后台线程,按需读文件) | 可能因文件锁造成短暂卡顿 | 开启独立线程,限制读取频率,使用缓存偏移量 |
通用优化建议
- 日志压缩:在写入前对文本进行 GZIP 压缩,减少存储空间与上传流量。
- 延迟上报:崩溃后下次冷启动再上传,避免在崩溃线程做网络 IO。
- 采样率:仅针对 release 版本开启完整堆栈,开发环境可关闭 minidump 生成以加速调试。
- 信号冲突防护:若同时接入多个 Native 崩溃库(如 Firebase + Breakpad),需只保留一个信号处理器。
关键指标: 崩溃日志采集率应 > 99%,上报延迟 < 5 分钟(95 分位),本地日志空间限制 < 20 MB。定期使用测试工具模拟各类崩溃验证采集是否到位。