Crashlytics 的自定义日志,定位 Native Crash

Crashlytics 的自定义日志,定位 Native Crash

Crashlytics 的自定义日志,定位 Native Crash


Crashlytics 的自定义日志,定位 Native Crash


去年线上有一个 Native Crash 把我们折腾了挺久。信号 SIGSEGV,崩溃地址 0x0000000000000000,Firebase Console 里的堆栈只有三行:第一行 libhwui.so,第二行 libjucy.so 后面跟着一个偏移地址,第三行 libc.so。用户反馈是“播放时闪退”,但播放器里涉及网络下载、解封装、解码、音频重采样、渲染至少五条链路,单靠这个堆栈根本没法判断是哪一段数据出了问题。 symbols 我们其实上传了,堆栈能解析到 avcodec_decode_audio4 附近,可知道越界又能怎么样?输入是 FLAC 还是 AAC,码率多少,有没有触发重采样,这些信息在崩溃瞬间全丢了。


这就是 Native Crash 最让人头疼的地方。Java 层的异常抛出时,调用栈里通常还能看出业务路径,但 Native 层一旦走到非法地址,系统给的信息就是寄存器状态和一堆 so 偏移。如果没有业务上下文,符号化再精准也只是把 ?? 换成 libffmpeg.so 里的某个函数名,离根因还差得远。


空堆栈的 Native Crash,问题不在符号化


很多团队第一次遇到 Native Crash 的思路是:堆栈看不懂,肯定是因为符号没传对。于是去研究 crashlytics-symbol 构建脚本、上传 unstripped so 文件、配置 NDK 版本对齐。这些当然重要,但即使符号完美,你看到的画面也只不过是 swr_init() 或者 openal_alc_play 这类函数名。它们告诉你崩溃发生在音频重采样或播放环节,却不会告诉你当前音频流的采样率、通道数、编码格式。


我们的项目是一个音视频播放器,底层依赖 FFmpeg 和自研的 OpenSL ES 输出层。Native Crash 大多发生在解码线程或音频回调线程里,这些线程完全在 C++ 侧运行,和 Java 层几乎没有交互。默认情况下,Crashlytics 能抓到的 Java 日志非常有限,因为最后一条 Java 日志可能是在几秒前 Activity 切换时留下的,和 Native 崩溃完全无关。


要让 Native Crash 可定位,必须让 C++ 代码在运行过程中把关键状态回写到 Crashlytics 的 Session 上下文里。Firebase Crashlytics 提供了两个主要工具:日志流 Crashlytics.log() 和键值对 setCustomKey()。前者是时间线,后者是状态快照。两者配合,才能把一个光秃秃的 SIGSEGV 翻译成“在播放某首 48kHz FLAC 时,重采样上下文初始化失败”。


Crashlytics.log 的缓冲区与 Native Crash 的时差


在开始写桥接代码之前,有必要先理解 Crashlytics.log() 的内部行为。这个 API 在 Java 层并不是直接写文件。它把字符串写进一个内存环形缓冲区,由后台线程择机落盘。对于 Java 崩溃来说,UncaughtExceptionHandler 被触发时,系统会保证缓冲区内容被 flush 到崩溃报告里。但 Native Crash 走的是另一条路:由 firebase-crashlytics-ndk 注册的 signal handler 捕获,然后交给底层 crashpad 封装上报。


这里有一个关键细节:如果你在 C++ 的解码线程里通过 JNI 调用 Crashlytics.log(),这条日志必须先经过 JNI 边界进入 Java 层,写入内存缓冲区,才算被 Crashlytics 持有。如果 Native 崩溃发生在 JNI 调用返回之前,或者发生在日志还在 Java 层队列里没被合并的时候,这条日志就可能丢失。更重要的是,如果你在 signal handler 里试图再做一次 JNI 调用来“补写”日志,那基本等于自杀——JVM 在崩溃瞬间可能已经处于不安全状态,AttachCurrentThread 会死锁或者直接崩溃。


所以策略只能是:在常态运行中就把关键日志送过去,不要指望崩溃瞬间还能现场写字。bridge 必须轻量、高频可用,但不能在崩溃后使用。


JNI Bridge 的写法与缓存陷阱


firebase-crashlytics-ndk 并没有提供官方的 C/C++ 头文件来直接调用日志或自定义键。你只能用 JNI 自己搭一座桥。这听起来简单,但里面有几个很容易踩的坑。


第一个坑是 FindClass 的类加载器问题。在非 Java 创建的线程(比如 C++ 里 std::thread 启动的解码线程)里,直接 AttachCurrentThread 后调用 FindClass("com/example/CrashlyticsBridge"),大概率返回空。因为系统类加载器看不到你的应用类。解决办法是在 JNI_OnLoad 里提前找到这个类,并创建全局引用缓存下来。


static jclass gBridgeClass = nullptr;
static jmethodID gLogMethod = nullptr;
static jmethodID gKeyMethod = nullptr;

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
    
    jclass local = env->FindClass("com/example/NativeCrashlyticsBridge");
    gBridgeClass = reinterpret_cast<jclass>(env->NewGlobalRef(local));
    gLogMethod = env->GetStaticMethodID(gBridgeClass, "log", "(Ljava/lang/String;)V");
    gKeyMethod = env->GetStaticMethodID(gBridgeClass, "setKey", "(Ljava/lang/String;Ljava/lang/String;)V");
    
    return JNI_VERSION_1_6;
}

