专栏名称: 鸿洋
你好,欢迎关注鸿洋的公众号,每天为您推送高质量文章,让你每天都能涨知识。点击历史消息,查看所有已推送的文章,喜欢可以置顶本公众号。此外,本公众号支持投稿,如果你有原创的文章,希望通过本公众号发布,欢迎投稿。
目录
相关文章推荐
stormzhang  ·  特斯拉画的饼,圆上了? ·  2 天前  
鸿洋  ·  裁员在家如何保持高效学习 ·  4 天前  
鸿洋  ·  2024 了, Insets 你不会还不懂吧? ·  1 周前  
51好读  ›  专栏  ›  鸿洋

Android 复杂项目崩溃率收敛至0.01%实践

鸿洋  · 公众号  · android  · 2024-10-15 08:35

正文

1
崩溃收敛机制

创建修BUG分支

在我们的项目中,每个版本发布之后,我们会创建一个opt分支,用于修复线上崩溃以及业务逻辑BUG。
开发过程中,一个APP可能同时并行开发多个需求,每个需求上线的预期时间可能会有不同。但是这个opt分支我们会保证在下个版本一定上线,QA同学也会在每个版本发布前预留测试opt分支的时间。

每天早晨查看Dump后台

每天上班第一件事就是查看DUMP后台,收集昨天线上发生的DUMP崩溃,具体的堆栈分配给对应的业务负责人。

业务负责人收到崩溃之后,会优先跟进排查。排查下来如果相对好修复,会第一时间直接修复掉,并提交到opt分支。如果排查下来发现,较难定位或者耗时较久,则需要给出修复预期。也可以将Bug转为技术优化,作为专项推进。因为确实有一些Bug需要通盘考虑,所有业务配合。

2
崩溃容灾机制

背景

我们为什么要开发一套崩溃容灾逻辑?
在对线上崩溃进行收敛时,我们发现线上有几类崩溃是我们在应用无法修复的。

例如:案例一

java.lang.NullPointerException: Attempt to invoke virtual method 'boolean android.content.ClipDescription.hasMimeType(java.lang.String)' on a null object reference
    at android.widget.TextView.canPasteAsPlainText(TextView.java:15065)
    at android.widget.Editor$TextActionModeCallback.populateMenuWithItems(Editor.java:4692)
    at android.widget.Editor$TextActionModeCallback.onCreateActionMode(Editor.java:4627)


案例二:小米手机上出现

java.lang.NullPointerException:Attempt to invoke virtual method 'int android.text.Layout.getLineForOffset(int)' on a null object reference


案例三:集成华为推送SDK后,偶现

ava.lang.RuntimeException:Unable to start activity ComponentInfo{com.netease.popo/com.huawei.hms.activity.BridgeActivity}:
android.util.AndroidRuntimeException: requestFeature() must be called before adding content"


案例四:BadTokenException

android.view.WindowManager$BadTokenException:Unable to add window -- token android.os.BinderProxy


我们大致将以上问题划分为四类:
  • 我们认为是系统异常,应用层仅能在使用的位置try cache,有些崩溃甚至无处try cache;
  • 排查下来发现仅在某个厂商的手机上出现;
  • 集成的一些第三方SDK所引入,依赖对方修复,时间上不好掌控;
  • 由于Android系统的一些机制引发的崩溃,如弹出弹框时,恰好依赖的Actity正在销毁。业务层希望弹框可以不弹出但不要崩溃,可是系统最终是抛出来一个BadTokenException。我们可以在使用DiaLog时做判断,但是总会有同学忘记。
基于以上我们思考是否可以开发一个框架,将这些崩溃统计进行拦截,使其不影响用户的使用。

技术方案

作为Android开发应该都比较清楚Handler机制。我们的崩溃容灾主要是利用了Handler机制。具体的逻辑图如下:
  • 应用启动后,初始化崩溃白名单(应用内置,也支持服务端动态下发)。
  • 通过Handler#post()方法,向主线程中发送一条消息。
  • Runnable#run()方法中,执行一个死循环逻辑。
  • 死循环中逻辑中使用try cacheLooper.loop()防护。
  • 这样只要应用的进程不结束,相当于任务一直执行在我们前面post的消息中。
  • 只是我们在这个消息中,再次执行了Looper.loop()方法,执行后续消息队列中所有的消息。
  • 一旦后续所有消息遇到崩溃,会先被try cache捕获。
  • 然后判断崩溃信息是否在我们的白名单中,一旦在白名单中直接捕获掉,不向外抛异常,逻辑会回到外部的死循环中,继续执行Looper.loop()方法获取后续的消息。这样就保证了逻辑的连贯,后续的事件可以继续处理。
  • 不在我们的白名单中则继续将这个异常throw出去。

