5.2 异常收集与统计
目前业界对App线上Crash的收集一般有2种,要么记录到第三方平台,要么记录到自己的数据库中。
使用第三方Crash收集分析平台的好处是,他们能提供一套完整的Crash分类和报表统计工具。比如腾讯的Bugly平台,他们还能提供技术支持,告诉你某类要怎么修复。
接下来我要介绍的是,如何记录到自己的数据库中,然后自行统计分析这些Crash数据。其实并不难。
5.2.1 人工统计线上Crash数据
最一开始,我们是通过人工的方式手动统计这些Crash数据的,当时是把这活儿分给了新来的3个Android开发人员,因为新人往往有股子冲劲儿。
第一次我们用了3天时间,分析了1天的Crash数据,大约2000多笔,对每个Crash进行了分类,我们在分析中就发现:
1)有很多重复的Crash。这其中分很多种情况。
·有不同设备在不同时间发出来重复的Crash,这时候要检查是否只对某些机型或Android版本才会发生类似问题,比如说Android2.1不支持https。
·有不同设备在一个时间段发出来重复的Crash。这时候要检查MobileAPI是否返回了脏数据而App没有使用try…catch…语句捕获到。
·有相同设备在很短的时间段内频繁发送了重复的Crash。这是因为App没有做好崩溃后的善后工作导致的,它试图重新启动发生崩溃的那个Activity,然后重启过程中因为要重新执行onCreate方法而这个方法有空指针,于是就会造成“崩溃—重启—崩溃—重启”的死循环,直到用户强制关闭App。对此,我们需要去除重复数据。
2)每笔异常信息都包括以下2部分数据信息:
·exception_name:Crash对应的异常名称。
·exception_stack:Crash的详细信息。
不要以exception_name作为Crash分类的标准,这是不准确的。比如说,因为空指针NullPointer导致的崩溃,但是exception_name却是RuntimeException。所以exception_name只能作为Crash的参考标准,而产生Crash的真正原因,则隐藏在exception_stack中。
3)exception_stack中含有OutOfMemory内容的,都是内容溢出导致的。但是逆命题不成立。因为有些内容溢出导致的崩溃,抛出的异常信息却不包括OutOfMemory内容,比如说ResourcesNotFoundException,有很多情况是资源明明存在于App中但还是说找不到,“睁眼说瞎话”,于是我们也只好眼睁睁地看着它崩溃了而无能为力。
4)对于空指针NullPointerException这个“不治之症”,我们观察到的情况是,NullPointerException只是导致崩溃的结果,而不是原因。导致空指针的情况五花八门,有时,我们要留意exception_stack中Cause by后面的内容,如下所示:
java.lang.RuntimeException: Failure delivering result ResultInfo {who=null, request=3, result=-1, data=Intent{ (has extras) contextId=0, taskId=0 }} to activity {包名称 /Activiyy名称 }
如果只看前半段信息,根本不知道问题所在,继续向下看,会发现在Cause by处有空指针的提示信息,如下所示:
Caused by: java.lang.NullPointException at包名称 .Activity名称 .onActivityResult(Unknown Source) at android.app.Activity.dispatchActivity(Activity.java:5352) at
5)窗体泄露这类问题,基本都是想关闭弹出框的时候,却发现承载它的宿主已经不在。
6)ListView和Adapter相关的Crash基本都发生在分页获取数据的场景,数据源发生了改变,却没有及时通知ListView和Adapter。
5.2.2 第一个线上Crash报表:Crash分类
在找到这些Crash的共性后,我们开始调整Crash分析的策略。我会引入SQL Server和C#作为分析的工具。
1)首先,每天上班,我会把昨天24小时的Crash数据从服务器上取下来,导出为excel文件,然后再把excel还原到本地SQL Server数据库的CrashDB这个表。这样我就可以在本地数据库上写各种各样的SQL语句来分析这些数据了,不必担心直接在线上直接操作SQL而把线上数据库搞死。
2)接下来我会执行一个存储过程UpdateCrashDesc,把这些Crash数据分门别类,为此,我要为存放Crash的表CrashDB加一个字段crash_desc,用来表明Crash是哪个类别的。
存储过程UpdateCrashDesc的逻辑就是把符合某类特征的那些Crash,设置其crash_desc字段为同一个值,如下所示:
CREATE PROCEDURE [dbo].[UpdateCrashDesc] AS BEGIN SET NOCOUNT ON; update CrashDB set crash_desc = '内存溢出 ' where exception_stack like '%OutOfMemory%' and crash_desc is null update CrashDB set crash_desc = 'ClassCastException' where crash_desc is null and exception_stack like '%java.lang.ClassCastException%' update CrashDB set crash_desc = '数组越界 ' where crash_desc is null and exception_stack like '%OutOfBoundsException%' update CrashDB set crash_desc = 'java.lang.VerifyError' where crash_desc is null and exception_stack like '%java.lang.VerifyError%' update CrashDB set crash_desc = '各个页面的空指针 ' where crash_desc is null and exception_stack like '%NullPointerException%' update CrashDB set crash_desc = 'is your activity running?' where crash_desc is null and exception_stack like '%is your activity running?%' - -中间省略若干 Update语句 update CrashDB set crash_desc = '不明觉厉 ' where crash_desc is null END
考虑到章节限制,我只贴出了UpdateCrashDesc这个存储过程的部分代码,全部代码请参见我博客上的源码 [1] 。值得注意的是:
每条Update语句代表做一次分类操作。一开始我也只有十几条Update语句,后来慢慢扩充到五十几条。每次新增一个Crash分类,就加在“不明觉厉”这条Update语句之上即可。
“不明觉厉”这个Crash类别,是经过上述五十几条Update语句筛选后,剩下的Crash。这类Crash数量不能超过100,否则,就应该从中继续寻找共性,拆分成新的Crash类别,编写一个新的Update语句。
3)接下来我会执行一个存储过程GroupOnlineCrash,用以统计各类Crash的数量。
CREATE PROCEDURE [dbo].[GroupOnlineCrash] AS BEGIN SET NOCOUNT ON; select * into #temp1 from CrashDB where client_type=20 order by page_name, exception_name, exception_stack select crash_desc, COUNT(crash_desc) as count from #temp1 group by crash_desc order by COUNT(crash_desc) desc END
执行结果如图5-1所示。
图5-1 线上Crash统计图
对于图5-1中排名前10的线上Crash,我们要花大力气去分析、修复它们。
5.2.3 第二个线上Crash报表:Crash去重
我们在前面手工统计分析的时候就已经发现,Crash有很多重复。我们接下来要去重,从而看出每天到底有多少种不同的Crash。
去重工作由4部分组成,如图5-2所示。对这4部分工作介绍如下。
图5-2 去重工作的4个步骤
1.去除数字不同导致的重复
去重主要是在exception_stack字段上做文章,也就是Crash的详细信息。我们发现,很多时候同一类Crash,它们的exception_stack字段仅仅是数字的不同,比较典型的有以下几种情况:
·发生崩溃时的代码行不同,如下所示:
package manager has died at android.app.ActivityThread .performLaunchActivity(ActivityThread.java:2215) package manager has died at android.app.ActivityThread .performLaunchActivity(ActivityThread.java:2296)
·运行时的数值不同,如下所示。
·崩溃信息中的#后面的数字不同:
android.view.InflateException: Binary XML file line #8: Error inflating class<unknown> android.view.InflateException: Binary XML file line #32: Error inflating class<unknown>
·崩溃信息中的result=后面的数字不同:
java.lang.RuntimeException: Failure delivering result ResultInfo {who=null, request=5, result=-1 java.lang.RuntimeException: Failure delivering result ResultInfo {who=null, request=3, result=-1
·崩溃信息中的ViewRootImpl$W@@后面的数字不同:
android.view.WindowManager$BadTokenException: Unable to add window - - token android.app.LocalActivityManager $LocalActivityRecord@45a58ee0 is not valid; is your activity running? android.view.WindowManager$BadTokenException: Unable to add window - - token android.app.LocalActivityManager $LocalActivityRecord@4012ef33 is not valid; is your activity running?
虽然数值不同,但其实是相同的Crash。一种好的去重方案是将这些数值都统一改为1000。这样exception_stack字段就全都一样了。但是一旦改成1000,就没机会恢复为之前的值了,所以我的做法是,在CrashDB这个表再增加一个字段dis_info,把exception_stack这个字段的数据复制一份到dis_info字段,然后我们在dis_info字段上进行修改。修改的方法是借助于正则表达式,进行批量替换。
按照上述思路,我使用C#写了一个小工具,它可以遍历CrashDB表中的每个Crash,取出它的exception_stack,使用正则表达式进行替换,然后赋值给dis_info字段。
以下是C#中使用正则表达式进行替换的实现代码:
string dis_info = (String)read["exception_stack"]; // from .java:836) // to .java:1000) string str = Regex.Replace(dis_info, @".java:\d*", @".java:1000"); // from window android.view.ViewRootImpl$W@41ec8258 // to window android.view.ViewRootImpl$W@12345678 // @后面是 8位字符 string str2 = Regex.Replace(str, @"\w{8}*", @"@12345678"); // from request=327681 // to request=1000 string str3 = Regex.Replace(str2, @"request=\d*", @"request=1000"); // from #4: // to #1000: string str4 = Regex.Replace(str3, @"#d*", @"#1000"); dicCrash[(String)(read["id"].ToString())] = str4;
因为数字的不同而导致的Crash不能去重的问题,不仅限于上述这几种情况。我们应该具体问题具体分析,每发现一种新情况,就在程序中增加相应的正则表达式,进行批量替换。
那么接下来,我们只要使用下述SQL语句就能取得去除重复的数据了,不受Crash信息中数字不同的影响:
select distinct page_name, dis_info from CrashDB order by page_name, dis_info
在对CrashDB表中的四万笔数据执行这个SQL语句后,得到3000多笔数据,重复数据大幅减少。
我对上述C#程序进行封装,做成一个工具,可以在配置文件中增加新的正则表达式,我将这个工具称为AnalysisCrash,图形界面如图5-3所示。
图5-3 AnalysisCrash
相应的配置文件RegexRules.xml,如下所示:
<?xml version="1.0" encoding="utf-8" ?> <Rules> <Rule name="r1" from=".java:\d*" to=".java:1000" /> <Rule name="r2" from="@\w{8}" to="@12345678" /> <Rule name="r3" from="request=\d*" to="request=1000" /> </Rules>
2.去除其他情况的重复
我还观察到,有很多Crash信息,它们仅仅是长度的不同,比如说B的Crash信息比A多了一块。相应的解决方案是,对exception_stack从起始位置取150个字符,再进行distinct去重。这样就又能少大量的Crash数据了,SQL语句如下所示:
select distinct page_name, SUBSTRING(dis_info, 1, 150) from CrashDB order by page_name, SUBSTRING(dis_info, 1, 150)
这样Crash数量就从3000降低到了1200个。
也许你会问我为什么是150,我只能说这是试出来的。如果设置为200,会略显宽松,执行上述语句后,Crash数量会变成1300个;如果设置为100,则又太严格,Crash数量会变成1100个。我们可以根据实际情况动态调整这个值。
不要轻易满足于筛选后的这1200笔数据。这里面还是有很多水分的。纵观去重后的1200笔数据,里面重复的Crash数据还是很多。我们只能根据不同Crash,相应的给出不同的去重方案。
比如说,对于VerifyError这样的Crash,它的page_name字段不是某个Activity页面,而是具有相同的值Application,而对于exception_stack字段则仅仅是类的名称的不同,如下所示:
java.lang.VerifyError: Rejecting class com.company.app.activity.ActivityA that attempts to sub-class erroneous class com.company.app.activity.BaseActivity (declaration of 'com.company.app.activity.ActivityA') appears in /data/app/com.company.app.ui-2.apk
对于这个Crash,我们的解决方案是,只要exception_stack的前38个字符是java.lang.VerifyError:Rejecting class,都视为一个Crash。在执行distinct语句之前,先排除这类Crash。
select * into #temp1 from CrashDB delete from #temp1 where SUBSTRING(dis_info, 1, 38) = 'java.lang.VerifyError: Rejecting class' select distinct page_name, SUBSTRING(dis_info, 1, 150) from #temp1 order by page_name, SUBSTRING(dis_info, 1, 150)
执行上述语句后,Crash数据从1200降低为1100。
我们需要不断地增加新的规则,从而进一步优化我们的去重结果。这就需要投入人力砸在上面去做了。很多第三方Crash收集平台也是基于这个思路去设计的。
3.去除同一版本之前的重复
如何确保昨天统计过的Crash,今天不会再统计?
相应的解决方案是把今天的线上Crash放到一个数据表CrashStore中,对于第二天的线上Crash数据,先到CrashStore表中去重,那么剩下来的Crash数据就是新的了。
为此我们设计CrashStore表结构如图5-4所示。
图5-4 CrashStore表结构
然后编写一个存储过程UpdateCrashStore,我为每个SQL操作都添加了注释,仅供参考:
CREATE PROCEDURE [dbo].[UpdateCrashStore] @version varchar(30) AS BEGIN SET NOCOUNT ON; select * into #temp1 from CrashDB -- 1. 排除 java.lang.VerifyError之类 Crash对去重结果的影响 delete from #temp1 where SUBSTRING(dis_info, 1, 38) = 'java.lang.VerifyError: Rejecting class' -- 1.x 这里可以添加其他排除语句,减少对去重逻辑的干扰 -- 2. 取 dis_info的前 150个字符,去重 select distinct page_name, SUBSTRING(dis_info, 1, 150) as sub_crash_desc into #temp2 from #temp1 order by page_name, SUBSTRING(dis_info, 1, 150) -- 3. 在 CrashStore表中,取出当前版本的之前已经统计过的 Crash select * into #tempCrashStore from CrashStore where app_version=@version -- 4. 使用 left join语句,筛选出今天的、未统计过的 Crash select t.page_name, t.sub_crash_desc into #temp4 from #temp2 t left join #tempCrashStore c on c.page_name = t.page_name and c.sub_crash_desc = t.sub_crash_desc where c.categoty_id is null -- 5. 将今天统计的 Crash放入 CrashStore表 insert CrashStore(page_name, sub_crash_desc, sub_crash_length, app_version) select distinct page_name, sub_crash_desc, 150, @version from #temp4 END
4.按照Activity,把Crash自动分发到人
这一步不属于去重工作,而是一件锦上添花的工作。我们在给出Crash报表后,发现并没有把每个Crash落实到具体的开发人员身上。
为此,我们设计PageOwner表,用来记录每个Activity应该由哪位开发人员负责修复的对应关系,表结构如图5-5所示。
表中的数据可以如图5-6所示。
图5-5 PageOwner的表结构
图5-6 PageOwner中的数据
那么在出报表的时候,与PageOwner这个表进行匹配,就能得出每个Crash应该谁来负责修复了。
至此,一套完整的Crash去重流程就做完了。我们再重新梳理一下上述去重的流程,如图5-7所示。
本节所介绍的线上Crash分析流程,在Android发版后每天都要做一遍,把昨天24小时内产生的线上Crash分析一遍。一般而言,发版后的头2天,Crash数据不太多,因为很多人还没有升级App到最新的版本,发版后的第3到5天,基本就能收集到这个版本95%的线上Crash。再往后,虽然Crash数量也很多,但大都重复,再投入时间分析,意义不大。
我们可以把上述过程中的建表、执行SQL脚本、执行C#程序这些操作串起来,做成自动化执行脚本,这样就能大大节省人力成本了。
图5-7 去重流程
5.2.4 线上Crash的其他分析工作
对于我而言,上述两节所整理出来的报表基本就能满足我的需求了。但这还远远不够,如果有人力,应该把以下工作也完成:
1)对Crash进行归纳,从而知道每类Crash发生的次数、涉及的机型、涉及的Android系统版本。
我们曾经按照page_name,dis_info这两个维度对Crash进行了去重。对这些去重了的Crash数据,当我们点击其中一个Crash时,应该能够看到有多少种机型、哪些版本的Android系统,发生过这类Crash。
这是个一对多的关系,我们需要根据去重后的Crash数据,反向查找每种Crash在CrashDB表中出现过多少次,以及相应的Crash信息。
我们在前面创建了CrashStore表,这个表中的category_id字段是自增的,相应的,我们要在CrashDB这个表也增加category_id字段,它们是一对多的关系,这样就做到了点击一种Crash,能够知道每类Crash发生的次数、涉及的机型、涉及的Android系统版本。
接下来就是写个C#程序,把CrashDB表中的category_id字段值都反向填充上。
2)目前第三方平台的Crash统计工具是即时的,也就是说服务器每收到一个Crash,就会将其归类,而不是要等到一天结束后才一起进行分析。
此外,我们应该基于线上的Crash数据,做一个Crash查询平台,开发人员可以根据App版本、Crash发生时间段、机型、Activity页面等条件来查询相应相应的Crash。
这个平台还应该提供Crash趋势图,它能绘制出一天24小时的线上Crash趋势图。如果在某个时间段Crash数量激增,一定是有重大事情发生,比如MobileAPI返回了脏数据。
[1] 代码地址为:http://www.cnblogs.com/Jax/p/4573575.html。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论