Soul Android端稳定性背后的那些事
前言:移动应用的稳定性对于用户体验和产品商业价值都有着至关重要的作用。应用崩溃会导致关键业务中断、用户留存率下降、品牌口碑变差、生命周期价值下降等影响,甚至会导致用户流失。因此,稳定性是APP质量构建体系中最基本和最关键的一环。当然,稳定性优化的面很广,不仅仅是指崩溃,像卡顿、耗电,业务可用性等也属于稳定性优化的范畴。
下面我们以时间为主线,来介绍下SoulAPP Android端在持续有效控制APP稳定性方面,都做了哪些事情。
开发阶段
对于稳定性来说,如果APP已经到了线上才发现异常,那其实已经造成了损失,所以,对于稳定性的控制,预防大于优化。在APP上线前,充分暴露问题是重点,然而由于上线前没有大量实际用户使用,怎么及时暴露问题也是我们的一个痛点。
在开发阶段,我们做了如下一些工作,尽可能的在交给用户前保证APP的稳定性
研发方面
-
技术方案Review:技术方案评审可以有效防止在方案层面出现一些耗 CPU、耗内存等的问题。是一个早期预防措施,可以避免后期开发和测试中出现一些不必要的问题。
-
SDK稳定性:在接入或升级三方 SDK 时,为了保证应用的稳定性,我们需多方面对 SDK 进行评估,同时要求三方提供稳定性报告。
-
容灾过滤:在开发过程中,我们设置的一些稳定性兜底策略,经常会让一些问题在开发期间被兜住,得不到暴露。所以我们几乎任何的容灾手段,都会过滤掉开发包和测试包。
-
Lint编码规范:对于已经踩过的坑,我们总结了一些编码规范;但是规范经常只会停留在文档里,编码过程中很难注意到所有规范;因此,我们做了一些 Lint 编码检查,在代码编写期间发现问题并给出提示和报错。
-
架构优化:架构层面我们主要通过能力收敛、统一容错来提升应用的可靠性。在架构优化过程中,我们沉淀了较多的基础库,对基础功能进行封装,在基础库里做好统一容错等稳定性防护。
-
Method Not Found检测:当公共库删除 public 方法后,如果调用的业务库没有同步修改,在以 aar 集成的方式中,编译期间不会报错;但是运行期会报 ClassNotFoundException 或 MethodNotFoundException 导致 Crash;我们做了个工具在测试回归期间和发布前进行检测。
测试方面
-
UI自动化:在每个版本发布前,我们都会进行 UI自动化测试。提高效率的同时,也保证了应用程序基本业务的稳定性和可用性。
-
Monkey:每天我们都会运行即将发布的版本的 Monkey 自动化测试,充分暴露性能问题。
-
性能准出回归:在应用发布前,每个业务领域都会生成性能报告,包括各种业务场景下的 CPU 占用、内存、Crash、ANR 等多个方面。
-
线下Crash 统计:对于即将上线的 APP 版本,稳定性方面做了特有的监控和报警,保证上线前及时发现潜在的稳定性问题。
灰度阶段
在开发阶段经过测试全量回归,在灰度发布阶段基本能保证没有重大问题。
但是有一些灰度期间发现的稳定性问题,线上紧急问题等经常会在灰度期间合入代码;由于灰度阶段后不会有测试全面回归所有功能,改动很容易出现不可预知的问题,直接影响到线上用户;所以灰度阶段我们会通过严格的流程保证APP稳定性。
同时,灰度因为有较多用户使用,能充分暴露开发阶段没有发现的问题,这一点是稳定性保障的中流砥柱。
新版本上线采用多轮灰度 → 全量 的发布模式,在新版本全量发布前,充分灰度暴露问题,将影响范围降至最低。
灰度发布流程
改动严格审核
灰度期间由于合入的代码只有针对改动的测试,缺少全量测试回归,所以尤其需要谨慎;合入代码的审核我们进行了更严格的流程管控,需要通过功能测试,Leader 代码 Review,测试 Review,都通过才能合入正式包。
正常合代码流程:
灰度合代码流程:
Crash&ANR及时监控和修复
由于在开发测试期间,只有少量的手机在测试新版本,很多偶现的问题,兼容性问题很难暴露,而往往一些偶现的概率性问题,手机兼容性问题在全量后会被放大,导致整体的稳定性变差。而灰度有一定的用户量,机型覆盖也比较全面,正好能有效的暴露这些问题。经过灰度期间的暴露和修复,稳定性方面能得到有效的保障。
用户反馈
借助于用户反馈平台,灰度期间我们通过机器人监控新版本用户反馈,经过筛选和分类后,在工作群里通知;能及时的发现新版本业务可用性问题。
运维阶段
经过开发阶段和灰度阶段的种种手段,到正式全量进入运维阶段后,新版本的稳定性一般都能达到一个较好的水平。
但还是会出现一些意料之外的状况,举个例子,比如有些开关或者abtest在灰度期间没有打开;一些偶现问题在灰度期间没有发现,全量之后打开开关或者abtest,会发现可能出现突增的偶现Crash&ANR。
所以进入运维阶段后,并不代表结束,反而更需要关注。在进入运维阶段后,我们注重两点,一是监控报警,二是容灾。除此之外对于线上稳定性问题,我们也会组织学习,尽量避免再次踩坑。
Crash&ANR 监控&报警
当前稳定性监控主要是通过Bugly,虽然Bugly平台的信息较为丰富,能较为有效的帮助我们监控线上稳定性问题,但是使用过程中也有一些痛点问题,比如报警的维度不够丰富。
Bugly上可以配置报警,但是只能针对大盘数据波动的情况;然而我们需要更细的粒度,期望能及时的知道哪一个Crash有大幅波动,这样才能更精准,更快速的发现问题。
于是我们基于Bugly做了监控工具,在全量上线后,通过Bugly监控脚本,及时监控报警,第一时间发现线上稳定性问题。
报警信息如图,我们能及时的监控到具体哪条Crash波动。
Crash&ANR 波动分析
在对于数据分析这块,Bugly 平台提供的能力较弱,我们经常会碰到这样一些情况
-
某一天来观察 Bugly 大盘的情况,发现昨天要比前天 Crash 率高 10%;然而去看数量排名 Top 的 Crash,发现并没有变化;此时要分析是由于哪些 Crash 引起的总体大盘上涨就很难了。
-
Bugly 上的 Crash&ANR 聚合上不能完全满足需求,经常发现存在相同的 Crash&ANR,但是 Bugly 上对应的是多条数据。
而在日常维护中,通过数据分析快速定位问题,能大幅提高我们的效率;基于以上问题,数据分析这块我们做了较多的工具。
-
快速归因 Crash 上涨工具:可以看到,工具输出了按影响用户数增加数量排名的相关 Crash;简单明了的就能看出是哪些 Crash 增加导致的大盘波动。
-
Crash 很多,不可能都解决完,那么找出解决哪些 Crash 收益大就变的很重要了;然而 Bugly 聚合不符预期,会导致找影响用户多的 Crash 变的很难;基于此,我们做了工具去通过堆栈关键字,聚合关联的 Crash,方便分析出 Bugly 平台聚合不够好的但是特征统一的 Crash;指导我们评定 Crash Case 的优先级。
整体 Bugly 数据分析思路:先从 Bugly 获取数据,然后 sql 来分析数据。
容灾
开关&AB 控制功能
在稳定性控制中,配置中心是非常重要的一部分,在我们实战过程中,比如我们新上线功能,我们可以在上线之前对其加上功能开关,通过服务端下发配置的方式,下发开关来决定是否显示新功能的入口,保证新功能处于可控状态,如出现紧急问题能及时关闭功能入口。
对于代码改动,比如重构代码,一般都要求保留之前的老方案,通过配置中心控制是否使用新方案,在新方案上线之后如果有问题,可以切回之前的老方案,保证应用的稳定性和可靠性。
Crash安全垫方案
当Android应用发生异常并且没有捕获时,程序会闪退,直接退出应用,对用户来说影响极大;我们的安全垫是一种兜底 Android 程序稳定性的机制。当应用程序发生未捕获的异常时,安全垫可以捕获异常,触发兜底逻辑,避免程序崩溃,让用户无感继续使用APP。
Java 层安全垫实现方案
我们知道,在 Android 程序运行中,APP 主线程是由 Handler 消息驱动,Handler 不停往 MessageQueue 中发送消息,Looper 则是一个死循环,不断从 MessageQueue 中取出消息,并交给 Handler 处理;整个 APP 由消息驱动运行。
当异常发生时,Looper 循环被异常退出,消息便不会被消费了,所以就算 APP 没有直接闪退,但是用户感知是页面卡死,APP 无法继续使用;所以,当崩溃发生在主线程时,我们主动恢复 Looper 循环,程序即可继续运行。整体方案如下:
关键代码
-
// 获取系统默认的UncaughtException处理器
-
mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler();
-
//注册自定义UncaughtExceptionHandler
-
Thread.setDefaultUncaughtExceptionHandler((Thread t, Throwable e) -> {
-
handleUnCatchException(t, e);
-
});
-
//匹配堆栈
-
if (isExceptionCrash(e)) {
-
//堆栈不匹配则交由默认UncaughtExceptionHandler处理,正常crash
-
mDefaultHandler.uncaughtException(t,e);
-
return;
-
}
-
//堆栈匹配则进行拦截
-
//如果是主线程,则重启Looper
-
if (t == Looper.getMainLooper().getThread()) {
-
while (true) {
-
try {
-
Looper.loop();
-
} catch (Throwable e) {
-
if (isExceptionCrash(e)) {
-
mDefaultHandler.uncaughtException(Looper.getMainLooper().getThread(),e);
-
return;
-
}
-
}
-
}
-
}
当然,在目前 Native Crash 占比较多的情况下,我们在 Native 层也做了安全垫方案。
基于 Router 的客户端容灾方案
在项目组件化之后,页面跳转,以及很多功能调用,通常是通过路由来实现的。如果在线上出现了异常,我们可以在路由跳转这一步拦截有问题的跳转,友好的对用户进行提示,或者重定向到统一的异常处理界面,这样能尽可能减少对用户的影响。
单点追查
针对单个用户反馈的问题,不管是性能问题,还是业务可用性问题,我们排查都依赖详细的日志。我们目前建立了多维度的用户日志和数据平台,比如用户行为埋点,系统或者业务 Log,手机性能数据如内存,CPU,流量等;有了多维度的用户数据,在排查和定位问题时,效率和成功率有了极大的提升。
另外对于系统或者业务 Log 这类大量的日志,我们也做了上传策略优化,保证排查问题时能及时拿到;平时只存储在用户手机,这样一方面尽可能的减少对用户手机性能和流量的消耗,同时也减轻了服务器的数据解析和存储压力。
问题复盘&Case学习
在长期的稳定性建设和问题解决中,我们积累了丰富的经验和知识。为了更好地利用这些宝贵的资源,我们注重沉淀和记录,定期对疑难 Case 进行复盘和学习。这样我们可以避免重复踩坑,提高团队的效率和生产力;同时,也可以不断地提升团队成员的技能和能力,持续提高软件的稳定性和可靠性。
结语
当前我们也存在一些稳定性建设方面的不足,比如对三方SDK的把控没有做的很到位,一些功能对开关的考虑不够周全,反馈用户的Log丢失,容灾覆盖面不够广等等问题。端稳定性治理是一件逆水行舟的事情,要达到高水平的稳定性并不容易,更难的是一直保持高水平。因为代码会在一次次迭代中劣化,所以中间的过程尤其磨人,需要持续不断的耐心和投入。