现状

崩溃拦截框架上线至今几年的时间,积累的崩溃种类目前已经达到81种。
比较典型的除了上面介绍的几类崩溃以外,还有如我们在适配Android 12的SplasScree时,遇到的TransferSplashScreenViewStateItem 相关的错误。
java.lang.IllegalArgumentException: Activity client record must not be null to execute transaction item: android.app.servertransaction.TransferSplashScreenViewStateItem@de845fa
at android.app.servertransaction.ActivityTransactionItem.getActivityClientRecord(ActivityTransactionItem.java:85)
at android.app.servertransaction.ActivityTransactionItem.getActivityClientRecord(ActivityTransactionItem.java:58)
at android.app.servertransaction.ActivityTransactionItem.execute(ActivityTransactionItem.java:43)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:149)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:103)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2708)
at android.os.Handler.dispatchMessage(Handler.java:114)
at android.os.Looper.loopOnce(Looper.java:206)
at android.os.Looper.loop(Looper.java:296)

按照过往的手段,我们仅能等待Google官方来修复这个错误,或者先下线掉这个错误。本框架可以直接将其进行拦截,同时用户无感知。

3
其他崩溃收敛

我们针对自身业务特点,大致梳理以下几种业务中非常常见的崩溃场景,提醒每一位同学注意。同时内部维护了一个研发质量表,每个需求提测时我们会过一遍研发质量表格,提醒同学注意相关代码质量与性能。

空指针问题

NPE应该是最常见的问题了。针对NPE的问题,我们的解决方式有:
  • 推荐组内所有同学习惯使用注解@NonNull@Nullable
  • 推荐大家使用Kotlin;并且在Java调用kotlin方法时,一定要注意Kotlin方法是否要求入参不为空。
  • 从List、Map中取到的对象,使用前必须判空。
  • 业务代码需要将Context传给第三方工具类,传入之前必须判空。
  • 对象建议声明为final或者val,防止后续其他位置置空引起NPE。
  • 外接传入的对象使用前必须判空。
  • 基于AS插件进行检测。

IndexOutBoundsException

角标越界异常在平时开发中也特别常见,在我们的业务中常见于集合以及Span操作。
  • 集合传入index时需要判断是否在[0, size]内。
  • 操作Spannable接口setSpan方法时,需要start与end的数值不会超过长度,同时不能够为负数。

ConcurrentModificationException 并发修改异常

并发修改异常在复杂的业务中,是非常容易遇到的。通常有两个场景容易触发,分别是foreach循环中直接调用remove方法移除元素,以及线程不安全环境下使用线程不安全集合。
针对并发修改异常:
  • 我们推荐在遍历集合时,可以new一个新的List集合,将要遍历的List集合作为参数传入,然后遍历新的集合。这样原集合在遍历时改变也不会抛异常。
  • 使用线程安全的集合,如CopyOnWriteArraylistConcurrentHashMap等。

系统服务(FrameWork API)

  • 调用系统服务通常需要跨进程通信,其内部很可能会抛异常,所有调用系统服务的地方都必须使用try cache。cache异常必须写入日志文件,根据业务重要性判断是否需要上报埋点数据。
  • 系统服务频繁调用时可能会引发ANR,这点也需要特别注意。

数据库类问题

由于我们的业务重度依赖数据库,所以数据库相关的问题占比也比较高。主要有以下几类问题:

CursorWindowAllocationException 2048问题:

com.tencent.wcdb.CursorWindowAllocationException
Cursor window allocation of 2048 kb failedtotal:8159,active:49
at com.tencent.wcdb.CursorWindow.<init>(SourceFile:127)


针对CursorWindowAllocationException,在我们的工程中主要是短时间内大量的内存申请。解决方案是基于SQL监控,统计工程中SQL执行的数量,基于SQL语句针对性的优化相关逻辑,将SQL语句执行数量降低了90%以上,这个问题线上不再复现。

存储空间不足

