如何捕获未处理的异常并上报到服务器?
解读
面试官问的是“未处理异常”的兜底与闭环,而不是简单的 try-catch。国内 App 必须兼顾以下场景:
- 厂商 ROM 对后台拉起限制(特别是 Android 8.0+ 的 Background Execution Limit)。
- 国内无统一推送通道,进程被杀后无法靠 GMS 拉起,需自建保活/唤醒策略。
- 合规要求:异常信息不能包含用户隐私(IMEI、MAC、通讯录等),否则无法通过工信部 164 号文与各大应用商店审核。
- 网络环境复杂,弱网、断网、代理劫持常见,上报通道必须支持压缩、加密、重试、去重。
- 面试现场需要给出“代码级实现 + 策略级兜底”双重答案,证明你既懂 Framework 细节,也懂国内落地套路。
知识点
- Java 层未捕获异常接口:Thread.setDefaultUncaughtExceptionHandler。
- Native 层信号量捕获:signal、sigaction,配合 libcorkscrew 或 unwindstack 生成行号。
- ANR 与 Java Crash 区别:ANR 由 AMS 发出 SIGQUIT,需 watchdog 线程轮询 /data/anr/traces.txt 并二次确认。
- 进程存活策略:
- 独立 :crash 子进程做上报,主进程自杀,避免前台卡死。
- JobScheduler + WorkManager 做延迟重试,适配国产 ROM 省电模式。
- 数据合规:
- 只采集 UUID(随机生成)、SDK 版本、线程名、关键业务参数,屏蔽手机号、定位、IP 末段。
- 加密采用 RSA+AES 混合,公钥内嵌 so,私钥放服务器,满足《个人信息保护法》第 38 条跨境评估要求。
- 上报通道:
- 主通道:HTTPS HTTP/2 + gzip + protobuf,域名使用国内备案域名,避免 SNI 被运营商重置。
- 降级通道:Mqtt over 443 或 quic,支持 0-RTT,失败写 mmap 文件,下次启动续传。
- 去重与采样:
- 对堆栈做 hash(去掉行号),24 h 内同 hash 只报一次,减少服务器压力。
- 采样率按版本灰度:灰度 5% 先全量,正式版降到 1%,节省用户流量。
- 面试高频追问:
- “如果 crash 发生在 so 内部,如何定位行号?”
- “WorkManager 在华为/小米被强制停止后还能重试吗?”
- “如何验证加密后的日志无法被 Charles 解密?”
答案
-
双端 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。
-
独立进程上报
在 AndroidManifest 声明:crash进程,android:process=":crash",低内存时仅杀死主进程,:crash 仍可由 JobScheduler 拉起。
mmap 文件路径:/data/data/<pkg>/files/crash.bin,MODE_PRIVATE,系统卸载即清,符合合规。 -
数据封装与加密
字段:uuid、verCode、verName、abi、osVersion、stack、logcatLast(500 行)、recentPage(路由栈)。
AES 随机 key 加密原始数据 → RSA(2048) 加密 key → base64 放入 header“X-Encrypt-Key”。
整体再 gzip,Content-Encoding: gzip,减少 70% 流量。 -
重试与去重
WorkManager 约束:NetworkType.CONNECTED + BatteryNotLow,最大重试 3 次,退避策略 EXPONENTIAL,初始 10 s。
服务器返回 200 且 body="ok" 才认为成功,否则继续重试;若 3 次失败,写 SharedPreferences 标记“crash_pending”,下次冷启再次发送。 -
灰度与采样
后台按设备 id 末字节做分桶,灰度阶段 00-04 全量上报,正式版 00 上报,其余丢弃。
同版本同堆栈 hash 24 h 内只上报一次,服务器按 hash 聚合,方便排期。 -
现场代码片段(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 并自停。
-
面试加分话术
“我们线上 1.3 亿 DAU,每天 crash 率 0.03%,通过这套方案 95% 的崩溃 5 分钟内可聚合到后台,平均修复周期从 7 天降到 1.8 天;合规层面通过中国信通院 164 号文检测,无个人隐私字段。”
拓展思考
- 折叠屏与多窗口场景:若崩溃发生在 Activity 重绘过程,如何在日志里带上当前屏幕尺寸、旋转状态与窗口模式(分屏/画中画),方便复现?
- 系统库符号缺失:国内 ROM 往往裁剪 /system/lib 符号表,如何与厂商合作把 so 符号表上传到 Sentry 私有符号服务器,实现 addr2line 一键还原?
- TEE 可信环境:如果崩溃发生在 Trustlet(支付指纹场景),普通 Linux 信号量无法捕获,如何与厂商安全团队对接,通过 QSEE log 通道捞回异常?
- 合规升级:2025 年工信部拟要求“终端侧日志不得出境”,若服务器部署在 AWS 新加坡,需引入国密 SM4/SM2 并走阿里云合规通道,如何改造现有 RSA+AES 体系?
- 面试反向提问:你可以反问面试官——“贵组对 crash 率的核心指标是 Java 崩溃还是 ANR?是否有独立的舆情监控渠道来校准后台聚合的漏报率?” 体现你对质量闭环的深度思考。