如何捕获未处理的异常并上报到服务器?

解读

面试官问的是“未处理异常”的兜底与闭环,而不是简单的 try-catch。国内 App 必须兼顾以下场景:

  1. 厂商 ROM 对后台拉起限制(特别是 Android 8.0+ 的 Background Execution Limit)。
  2. 国内无统一推送通道,进程被杀后无法靠 GMS 拉起,需自建保活/唤醒策略。
  3. 合规要求:异常信息不能包含用户隐私(IMEI、MAC、通讯录等),否则无法通过工信部 164 号文与各大应用商店审核。
  4. 网络环境复杂,弱网、断网、代理劫持常见,上报通道必须支持压缩、加密、重试、去重。
  5. 面试现场需要给出“代码级实现 + 策略级兜底”双重答案,证明你既懂 Framework 细节,也懂国内落地套路。

知识点

  1. Java 层未捕获异常接口:Thread.setDefaultUncaughtExceptionHandler。
  2. Native 层信号量捕获:signal、sigaction,配合 libcorkscrew 或 unwindstack 生成行号。
  3. ANR 与 Java Crash 区别:ANR 由 AMS 发出 SIGQUIT,需 watchdog 线程轮询 /data/anr/traces.txt 并二次确认。
  4. 进程存活策略:
    • 独立 :crash 子进程做上报,主进程自杀,避免前台卡死。
    • JobScheduler + WorkManager 做延迟重试,适配国产 ROM 省电模式。
  5. 数据合规:
    • 只采集 UUID(随机生成)、SDK 版本、线程名、关键业务参数,屏蔽手机号、定位、IP 末段。
    • 加密采用 RSA+AES 混合,公钥内嵌 so,私钥放服务器,满足《个人信息保护法》第 38 条跨境评估要求。
  6. 上报通道:
    • 主通道:HTTPS HTTP/2 + gzip + protobuf,域名使用国内备案域名,避免 SNI 被运营商重置。
    • 降级通道:Mqtt over 443 或 quic,支持 0-RTT,失败写 mmap 文件,下次启动续传。
  7. 去重与采样:
    • 对堆栈做 hash(去掉行号),24 h 内同 hash 只报一次,减少服务器压力。
    • 采样率按版本灰度:灰度 5% 先全量,正式版降到 1%,节省用户流量。
  8. 面试高频追问:
    • “如果 crash 发生在 so 内部,如何定位行号?”
    • “WorkManager 在华为/小米被强制停止后还能重试吗?”
    • “如何验证加密后的日志无法被 Charles 解密?”

答案

  1. 双端 Handler 注册
    Java 侧在 Application.attachBaseContext 中设置:

    val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
    Thread.setDefaultUncaughtExceptionHandler { t, e ->
        CrashReport.capture(e)          // 写 mmap
        defaultHandler?.uncaughtException(t, e) // 交给系统,保证弹系统崩溃对话框
        Process.killProcess(Process.myPid())    // 防止僵尸进程
    }
    

    Native 侧在 JNI_OnLoad 注册:

    struct sigaction old;
    sigaction(SIGSEGV, &(struct sigaction){ .sa_sigaction = native_crash_handler, .sa_flags = SA_SIGINFO }, &old);
    

    native_crash_handler 里用 unwindstack::Unwinder 生成带符号表的可读堆栈,写入同一块 mmap。

  2. 独立进程上报
    在 AndroidManifest 声明 :crash 进程,android:process=":crash",低内存时仅杀死主进程,:crash 仍可由 JobScheduler 拉起。
    mmap 文件路径:/data/data/<pkg>/files/crash.bin,MODE_PRIVATE,系统卸载即清,符合合规。

  3. 数据封装与加密
    字段:uuid、verCode、verName、abi、osVersion、stack、logcatLast(500 行)、recentPage(路由栈)。
    AES 随机 key 加密原始数据 → RSA(2048) 加密 key → base64 放入 header“X-Encrypt-Key”。
    整体再 gzip,Content-Encoding: gzip,减少 70% 流量。

  4. 重试与去重
    WorkManager 约束:NetworkType.CONNECTED + BatteryNotLow,最大重试 3 次,退避策略 EXPONENTIAL,初始 10 s。
    服务器返回 200 且 body="ok" 才认为成功,否则继续重试;若 3 次失败,写 SharedPreferences 标记“crash_pending”,下次冷启再次发送。

  5. 灰度与采样
    后台按设备 id 末字节做分桶,灰度阶段 00-04 全量上报,正式版 00 上报,其余丢弃。
    同版本同堆栈 hash 24 h 内只上报一次,服务器按 hash 聚合,方便排期。

  6. 现场代码片段(Kotlin)

    object CrashReport {
        private const val CRASH_FILE = "crash.bin"
        fun capture(e: Throwable) {
            val crashInfo = CrashInfo(
                uuid = UUID.randomUUID().toString(),
                stack = Log.getStackTraceString(e),
                time = System.currentTimeMillis()
            )
            val file = File(ContextHolder.app.filesDir, CRASH_FILE)
            RandomAccessFile(file, "rw").use { raf ->
                raf.channel.lock().use {
                    raf.write(crashInfo.toByteArray())
                }
            }
            // 立即拉起 :crash 进程
            ContextHolder.app.startService(Intent(ContextHolder.app, CrashUploadService::class.java))
        }
    }
    

    CrashUploadService 运行在 :crash 进程,内部使用 WorkManager 排队上传,上传完删除 crash.bin 并自停。

  7. 面试加分话术
    “我们线上 1.3 亿 DAU,每天 crash 率 0.03%,通过这套方案 95% 的崩溃 5 分钟内可聚合到后台,平均修复周期从 7 天降到 1.8 天;合规层面通过中国信通院 164 号文检测,无个人隐私字段。”

拓展思考

  1. 折叠屏与多窗口场景:若崩溃发生在 Activity 重绘过程,如何在日志里带上当前屏幕尺寸、旋转状态与窗口模式(分屏/画中画),方便复现?
  2. 系统库符号缺失:国内 ROM 往往裁剪 /system/lib 符号表,如何与厂商合作把 so 符号表上传到 Sentry 私有符号服务器,实现 addr2line 一键还原?
  3. TEE 可信环境:如果崩溃发生在 Trustlet(支付指纹场景),普通 Linux 信号量无法捕获,如何与厂商安全团队对接,通过 QSEE log 通道捞回异常?
  4. 合规升级:2025 年工信部拟要求“终端侧日志不得出境”,若服务器部署在 AWS 新加坡,需引入国密 SM4/SM2 并走阿里云合规通道,如何改造现有 RSA+AES 体系?
  5. 面试反向提问:你可以反问面试官——“贵组对 crash 率的核心指标是 Java 崩溃还是 ANR?是否有独立的舆情监控渠道来校准后台聚合的漏报率?” 体现你对质量闭环的深度思考。