Caused by
com.tencent.wcdb.database.SQLiteFullException:database or disk is full (code 13,errno 0): 
    at com.tencent.wcdb.database.SQLiteConnection.nativeExecute(Native Method)
    at com.tencent.wcdb.database.SQLiteConnection.execute(SourceFile:728)
    at com.tencent.wcdb.database.SQLiteSession.endTransactionUnchecked(SourceFile:436)
    at com.tencent.wcdb.database.SQLiteSession.endTransaction(SourceFile:400)
    at com.tencent.wcdb.database.SQLiteDatabase.endTransaction(SourceFile:533)
    at com.tencent.wcdb.room.db.WCDBDatabase.endTransaction(SourceFile:100)


针对存储空间不足,在我们的APP中主要是添加手机存储空间检测,当空间不足时引导用户清理。

数据库损坏

com tencent wcdb database.SQLiteDatabaseCorruptExceptiondatabase disk image is malformed (code 11, errno 0): 
at com.tencent.wcdb.database.SQLiteConnection.nativePrepareStatement(Native Method)
at com.tencent.wcdb.database.SQLiteConnection.acquirePreparedStatement(SQLiteConnection.java:1004)
at com,tencent.wcdb.database.SQLiteConnection.executeForString(SQLiteConnection.java:807)
at com.tencent.wcdb.database.SQLiteConnection.setJournalMode(SQLiteConnection.java:424)
at com.tencent.wcdb.database.SQLiteConnection.setWalModeFromConfiguration(SQLiteConnection.java:414)
at com.tencent.wcdb.database.SQLiteConnection.open(SQLiteConnection.java:289)
at com.tencent.wcdb.database.SQLiteConnection.open(SQLiteConnection.java:254)
at com,tencent.wcdb.database.SQLiteConnectionPool.openConnectionLocked(SQLiteConnectionPool.java:603)
at com.tencent.wcdb.database.SQLiteConnectionPool.open(SQLiteConnectionPool.java:225)
at com.tencent.wcdb.database.SQLiteConnectionPool.open(SQLiteConnectionPool.java:217)
at com.tencent.wcdb.database.SQLiteDatabase.openInner(SQLiteDatabase.java:1002)


数据库损坏我们是引入了修复工具进行修复,同时对数据库损坏崩溃进行拦截。修复完成后退出到登录页面引导用户重新登录。

数据库的崩溃问题一度在我们的工程中占比超过50%,所以我们有启动数据库优化专项投入大量时间。

4
OOM问题收敛

OOM介绍