Java 层对应的 bridge 极其简单:


public class NativeCrashlyticsBridge {
    public static void log(String msg) {
        FirebaseCrashlytics.getInstance().log(msg);
    }
    public static void setKey(String key, String value) {
        FirebaseCrashlytics.getInstance().setCustomKey(key, value);
    }
}

第二个坑是字符串构造。如果你每次日志都 NewStringUTF,在高频场景(比如每帧解码都记一个 pts)下会制造大量局部引用,虽然每次调用后 DeleteLocalRef 能缓解,但频繁 JNI 字符串分配本身就会触发 JNI 层的锁竞争。实际测试下来,在 Pixel 6 上,单次 CallStaticVoidMethodNewStringUTF 的短字符串(50 字节以内)平均耗时约 3-5μs,峰值时因为 JVM 的 String 分配和 Crashlytics 内部的锁,能跳到 30μs 以上。对于 60fps 的解码线程,这意味着每帧都调用的话,偶尔会出现一次轻微卡顿。


自定义键:比日志更可靠的快照


相比于流式日志,我更依赖 setCustomKey 来做 Native Crash 的“现场勘验”。Custom Key 在 Firebase Console 里是结构化的,可以直接按 key 筛选崩溃。而且它的更新频率低,通常只在状态变化时写一次,比如进入解码、切换音轨、改变采样率时。


在我们的项目里,我会在以下几个节点强制同步设置 Custom Key:

  • native_codec_init 结束时,写入 video_codecaudio_codec
  • native_audio_track_open 时,写入 sample_ratechannel_layout
  • 发生网络切换时,写入 current_url 的域名哈希(完整 URL 太长,截断或哈希)

  • void CrashlyticsSetKey(const std::string& key, const std::string& value) {
        JNIEnv* env = nullptr;
        g_jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
        if (!env) {
            g_jvm->AttachCurrentThread(&env, nullptr);
        }
        if (env && gBridgeClass && gKeyMethod) {
            jstring jKey = env->NewStringUTF(key.c_str());
            jstring jValue = env->NewStringUTF(value.c_str());
            env->CallStaticVoidMethod(gBridgeClass, gKeyMethod, jKey, jValue);
            env->DeleteLocalRef(jKey);
            env->DeleteLocalRef(jValue);
        }
    }

    这些键值对在 Native Crash 上报时会被 Crashlytics 的底层引擎一起打包带走。因为它们是 Session 级别的元数据,写入路径比日志流更短,丢失概率也更低。我们曾经在 com.google.firebase:firebase-crashlytics-ndk:18.2.0 版本上遇到过一个现象:Native Crash 里的日志偶尔会缺最后两三条,但 Custom Key 永远是完整的。虽然没有在官方文档里找到明确解释,但观察下来 keys 的 flush 策略确实比 log buffer 更激进。


    线程、性能与批量提交的取舍


    如果直接把 C++ 里所有的 ALOGV 都桥接到 Crashlytics.log(),性能上撑不住。我们的做法是只在 C++ 层维护一个无锁环形队列(std::vector<std::string>std::mutex),每积累 20 条或每 100ms 触发一次批量 JNI 提交。这样可以平摊 AttachCurrentThread 和 JNI 调用的开销。


    对于 Java 创建的线程(比如通过 new Thread(() -> nativeLoop()).start()),JNIEnv 可以直接从调用参数透传,甚至不需要 AttachCurrentThread。但如果是 C++ 内部自己创建的线程,比如 FFmpeg 的 avcodec 工作线程,或者音频输出里的 std::thread 回调,那就必须 AttachCurrentThread,并在线程退出前 DetachCurrentThread。否则线程句柄会泄漏,JVM 无法退出。


    批量提交时还有一个细节:不要把时间戳拼接逻辑放在 Java 层。Java 层的 Crashlytics.log() 会自动给日志加上相对时间戳,但它是以调用时刻为准。如果你在 C++ 层缓冲了 100ms 的日志再一次性提交,所有日志的时间戳都会被标记为提交那一刻,造成时间线失真。解决办法是在 C++ 层每条日志前手动拼一个相对秒数,然后关闭 Crashlytics 的自动时间戳——不过 Crashlytics 并没有提供关闭时间戳的选项,所以这是一个已知的小瑕疵,不影响定位,只是看时间线时要知道那 20 条日志其实是分布在 100ms 内的。


    版本差异:18.2.0 之后的 NDK 上报链路变化


    com.google.firebase:firebase-crashlytics-ndk 在 18.2.0 版本左右做了一次底层重构,把之前的 breakpad 风格封装切到了更标准的 crashpad 集成。这对开发者来说最直观的改变是:Native Crash 的符号化要求变严格了。18.2.0

    VS Code 写 Android,插件生态够用吗 2026-07-17
    Fastlane 的自动化发布流程,Play Store 上传 2026-07-20

    评论区