返回介绍

5.2 异常收集与统计

发布于 2024-08-17 23:46:12 字数 12374 浏览 0 评论 0 收藏 0

目前业界对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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文