OOM的问题在Android中也是非常常见了,所以这里单独拎出来说说。OOM产生的条件:待申请的内存大于系统分配给应用的剩余内存。OOM原因大致可以归为以下几类
  • 堆内存分配失败。
  1. 堆内存溢出。
  2. 没有足够的连续内存空间。
  • 创建线程失败(pthread_create (1040KB stack) failed: Try again)。
  • FD数量超出限制。
  • Native虚拟内存OOM。
  • 内存泄露监控

    线上内存泄露的监控我们是使用的快手的KOOM。KOOM原理这里笔者就不详解了,社区内也有专门分析的文章,大家可以找找看,不过还是建议去读读源码,写的挺不错的。
    将KOOM分析的报告上报到我们的后台中,有专门的同学每周会排时间跟进。

    全局浮窗实时显示APP当前总体内存

    除了线上的监控,我们也有一个自研的开发者工具。工具有一个浮窗功能,我们会在浮窗上实时显示当前应用的内存信息(每秒采集一次)。数据主要是通过获取Debug.MemoryInfo#getTotalPss()。与Android Studio Profile中Memory数据基本一致。同时在UI层面我们还会设置一个阈值,超过阈值就会将浮窗中内存的数值颜色改为红色,旨在提醒开发同学关注内存变化。
    通过实时显示内存我们在开发过程中,就可以发现一些问题。如我们再进入某一个业务时,发现内存会固定涨50M+,基于此开发同学去排查,发现了很多优化点。线下发现这个问题的意义就是可以质量左移,避免到线上影响到用户。

    线程数量监控与收敛

    获取线程数量,我们可以读取文件/proc/[pid]/status 中的线程数量,代码大致如下:
    public static String readThreadStatus(String pid){
        RandomAccessFile reader2= null;
        try {
            reader2 = new RandomAccessFile("/proc/" + pid + "/status""r");
            String str;
            while ((str = reader2.readLine()) != null) {
                if (str.contains("Threads")) {
                    return str;
                }
            }
            reader2.close();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (reader2 != null) {
                    reader2.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        return "";
    }


    在前面提到的开发者工具的浮窗中,我们也有一行用来实时显示线程的数量。不过在我们的工具中,我们没有使用上面的方法,而是使用:Thread.getAllStackTraces();
    使用Thread.getAllStackTraces();获取的数量比 /proc/[pid]/status 获取的少,但是在我们的工程中,我们主要关注Java线程而且通过Java线程的数量的波动也能观察到App当中线程的变化。而且Thread.getAllStackTraces()会返回Thread对象,以及堆栈数据这个对我们更加有用。
    在开发者工具我们有一个单独的页面可以实时查看线程的ID、名称以及对应堆栈。在线上我们会间隔一段收集一次线程数据上报到我们的后台中。
    在笔者过往的开发经历中,遇到过一次由于线程数量较多直接导致应用崩溃的情况,即某个独立业务使用OkHttp没有创建OkHttpClient单例对象,而是每次接口回调都创建一个新的client...
    定位过程比较简单,在特定的场景下,可以看到浮窗中的线程数量基本处于线性增长,通过开发者工具查看线程列表可以直接看到非常多的OkHttp相关的线程。

    FD 数量监控

    获取FD数量大致可以通过以下代码:
    public static int getCurrentFdSize() {
        int size = 0;
        File dir = new File("/proc/self/fd");
        try {
            File[] fds = dir.listFiles();
            if (fds != null) {
                size = fds.length;
                for (File fd : fds) {
                    if (Build.VERSION.SDK_INT >= 21) {
                        MLog.d("message", Os.readlink(fd.getAbsolutePath()));
                    }
                }
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
        return size;
    }


    同时在KOOM中每次分析的结果中会携带所有FD句柄信息,所以我们没有单独做额外的监控了,直接查看KOOM的解析数据。
    笔者仅遇到过一次由于FD句柄超限导致的异常。异常信息如下:
    java.lang.RuntimeException: Could not read input channel file descriptors from parcel.
    at android.view.InputChannel.nativeReadFromParcel(Native Method)
    at android.view.InputChannel.readFromParcel(InputChannel.java:148)
    at android.view.InputChannel$1.createFromParcel(InputChannel.java:39)at android.view.InputChannel$1.createFromParcel(InputChannel.java:37)
    at com.android.internal.view.InputBindResult.(InputBindResult.java:68)
    at com.android.internal.view.InputBindResult$1.createFromParcel(InputBindResult.java:112)at com.android.internal.view.InputBindResult$1.createFromParcel(InputBindResult.java:110)at com.android.internal.view.IInputMethodManager$Stub$Proxy.startInputOrWindowGainedFocus(IInp
    at android.view.inputmethod.InputMethodManager.startInputInner(InputMethodManager.java:1361)
    at android.view.inputmethod.InputMethodManager.onPostWindowFocus (InputMethodManager.java:1631)
    at android.view.ViewRootImpl$ViewRootHandler.handleMessage(ViewRootImpl.java:4259)
    at android.os.Handler.dispatchMessage(Handler.java:109)
    at android.os.Looper.loop(Looper.java:166)


    后面经过排查进入内置浏览器查看某网页,FD句柄的数量会瞬间飙升。通过遍历 /proc/pid/fd 文件发现大多是都是Socket。后面排查下来是应用内某个前端页面存在Bug,疯狂new Socket...

    5
    总结


    以上简单介绍了一下我们在工程中如何针对各类崩溃信息进行收敛,值得欣喜的是经过几年的努力基本可以将崩溃控制在万一。回过头来看很多问题还是遇到问题-解决问题的思路,这就依赖我们的开发同学本身所写的代码质量要高,否则就会陷入到写Bug-改Bug这样的循环中。
    所以我们也在积极探索如何通过前期的review,工具扫描的方式尽量降低线上问题的发生概率。尽可能将问题提前暴露。不过这方面目前还没有建设的特别好,人工review实验了一段时间发现在一个业务较多的团队的实施起来很难,没有时间不说盯着代码review也不见得就能发现一些逻辑上的异常。好在公司内其他团队再研究基于AI的代码扫描,后续计划接入到当项目中看看。


    最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


    推荐阅读

    移动端跨平台方案的实现,鸿蒙ArkUI的技术选择?
    裁员在家如何保持高效学习
    PackageManagerService和它的6个“小伙伴”


    扫一扫 关注我的公众号

    如果你想要跟大家分享你的文章,欢迎投稿~


    ┏(^0^)┛明天见!