你肯定见过这种场面:某个周五下午,用户手机上一个由你亲手编写的SDK方法,悄悄抛出了一个未被捕获的异常。App瞬间白屏,然后闪退——而你对这一切的发生毫无察觉。因为你根本不拥有那款宿主App,也无法直接看到它的崩溃日志。你唯一知道的,是第二天早上,有开发者愤怒地在GitHub提了个issue。

想给自己的SDK装一个崩溃处理器来抓日志?思路很直接:Thread.setDefaultUncaughtExceptionHandler(myHandler),一行代码的事。可真这么干了,你就在那一瞬间彻底“灭掉”了宿主App自带的崩溃报告——Firebase Crashlytics、Sentry、Bugsnag,应有尽有的那一套,全被你静悄悄替换掉了。对方会突然发现自家监控面板一片平静,直到哪天复盘事故,才顺着线索摸到你头上。

打开网易新闻 查看精彩图片

Android系统里,全局未捕获异常处理器只有一个“插槽”。谁最后调用setDefaultUncaughtExceptionHandler,谁就拥有这个插槽,之前的家伙会被无声地踢出局。这也意味着你如果从SDK里直接调用,不是在“增添”一个处理器,而是在“取代”宿主的处理器。宿主团队用惯了的崩溃报告就此消失,一切责任都得你来扛。

那怎么办?放弃不是选项,而取巧的办法是:别占那个插槽,只“装饰”它。思路很简单,SDK初始化时,先读出当前已经安装的处理器,不管它是Crashlytics还是别的什么。然后你再安装自己的处理器,但这个处理器的任务只是看一眼异常,如果归你所有,就记下来;不论结果如何,都必须把异常原封不动地传给之前读到的那个处理器。就像这样:crash → [你的拦截器] → 宿主原有的处理器 → 系统默认处理。你的拦截器永远只是一个“过客”,只观察不拦截,也不拦截别人。

这条铁律——永远把异常往下游委托——是整个过程能够安全嵌入的唯一保障。宿主的Crashlytics会像以前一样收到100%的崩溃事件,丝毫感觉不到你的存在。为了实现这个机制,我写了一个极小的库,叫crashsink。装饰器的想法只是轻松的前20%,真正惹人头疼的,其实是判断“这个崩溃到底是不是我的”。

你只想捕获自己SDK代码引发的崩溃。如果崩溃源自宿主App或者别的SDK,硬抓过来只会给你的后台刷满无用噪音。最初的想法很朴素:瞅一眼栈顶堆栈帧,看它是否落在你的包名里。但这个判断方法有一个隐蔽的偏差——异常经常被层层包裹。真正发生的是一个IOException,然后包装成RuntimeException,最后再向上抛出。栈顶往往落在framework代码或者java.、android.框架层,而那并不是真正的罪魁祸首。顶层堆栈帧几乎从来都不是“你的”包名。所以“只看最上面一帧”几乎一定会漏掉属于你的崩溃,或者把别人的误报为你的。

更稳健的做法是全帧扫描:遍历整个堆栈,看看有没有哪一帧落在你的SDK包名下。有了它就是你的,没有就安静委托给下游,别瞎报。整个过程依然保持装饰器的尊严——看一眼,做判断,然后全部向下传递,绝不在异常流中做任何手脚。

作为SDK开发者,你得时刻记住自己是个“房客”,不是“房东”。那个全局异常插槽是宿主App的领地,你只能在一旁窥视,决不能夺走控制权。用装饰器包一层,再看一眼,永远委托,这样你既能抓到属于自己的崩溃线索,又丝毫不会干扰宿主App的现有监控体系。那些令开发者又爱又恨的Crashlytics面板,将继续安然运转,仿佛你从未出现。