网校Android Crash治理之路

3,094 阅读8分钟

**作者:**好未来-Android开发资深工程师-林玉强

崩溃率是很影响用户体验的事情,还最容易流失用户。

网校快速发展多年,增加了无数的业务,开发也使用了无数的第三方库,第三方库良莠不齐。
在19年年底的时候7.0版本的时候,崩溃率还是0.66%。过年开发8.0,年后发版8.0以后,崩溃率还是很高,在周大鑫发起下,邹昊滑栋梁的带领下,开始强攻APP崩溃,目标降到千分之一以下。在团队共同努力下,一个个的改,最终实现了用户崩溃率从千分之七到万分之八。

一.面临的挑战

由于Android碎片化严重这些问题,有些系统崩溃,手机厂商rom的崩溃非常难解。
第三方软件glide崩溃比较多,但是github上有人提过,也没有人解决。
x5浏览器崩溃,也像他们反馈过,好多都是app内存越来越多,运行环境差,内存不够引起的。

二.java常规崩溃

1.NullPointerException

  比较场景且量大的崩溃,而且产生的场景很多。
 (1)变量周期控制
  大家一般都会注意有没有初始化,看代码逻辑没有问题。
  但是在退出的时候时候置为null,收到释放,但是忽略了异步任务。

      mWorkerThread.setOnEngineCreate(new CloudWorkerThreadPool.OnEngineCreate() {
           @Override
           public void onEngineCreate(RTCEngine mRtcEngine, String fileFullPath) {
               if (mRtcEngine != null) {
               }
//                https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/1326941?pid=1
               //已经stop了
               if (mWorkerThread == null) {
                   return;
               }

  创建RTC,等待回调。当老师停止接麦,置为nullpublic void stopRTC() {
       if (mWorkerThread != null) {
           mWorkerThread.exit();
           mWorkerThread = null;
       }
   }

(2)界面跳转
 不合理的界面跳转不用intent,用静态变量的, 正常使用,不会崩溃,用户从后台重启,静态变量回收,就发生崩溃。
 是当时做插件化的时候不支持Parcelable,临时方案。

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_correct_report);
       Bundle extras = getIntent().getExtras();
       scoreInfo = ExerciseSingle.getInstance().getmExerciseResult();
       if (extras == null || scoreInfo == null) {
           finish();
           //https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/32238?pid=1
           XrsCrashReport.postCatchedException(new Exception("" + (savedInstanceState == null)));
           return;
       }

虽然不发生崩溃了,但是这种写法不合法。需要改技术方案。
(3)intent传值失败
大家都知道。Activity的intent除了传递基础类型。还有Serializable和
Parcelable两种复杂类型。第一种是java的,第二种是android新加的。
(VideoLivePlayBackEntity) bundle.getSerializable(LiveVideoConfig.videoliveplayback)
Serializable有可能失败。以前是通过提示让用户重试解决。

        if (mVideoEntity == null) {
            XESToastUtils.showToast("参数错误,请重新进入");
            activity.finish();
            XrsCrashReport.postCatchedException(new Exception());
            return false;
        }

改成Parcelable解决问题,而且传输效率高

2.IllegalArgumentException

(1)glide加载图片
在Activity finish之后调用

统一在 ImageLoader 换成 Application,glide使用Activity会控制生命周期。所以这种写法是暂时的。需要上次调用都判断加载的时候Activity是不是finish。

    public static SingleConfig.ConfigBuilder with(Context context) {
        //https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/105?pid=1
        if (context instanceof Activity) {
            context = context.getApplicationContext();
        }
        return new SingleConfig.ConfigBuilder(context);
    }

(2)Recycle Item重复添加问题。
解决这个bug。真是一个折磨。改了几个版本都改不好,最后只能换实现方式。

RecyclerView虽然作为ListView的替代者有着较好的性能提升,但是ListView常用的addHeaderView,addFooterView,在RecyclerView中没有提供这个方法。
有两种方案。一直是head和foot都是一个viewgroup。都往里面加 ,判断type。

     //创建View,如果是HeaderView或者是FooterView,直接在Holder中返回           
     @Override    
     public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {        
        if(mHeaderView != null && viewType == TYPE_HEADER) {            
            return new ListHolder(mHeaderView);       
        }        
        if(mFooterView != null && viewType == TYPE_FOOTER){            
            return new ListHolder(mFooterView);       
        }        
        View layout = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item, parent, false);        
        return new ListHolder(layout);    
    }    

另一种是都当一个个的item,判断是不是head和foot。

    @Override
    public int getItemViewType(int position) {
        if (isHeader(position)) {
            return ITEM_TYPE_HEADER_MIN + position;
        }
        if (isFooter(position)) {
            return ITEM_TYPE_FOOTER_MIN + getFooterIndexInFooters(position);
        }
        return getDataItemType(itemPositionInData(position));
    }

前一个移除。不用通知adapter,直接移除里面的View。
但是LinearLayoutManager不能使用scrollToPosition,后一个需要通知。
如果添加以后不移除,两个都没事。
但是如果有移除,view的postion会变。引起各种isAttached true的崩溃。
然后不让getItemType和postion绑定,每个view固定type,以为能好。但还是崩溃了
后来判断是不是重复添加。然后返回一个其他类型-1234。添加空的ViewHolder,当时容易复现的崩溃后来没有复现了。但是上线以后,还是有崩溃。
后来使用的技术方案是把添加的view包一层。

@Override
           public int getItemLayoutId() {
               //只是一个 RelativeLayout
               return R.layout.ct_video_modle_item;
           }

           @Override
           public void convert(ViewHolder holder, View view, int position) {
               ViewGroup group = (ViewGroup) holder.getConvertView();
               ViewGroup parent = (ViewGroup) view.getParent();
               if (parent != null) {
                   if (parent != group) {
                       parent.removeView(view);
                       group.addView(view);
                   }
               } else {
                   group.addView(view);
               }
           }

然后就没有再复现了。

三.native崩溃

1.硬件发生异常,即硬件(通常是CPU)检测到一个错误条件并通知Linux内核,内核处理该异常,给相应的进程发送信号。硬件异常的例子包括执行一条异常的机器语言指令,诸如,被0除,或者引用了无法访问的内存区域。大部分信号如果没有被进程处理,默认的操作就是杀死进程。SIGSEGV(段错误),SIGBUS(内存访问错误),SIGFPE(算数异常)属于这种信号。
2.进程调用的库发现错误,给自己发送中止信号,默认情况下,该信号会终止进程。在本文中,SIGABRT(中止进程)属于这种信号。
3.带源码的基本都好解决,常用的工具
arm-linux-androideabi-addr2line 可以将崩溃的pc值转化成方法和源文件的行数
arm-linux-androideabi-readelf 读取elf信息,so就是elf格式文件
arm-linux-androideabi-c

filt 可以将符号表的变量方法转成c

类的方法
arm-linux-androideabi-objdump 反汇编,但没有IDA好用
4.java调用native
java类中存一个c层对象的指针long变量。传到c层会强转成指针
这个操作是线程不安全的,所以使用的时候需要加同步锁或者使用只有一个线程的线程池。
(1)语音评测,初始化时间长,所以启动线程。
初始化文件下载,加了同步锁。

初始化模型,单独开了线程。没有加锁。

所以如果在初始化过程中切换语音,就容易崩溃,写代码频繁调用,可以复现。增加同步锁以后,就解决了。

          new Thread("initOfflineSpeech") {
          @Override
          public void run() {
              synchronized (eventId) {
                  if (language != lang) {
                      BuglyLog.e(TAG, "initOfflineSpeech : lang=" + lang + ",return");
                      return;
                  }

在以后的开发中,对这种问题就格外注意,在开发rtc的时候就没有发生类似问题了。
(2)glide崩溃,glide是个第三方库,没有native源码,但是好几个native的崩溃。

通过源码 RecyclableBufferedInputStream 这个初始化的时候传入AssetManager$AssetInputStream。这种输入流肯定是apk里的资源。
看堆栈,是加载的gif图片。在首页找到一个这种图片,开始的解决办法是这个图片只有在显示的时候执行,后台或者切换的时候暂停。这样崩溃就降低了不少。
通过代码查看,这个流是在 LocalUriFetcher 里加载出来的。猜测这个崩溃可能也是多线程造成的。LocalUriFetcher类中 loadData 通过子类StreamLocalUriFetcher获得流。cleanup 有个关闭流的方法。猜测AssetInputStream并不是线程安全的。
多次调用加载图片。在loadData以后起个线程,同时关闭。这两个崩溃都能复现。

第一个崩溃

第二个崩溃

为了解决AssetInputStream线程不安全。写一个 SafeInputStream 代理了它。里面在方法上加 synchronized 关键字。然后多次调用就没有复现

5.xcrah崩溃
app接入了bugly,能按版本统计用户合次数崩溃,但是拿不到原始日志。
做华佗系统,需要按用户查询,所以接入了xcrash。但是上线后发现在步步高设备上有很多崩溃

崩溃的 pc 地址 000050da 使用的aar,没有符号表。通过addr2line获得不了
arm-linux-androideabi-addr2line.exe -C -f -e libxcrash.so 000050da
JNI_OnLoad
??:?
幸好xcrash是开源的。使用ndk源码编译。
c_trace_check_address_valid
xc_trace.c:212
而且注释也写了有些时候会崩溃。

为了验证这个,通过IDA工具反编译so,通过上下文,可以看出来sub_4B0C 就是xc_trace_dumper方法。

在R0崩溃,R0是dword_E988,跳到赋值的地方

为啥新版本到这个地方崩溃的多了。看源码发现执行到这的是发生anr了。
线程 xcrash_trace_dp

崩溃的方法 xc_trace_libart_runtime_instance 是 system/lib/libart.so 的 _ZN3art7Runtime9instance_E
编译源码,使用readelf 解析elf信息
readelf -s -W …/out/target/product/generic/system/lib/libart.so | grep

ZN3art7Runtime9instance_E

使用命令 arm-linux-androideabi-c++filt.exe 可以很方便的解析出来

ZN3art7Runtime9instance_E 是 art::Runtime::instance

所以就是 art/runtime/runtime.h 中的
// A pointer to the active runtime or null.
static Runtime* instance

;
变量。
anr 以后调用 符号表 _ZN3art7Runtime14DumpForSigQuitERNSt3__113basic_ostreamIcNS1_11char_traitsIcEEEE 也就是
art::Runtime::DumpForSigQuit(std::__1::basic_ostream<char, std::__1::char_traits >&)
Runtime 的 void Runtime::DumpForSigQuit(std::ostream& os) 方法
但某些机型(多为步步高)这个有问题,可以使用 setjmp 和 longjmp 做异常处理,c语言的try catch。
调用之前使用signal捕获异常。第一次longjmp返回的res=0,崩溃以后在sighandler方法里
longjmp(envinstance,1);然后longjmp返回的res=1;不进行调用。

最后调用方法signal(SIGSEGV,oldinstance);把就的崩溃处理设置回去,这样才能让xcrash和bugly继续统计崩溃。
6.mmkv崩溃
新版本有几个mmkv的SIGBUS(BUS_ADRERR)崩溃。

SIGBUS就是内存不对齐产生的,但是Android并没有这个问题。
目前存在的基本都是mmap产生的问题。
mmap将一个文件或者其它对象映射进内存。读文件是非常高效。
产生sigbus的原因第一种是,加载到内存以后,其他地方,修改了文件,使文件大小变小发生的。
还有一直是使用memcpy扩大文件,但是磁盘已满。
尝试编译源码,始终对不上,最后在github提问题,他们把debug的so发过来,addr2line解析了一下。

arm-linux-androideabi-addr2line.exe -C -f -e libmmkv.so 00012020
MMKV::oldStyleWriteActualSize(unsigned int)
/Users/lingol/Developer/mmkv/Core/MMKV_IO.cpp:418

memcpy(m_file-&gt;getMemory(), &actualSize, Fixed32Size);

果然是memcpy方法。解决这个可以在初始化的时候判断文件剩余,弹出提示让用户清理空间,微信就是这么做的。
当发送在过程中,还是可以通过setjmp 和 longjmp 进行异常捕获,发送到上层进行提示。

四.系统崩溃

1.Toast崩溃

一般文章都会说这个崩溃,这个是系统7.0上的问题。而且数量很大,而且没有前置if判断可以解决

查看系统源码,后来的系统版本也是通过try解决的
API:25

public void handleShow(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                    + " mNextView=" + mNextView);
            if (mView != mNextView) {
            ...
            ...
                if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
                mWM.addView(mView, mParams);
                trySendAccessibilityEvent();
            }
        }

API:26

public void handleShow(IBinder windowToken) {
           if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                   + " mNextView=" + mNextView);
           if (mView != mNextView) {
           ...
           ...
               if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
               mWM.addView(mView, mParams);
               trySendAccessibilityEvent();
           }
       }

这个Handler是TN类的一个final变量

  private static class TN extends ITransientNotification.Stub {
      private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();

      private static final int SHOW = 0;
      private static final int HIDE = 1;
      private static final int CANCEL = 2;
      final Handler mHandler;

可以通过反射改变它,然后代理它解决。

public static void hook(Toast toast) {
        try {
            boolean isos7 = false;
            if (Build.VERSION.SDK_INT &gt; Build.VERSION_CODES.M && Build.VERSION.SDK_INT &lt; Build.VERSION_CODES.O) {
                isos7 = true;
            }
            if (isos7) {
                if (sField_TN == null) {
                    initField();
                }
                if (sField_TN != null) {
                    Object tn = sField_TN.get(toast);
                    Handler preHandler = (Handler) sField_TN_Handler.get(tn);
                    sField_TN_Handler.set(tn, new SafelyHandlerWarpper(preHandler));
                }
            }
        } catch (Exception e) {
            FrameCrashReport.postCatchedException(e);
        }
    }

    /**
     * https://www.jianshu.com/p/e0bf316045bb
     */
    public static class SafelyHandlerWarpper extends Handler {
        private Handler impl;

        public SafelyHandlerWarpper(Handler impl) {
            this.impl = impl;
        }

        @Override
        public void handleMessage(Message msg) {
            try {
                impl.handleMessage(msg);//需要委托给原Handler执行
            } catch (Exception e) {
                Log.d("XESToastUtils", "handleMessage", e);
                FrameCrashReport.postCatchedException(e);
            }
        }
    }

所以工程统一了Toast,封装类XESToastUtils。
类似都还有一个播放器的

可以使用反射修改变量解决。

2.DeadSystemException

 ![image.png](http://ttc-tal.oss-cn-beijing.aliyuncs.com/1595405585/image.png)
 还是在7.0的系统上非常常见的崩溃,通过getSystemService方法得到系统服务对象。调用它方法的时候容易复现。
 有替换方法的替换,没有的只能try。如果非常必须的可以延迟循环调用几次直到成功。
 比如获得屏幕宽高都有崩溃,使用替代方案。
 使用服务的方法

   public static int getScreenHeight() {
        WindowManager windowManager = (WindowManager) ContextManager.getContext().getSystemService(Context
                .WINDOW_SERVICE);
        DisplayMetrics dm = new DisplayMetrics();
        windowManager.getDefaultDisplay().getMetrics(dm);
        return dm.heightPixels;

不使用服务的方法

public static Point getScreenMetrics(Context context) {
       DisplayMetrics dm = context.getResources().getDisplayMetrics();
       int w_screen = dm.widthPixels;
       int h_screnn = dm.heightPixels;
       return new Point(w_screen, h_screnn);
   }

只要Handler崩溃在handleMessage 绝大多数都可以反射解决。通过设置Handler.Callback将原来的方法代理。如果有Handler.Callback,设置新的,代理旧的。进行异常捕获。编写LooperHook类

    /** 对Handler反射,崩溃中带handleMessage的都可以使用 */
    public static boolean hookCall(final Handler handler, final boolean throwExce) {
        android.util.Log.d(TAG, "hookCall:hand=" + handler + ",throwExce=" + throwExce);
        try {
            if (callbackField == null) {
                callbackField = Handler.class.getDeclaredField("mCallback");
                callbackField.setAccessible(true);
            }
            Handler.Callback callback = (Handler.Callback) callbackField.get(handler);
            if (callback != null) {
                //防止重复设置
                if (!(callback instanceof XesHandlerCallback1) && !(callback instanceof XesHandlerCallback2)) {
                    XesHandlerCallback1 callback1 = new XesHandlerCallback1(handler, callback, throwExce);
                    callbackField.set(handler, callback1);
                }
            } else {
                XesHandlerCallback2 callback2 = new XesHandlerCallback2(handler, throwExce);
                callbackField.set(handler, callback2);
            }
            return true;
        } catch (Exception e) {
            FrameCrashReport.postCatchedException(e);
            Log.e(TAG, "hookCall", e);
        }
        return false;
    }

对android.app.ActivityThread也进行hook

    private static void hookActivityThread() {
        try {
            Class&lt;?&gt; activityThreadClass = Class.forName("android.app.ActivityThread");
            Field field = activityThreadClass.getDeclaredField("mH");
            field.setAccessible(true);
            Object activityThread = activityThreadClass.getMethod("currentActivityThread").invoke((Object) null);
            LooperHook.hookCall((Handler) field.get(activityThread), true);
        } catch (Exception e) {
            Log.d(TAG, "hookActivityThread", e);
        }
    }

但是也不能所以移除都捕获。需要加一些白名单判断

        private boolean isWhiteExce(Exception e) {
            if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.N) {
                if (e instanceof DeadSystemException) {
                    return true;
                }
            }
            //https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/1364347?pid=1
            if (e.getClass().getName().contains("WindowManager$BadTokenException")) {
                return true;
            }else if (e instanceof IllegalArgumentException) {
                //https://bugly.qq.com/v2/crash-reporting/crashes/a0df5ed682/1369716?pid=1
                return ("" + e.getMessage()).contains("not attached to window manager");
            }
            return false;
        }

如果有的崩溃无法反射,也可以使用epic等hook框架,后台配置hook方法,如果还是特定机型,可以加上机型控制。

五.内存问题

这半年没有专门对内存做特别的优化,但是对内存泄露做了很多处理,内存降下来,OOM也将了,引起底层内存不足的abort的崩溃也少了。
java的有专门的第三方库leakcanary可以使用,在团队集体努力下,解决了99.9%的java内存泄露。Activity如果释放,会有很大的提示。
对于android native 内存泄露,一般使用android系统8.0以后的debug_malloc进行检测。
内存监控沿用LIBC的malloc_debug模块。不使用官方方式开启该功能,比较麻烦,不利于自动化测试,离开开发工具就没法用了。可以编译一份放到自己的项目中,hook所有内存函数,跳转到malloc_debug的监控函数leak_xxx执行,这样malloc_debug就监控了所有的内存申请/释放,并进行了相应统计。
android源码中的malloc_debug放到工程,需要5个so。结合爱奇艺的xhook实现,在合适的时候调一下方法,就知道内存有没有释放。
可以监控的内存方法有malloc,free,calloc,realloc,posix_memalign,memalign,aligned_alloc,malloc_usable_size,还有c++的new,delete关键字。
首先监控了psijk的so。就发现一个指针没有释放的问题。虽然只有几十个字节

六.稳定控制

1.使用leakcanary以后,这个工具发现问题,会弹出警告,测试就会提一个jira问题,把信息放进去,相应的开发需要解决。
2.灰度发版。目前每个大版本测试都会组织内测。网校大部分开发,测试和产品都会参加,会把问题记录下来。有时候的崩溃可能没有发现。但是bugly统计的每一个崩溃,都会解决,由于内测的原因,好多解决过的bug又崩溃了。查到以后发现是前几天安装的,所以后台打包增加了编译时间放到了bugly。
发到用户那边是小渠道发放,发现崩溃就解决,继续放量。
后来又新增了自动化测试,崩溃会提到jira上。
通过两次灰度,使崩溃率基本稳定。

**作者 **

好未来-Android开发资深工程师-林玉强

招聘信息

好未来技术团队正在热招前端、算法、后台开发等各个方向高级开发工程师岗位,大家可扫描下方二维码或微信搜索“好未来技术”公众号,点击“技术招聘”栏目了解详情,欢迎感兴趣的伙伴加入我们!

也许你还想看

DStack--基于flutter的混合开发框架

WebRTC源码分析——视频流水线建立(上)

"考试"背后的科学:教育测量中的理论与模型(IRT篇)