开篇-焦虑的移动开发者如何破局
移动互联网的发展不知不觉已经十多年了,Mobile First 也已经变成了 AI First。换句话说,我们已经不再是“风口上的猪”。
可以说,国内移动互联网的红利期已经过去了,现在是增量下降、存量厮杀,从争夺用户到争夺时长。
移动端的招聘量变少,但中高端的职位却多了起来,这说明行业只是变得成熟规范起来了。
竞争激烈,但产品质量与留存变得更加重要,我们进入了技术赋能业务的时代。
不要把时间浪费在纠结问题上,而是应该放在解决问题上。
- 一个应用至少会经过开发、编译 CI、测试、灰度和发布这几个阶段;
- Android 绿色联盟开发者大会上推出的应用体验标准,有对应用的兼容性、稳定性、性能、功能和安全做了详细的定义;
- 我们很多时候都在用战术的勤奋掩盖战略的懒惰,性能优化的关键在于如何解决存量问题,同时快速发现增量问题;
下面开始高质量开发篇
崩溃优化(上)
崩溃率是衡量一个应用质量高低的基本指标;
-
Android 崩溃分为 Java 崩溃和 Native 崩溃:
- Java 崩溃就是在 Java 代码中,出现了未捕获异常,导致程序异常退出;
- Native 崩溃又是怎么产生的呢?一般都是因为在 Native 代码中访问非法地址,
也可能是地址对齐出现了问题,或者发生了程序主动 abort,这些都会产生相应的 signal 信号,导致程序异常退出。
Native崩溃的捕获流程
- 可以参考:
- 完整的 Native 崩溃从捕获到解析的流程:
- 编译端。编译 C/C++ 代码时,需要将带符号信息的文件保留下来
- 客户端。捕获到崩溃时候,将收集到尽可能多的有用信息写入日志文件,然后选择合适的时机上传到服务器。
- 服务端。读取客户端上报的日志文件,寻找适合的符号文件,生成可读的 C/C++ 调用栈。
- Native 崩溃捕获的难点
上面的三个流程中,最核心的是怎么样保证客户端在各种极端情况下依然可以生成崩溃日志。
因为在崩溃时,程序会处于一个不安全的状态,如果处理不当,非常容易发生二次崩溃。
那么,生成崩溃日志时会有哪些比较棘手的情况呢?
情况一:文件句柄泄漏,导致创建日志文件失败,怎么办?
应对方式:我们需要提前申请文件句柄 fd 预留,防止出现这种情况。
情况二:因为栈溢出了,导致日志生成失败,怎么办?
应对方式:为了防止栈溢出导致进程没有空间创建调用栈执行处理函数,我们通常会使用常见的 signalstack。
在一些特殊情况,我们可能还需要直接替换当前栈,所以这里也需要在堆中预留部分空间。
情况三:整个堆的内存都耗尽了,导致日志生成失败,怎么办?
应对方式:这个时候我们无法安全地分配内存,也不敢使用 stl 或者 libc 的函数,因为它们内部实现会分配堆内存。
这个时候如果继续分配内存,会导致出现堆破坏或者二次崩溃的情况。Breakpad 做的比较彻底,重新封装了
Linux Syscall Support,来避免直接调用 libc。
情况四:堆破坏或二次崩溃导致日志生成失败,怎么办?
应对方式:Breakpad 会从原进程 fork 出子进程去收集崩溃现场,此外涉及与 Java 相关的,一般也会用子进程去操作。
这样即使出现二次崩溃,只是这部分的信息丢失,我们的父进程后面还可以继续获取其他的信息。在一些特殊的情况,
我们还可能需要从子进程 fork 出孙进程。
- 选择合适的崩溃服务
对于很多中小型公司来说,并不建议自己去实现一套如此复杂的系统,可以选择一些第三方的服务。
目前各种平台也是百花齐放,包括腾讯的Bugly、阿里的啄木鸟平台、网易云捕、Google 的 Firebase 等等
- 如何客观地衡量崩溃
要衡量一个指标,首先要统一计算口径。如果想评估崩溃造成的用户影响范围,我们会先去看 UV 崩溃率。
UV 崩溃率 = 发生崩溃的 UV / 登录 UV
我们还可以去看应用 PV 崩溃率、启动崩溃率、重复崩溃率这些指标,计算方法都大同小异。
这里为什么要单独统计启动崩溃率呢?因为启动崩溃对用户带来的伤害最大,应用无法启动往往通过热修复也无法拯救。
闪屏广告、运营活动,很多应用启动过程异常复杂,又涉及各种资源、配置下发,极其容易出现问题。
微信读书、蘑菇街、淘宝、天猫这些“重运营”的应用都有使用一种叫作“安全模式”的技术来保障客户端的启动流程,
在监控到客户端启动失败后,给用户自救的机会。
安全模式:天猫App启动保护实践
不要写死!天猫App的动态化配置中心实践
天猫App A/B测试实践
- 如何客观地衡量稳定性
崩溃率是不是就能完全等价于应用的稳定性呢?答案是肯定不行。处理了崩溃,我们还会经常遇到 ANR。
怎么去发现应用中的 ANR 异常呢?
1. 使用 FileObserver 监听 /data/anr/traces.txt 的变化;
很多高版本的 ROM,已经没有读取这个文件的权限了,只能思考其他路径,海外可以使用 Google Play 服务,
而国内微信利用Hardcoder框架向厂商获取了更大的权限。
2. 监控消息队列的运行时间;
这个方案无法准确地判断是否真正出现了 ANR 异常,也无法得到完整的 ANR 日志, 在我看来,更应该放到卡顿的性能范畴;
都有哪些应用退出的情形?
1. 主动自杀:Process.killProcess()、exit() 等。
2. 崩溃:出现了 Java 或 Native 崩溃。
3. 系统重启: 系统出现异常、断电、用户主动重启等,我们可以通过比较应用开机运行时间是否比之前记录的值更小。
4. 被系统杀死: 被 low memory killer 杀掉、从系统的任务管理器中划掉等。
5. ANR。
我们可以在应用启动的时候设定一个标志,在主动自杀或崩溃后更新标志,
这样下次启动时通过检测这个标志就能确认运行期间是否发生过异常退出。
对应上面的五种退出场景,我们排除掉主动自杀和崩溃(崩溃会单独的统计)这两种场景,
希望可以监控到剩下三种的异常退出,理论上这个异常捕获机制是可以达到 100% 覆盖的。
所以就得到了一个新的指标来衡量应用的稳定性,即异常率。
UV 异常率 = 发生异常退出或崩溃的 UV / 登录 UV
根据应用的前后台状态,我们可以把异常退出分为前台异常退出和后台异常退出;
通过异常率我们可以比较全面的评估应用的稳定性,对于线上监控还需要完善崩溃的报警机制。
关于Hardcoder: 你信不信,这篇只有手机厂商能看懂
- 课后作业: 使用 Breakpad 来捕获一个 Native 崩溃,通过这个simple可以深入学习一下Breakpad如何不会 Native 崩溃
崩溃优化(下)
- 解决崩溃跟破案一样需要经验,我们分析的问题越多越熟练,定位问题就会越快越准;
- 崩溃现场是我们的“第一案发现场”,操作系统是整个崩溃过程的"最佳目击证人"
崩溃现场应采集哪些信息
1. 崩溃信息:
- 进程名、线程名:
崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。
- 崩溃堆栈和类型:
属于 Java 崩溃、Native 崩溃,还是 ANR
2. 系统信息
- Logcat: 这里包括应用、系统的运行日志
- 机型、系统、厂商、CPU、ABI、Linux 版本等
- 设备状态:是否root、是否是模拟器
3. 内存信息
OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系
- 系统剩余内存:
当系统可用内存很小(低于 MemTotal 的 10%)时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现
- 应用使用内存:
包括 Java 内存、RSS(Resident Set Size)、PSS(Proportional Set Size),我们可以得出应用本身内存的占用大小和分布
- 虚拟内存:
可以通过 /proc/self/status 得到,通过 /proc/self/maps 文件可以得到具体的分布情况,有时候我们一般不太重视虚拟内存,但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的
4. 资源信息
有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系
- 文件句柄 fd:
文件句柄的限制可以通过 /proc/self/limits 获得,一般单个进程允许打开的最大文件句柄个数为 1024。
但是如果文件句柄超过 800 个就比较危险,需要将所有的 fd 以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏
- 线程数:
当前线程数大小可以通过上面的 status 文件得到,一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。
根据我的经验来说,如果线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
- JNI:
使用 JNI 时,如果不注意很容易出现引用失效、引用爆表等一些崩溃。我们可以通过 DumpReferenceTables 统计 JNI 的引用表,进一步分析是否出现了 JNI 泄漏等问题。
5. 应用信息
除了系统,其实我们的应用更懂自己,可以留下很多相关的信息
- 崩溃场景
崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务中
- 关键操作路径
不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助
- 其他自定义信息
不同的应用关心的重点可能不太一样,比如网易云音乐会关注当前播放的音乐,QQ 浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。
6. 其他信息
除了上面这些通用的信息外,针对特定的一些崩溃,我们可能还需要获取类似磁盘空间、电量、网络使用等特定信息。所以说一个好的崩溃捕获工具,会根据场景为我们采集足够多的信息,让我们有更多的线索去分析和定位问题。当然数据的采集需要注意用户隐私,做到足够强度的加密和脱敏。
崩溃分析 三部曲
第一步:确定重点
确认和分析重点,关键在于在日志中找到重要的信息,对问题有一个大致判断。一般来说,我建议在确定重点这一步可以关注以下几点。
1. 确认严重程度
解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响,例如启动、支付过程的崩溃。
2. 崩溃基本信息
确定崩溃的类型以及异常描述,对崩溃有大致的判断。
- Java 崩溃类型比较明显
- Native 崩溃:
需要观察 signal、code、fault addr 等内容,以及崩溃时 Java 的堆栈; - ANR:
先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR 日志中 iowait、CPU、GC、system server 等信息,进一步确定是 I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死。
3. Logcat
Logcat 一般会存在一些有价值的线索,日志级别是 Warning、Error 的需要特别注意。从 Logcat 中我们可以看到当时系统的一些行为跟手机的状态,例如出现 ANR 时,会有“am_anr”;App 被杀时,会有“am_kill”。
4. 各个资源情况
结合崩溃的基本信息,我们接着看看是不是跟 “内存信息” 有关,是不是跟“资源信息”有关。比如是物理内存不足、虚拟内存不足,还是文件句柄 fd 泄漏了。
第二步:查找共性
如果使用了上面的方法还是不能有效定位问题,我们可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步
机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,找到了共性,可以对你下一步复现问题有更明确的指引。
第三步:尝试复现
“只要能本地复现,我就能解”,相信这是很多开发跟测试说过的话。有这样的底气主要是因为在稳定的复现路径上面,我们可以采用增加日志或使用 Debugger、GDB 等各种各样的手段或工具做进一步分析。
疑难问题:系统崩溃 的解决思路
1. 查找可能的原因。
通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。
2. 尝试规避。
查看可疑的代码调用,是否使用了不恰当的 API,是否可以更换其他的实现方式规避。
3. Hook 解决。
这里分为 Java Hook 和 Native Hook。以我最近解决的一个系统崩溃为例,我们发现线上出现一个 Toast 相关的系统崩溃,
它只出现在 Android 7.0 的系统中,看起来是在 Toast 显示的时候窗口的 token 已经无效了。这有可能出现在 Toast
需要显示时,窗口已经销毁了。
android.view.WindowManager$BadTokenException:
at android.view.ViewRootImpl.setView(ViewRootImpl.java)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4)
at android.widget.Toast$TN.handleShow(Toast.java)
为什么 Android 8.0 的系统不会有这个问题?在查看 Android 8.0 的源码后我们发现有以下修改:
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
考虑再三,我们决定参考 Android 8.0 的做法,直接 catch 住这个异常。这里的关键在于寻找 Hook 点,这个案例算是相对比较简单的。
Toast 里面有一个变量叫 mTN,它的类型为 handler,我们只需要代理它就可以实现捕获。
崩溃攻防是一个长期的过程,我们希望尽可能地提前预防崩溃的发生,将它消灭在萌芽阶段。这可能涉及我们应用的整个流程,包括人员的培训、编译检查、静态扫描工作,还有规范的测试、灰度、发布流程等。
获得logcat和Jave堆栈的方法:
一. 获取logcat
logcat日志流程是这样的,应用层 --> liblog.so --> logd,底层使用ring buffer来存储数据,获取的方式有以下三种:
1. 通过logcat命令获取
- 优点:非常简单,兼容性好。
- 缺点:整个链路比较长,可控性差,失败率高,特别是堆破坏或者堆内存不足时,基本会失败。
2. hook liblog.so实现
通过hook liblog.so 中__android_log_buf_write 方法,将内容重定向到自己的buffer中。
- 优点:简单,兼容性相对还好。
- 缺点:要一直打开。
3. 自定义获取代码
- 通过移植底层获取logcat的实现,通过socket直接跟logd交互。
- 优点:比较灵活,预先分配好资源,成功率也比较高。
缺点:实现非常复杂
二. 获取Java 堆栈
native崩溃时,通过unwind只能拿到Native堆栈。我们希望可以拿到当时各个线程的Java堆栈
1. Thread.getAllStackTraces()。
- 优点:简单,兼容性好。
- 缺点:
a. 成功率不高,依靠系统接口在极端情况也会失败。
b. 7.0之后这个接口是没有主线程堆栈。
c. 使用Java层的接口需要暂停线程
2. hook libart.so
通过hook ThreadList和Thread的函数,获得跟ANR一样的堆栈。为了稳定性,我们会在fork子进程执行。
- 优点:信息很全,基本跟ANR的日志一样,有native线程状态,锁信息等等。
- 缺点:黑科技的兼容性问题,失败时可以用Thread.getAllStackTraces()兜底
获取Java堆栈的方法还可以用在卡顿时,因为使用fork进程,所以可以做到完全不卡主进程。这块我们在后面会详细的去讲。
课后练习
一种“完全解决”TimeoutException 的方法 https://github.com/AndroidAdvanceWithGeektime/Chapter02