大厂 “不死” Android App ?可定制化 Looper 兜底框架帮你!
  UfRZbSG3rhJ9 2023年12月23日 61 0

代码 Demo

App Crash 对于用户来讲是一种最糟糕的体验,它会导致流程中断、app 口碑变差、app 卸载、用户流失、订单流失等。相关数据显示,当 Android App 的崩溃率超过 0.4%的时候,活跃用户有明显下降态势。
目前受益于我司采取的一系列的治理、监控、防劣化体系,java crash 率降低到了一个十万分级别的数字,今天分享的就是稳定性治理过程中的一个重要工具,下面开整。

为什么抛出异常时 app 会退出?

不细致分析了,网上随便找一下就是一堆博客,简单来说就是没有被 catch 的崩溃抛出时,会调用Thread#dispatchUncaughtException(throwable) 来进行处理,而在进程初始化时,RuntimeInit#commonInit 里会注入默认杀进程的 KillApplicationHandler,如果我们没有实现自定义的 UncaughtExceptionHandler 时,dispatchUncaughtException 被调用就会走到 KillApplicationHandler 里,把当前的进程杀掉,即产生了一次用户感知的崩溃行为。

有没有办法打造一个永不崩溃的 app?

这个问题问出来的前提是指发生的崩溃是来自于 java 层面的未捕获的异常,c 层就是另一回事了。我们来尝试回答一下这个问题: 答:可以,至少可以做到把所有的异常都吃掉。

  • 问:那么怎么做呢?

答:当某个线程发生异常时,只要不让 KillApplicationHandler 处理这个异常就行了,即只要覆盖掉默认的 UncaughtExceptionHandler 就行了噢。

  • 问:那当这样做的时候,比如主线程抛出一个异常被吃掉了,app 还能正常运行吗?

答:不能了,因为不做任何处理的话,当前线程的 Looper.loop()就被终止了。如果是主线程的话,此时你将会获得一个 anr。

  • 问:怎么才能在吃掉异常的同时,让主线程继续运行呢?

答:当由于异常抛出,导致线程的 Looper.loop() 终止之后,接管 Looper.loop()。代码大概长下面这样:

public class AppCrashHandler implements UncaughtExceptionHandler {
    @Override
    public void uncaughtException(@NonNull Thread thread, @NonNull Throwable ex) {
        while (true) {
            try {
                if (Looper.myLooper() == null) {
                    Looper.prepare();
                }
                Looper.loop();
            } catch (Exception e) {
            }
        }
    }
}

上面这段代码,就是我标题中被描述为 Looper 兜底框架的实现机制。但是对于一个正常的 app,线上是不可能这样无脑的 catch,然后 Looper.loop 的,这是因为:

  1. 不是所有的异常都需要被 catch 住,如:OOM、launcher Activity onCreate 之类的。
  2. 稳定性不是靠屏蔽问题,而是靠解决问题,当异常无法解决或者解决成本太高,且异常被屏蔽对用户、业务来说并没有啥实质性的影响时,可以被屏蔽,当异常抛出时已经对业务产生了破坏,但是通过保护住然后重试可以让业务恢复运作时,也可以被屏蔽,只是多了个环节,即修复异常。
  • 问:异常被吃掉之后会有什么影响?

抛异常的那句代码之后的代码将不会被调用,即当前的调用栈将会中断。假如代码像下面这样,通过 Looper 兜底的方式去让 app 不崩溃,会导致 throw 异常之后的代码无法被执行到。可以简单的理解为,是对整个调用栈加了 try-catch,不过这个 try-catch 是加在了 Looper.loop()上

private void testCrash() {
        int x = 0;
        if(x == 0){
            throw new IllegalArgumentException("xx");
        }
        int y = 1;
        Log.e("TEST", "y is : " + y);
    }
  • 问:到底什么异常需要被吃掉呢?

上一个回答中我们大致将需要被吃掉的异常分了两类

  • 异常我们无法解决或者解决成本太高

举个例子,假如公司有使用 react native 之类的三方大框架,当业务抛出来一个如下的异常时,我们就可以认为这无法解决。

com.facebook.react.bridge.JSApplicationIllegalArgumentException: connectAnimatedNodes: Animated node with tag (child) [30843] does not exist
    at com.facebook.react.animated.NativeAnimatedNodesManager.connectAnimatedNodes(NativeAnimatedNodesManager.java:7)
    at com.facebook.react.animated.NativeAnimatedModule$16.execute
    at com.facebook.react.animated.NativeAnimatedModule$ConcurrentOperationQueue.executeBatch(NativeAnimatedModule.java:7)
    at com.facebook.react.animated.NativeAnimatedModule$3.execute
    at com.facebook.react.uimanager.UIViewOperationQueue$UIBlockOperation.execute
    at com.facebook.react.uimanager.UIViewOperationQueue$1.run(UIViewOperationQueue.java:19)
    at com.facebook.react.uimanager.UIViewOperationQueue.flushPendingBatches(UIViewOperationQueue.java:10)
    at com.facebook.react.uimanager.UIViewOperationQueue.access$2600
    at com.facebook.react.uimanager.UIViewOperationQueue$DispatchUIFrameCallback.doFrameGuarded(UIViewOperationQueue.java:6)
    at com.facebook.react.uimanager.GuardedFrameCallback.doFrame(GuardedFrameCallback.java:1)
    at com.facebook.react.modules.core.ReactChoreographer$ReactChoreographerDispatcher.doFrame(ReactChoreographer.java:7)
    at com.facebook.react.modules.core.ChoreographerCompat$FrameCallback$1.doFrame
    at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1118)
    at android.view.Choreographer.doCallbacks(Choreographer.java:926)
    at android.view.Choreographer.doFrame(Choreographer.java:854)
    at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1105)
    at android.os.Handler.handleCallback(Handler.java:938)
    at android.os.Handler.dispatchMessage(Handler.java:99)
    at android.os.Looper.loopOnce(Looper.java:238)
    at android.os.Looper.loop(Looper.java:379)
    at android.app.ActivityThread.main(ActivityThread.java:9271)
    at java.lang.reflect.Method.invoke(Method.java)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:567)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1018)
  • 2. 异常被屏蔽对用户、业务来说并没有实质性影响

比如老生常谈的 Android 7.x toast 的 BadTokenException 之类的系统崩溃,一呢发生概率非常低,二呢在 Android 8 上的修复方式也只是 try-catch 住。

一些不影响业务、用户使用的三方库崩溃,比如瞎说一个,当使用 OkHttp 在请求接口时,内部切了个线程执行了个更新缓存的任务,结果里面抛出了一个 NPE 。外面没法 try-catch ,而且这个异常抛出时,顶多下次请求不走缓存,实际上没啥太大影响。

  • 3. 异常很严重,但是吃掉之后通过修复运行环境能够让用户所使用的业务恢复正常运行

比如我们想要保存一张图片到磁盘上,但是磁盘满了, 抛出了一个 no space left,这时候我们就可以将异常吃掉,同时清空 app 的磁盘缓存,并且告知用户重试,就可以成功的让用户保存图片成功

Looper 兜底框架辅助稳定性治理

上面我们说到,我们可以通过 Looper 兜底的机制能够做到吃掉所有的 java 异常,那我们自然也能想到对于一个 app 来说,通过 Looper 兜底机制来辅助稳定性的治理。我们可以先明确一下什么崩溃需要通过这种手段来治理、兜底:

  1. 系统崩溃,如老生常谈的 Android 7.x toast 的 BadTokenException
  2. 三方库的无痛崩溃,比如公司有使用 react native 之类的三方大框架,没有能力改或者不想改一些相关的 ui 引起的 崩溃,比如做动画时莫名其妙的抛出异常
  3. 一些特殊崩溃,如磁盘空间不足引发的 no space left,可以尝试通过抓住崩溃同时清理一波 app 的磁盘缓存,再尝试继续运行。
  4. 其他...

那么,我们就可以将代码写成如下这样:

public class MyApplication extends Application{
    @override
    public void onCreate(){
        super.onCreate();
        CrashProtectUtil.init();
    }
}
public class CrashProtectUtil{
    public void init() {
        mOldHandler = Thread.getDefaultUncaughtExceptionHandler();
        if (mOldHandler != this) {
            Thread.setDefaultUncaughtExceptionHandler(this);
        }
    }

    @Override
    public void uncaughtException(@NonNull Thread thread, @NonNull Throwable ex) {
        //判断异常是否需要兜底
        if (needBandage(ex)) {
            bandage();
            return;
        }

        //崩吧
        if (mOldHandler != null) {
            mOldHandler.uncaughtException(thread, ex);
        }
    }

    private boolean needBandage(Throwable ex) {
        //如果是没磁盘空间了,尝试清理一波缓存
        if (isNoSpaceException(ex)) {
            CacheCleaner.cleanAppCache(mContext, true);
            return true;
        }

        //BadTokenException
        if (isBadTokenException(ex)) {
            return true;
        }

        return false;
    }

    private boolean isNoSpaceException(Throwable ex) {
        String message = ex.getMessage();
        return !TextUtils.isEmpty(message) && message.contains("No space left on device");
    }

    private boolean isBadTokenException(Throwable ex) {
        return ex instanceof WindowManager.BadTokenException;
    }

    /**
     * 让当前线程恢复运行
     */
    private void bandage() {
        try {
            //fix No Looper; Looper.prepare() wasn't called on this thread.
            if (Looper.myLooper() == null) {
                Looper.prepare();
            }
            Looper.loop();
        } catch (Exception e) {
            uncaughtException(Thread.currentThread(), e);
        }
    }
}

上面其实就是 Looper 兜底框架的大致代码实现了,在未捕获的异常抛出时,我们在代码中通过异常的类型来判断是否需要进行保护。 当然,如果代码真这么写,当我有新的异常要被保护时,不就得改代码,然后发版上线吗?周期太长了。于是乎,就顺理成章的可以把异常是否要保护的逻辑抽象成 需要保护的崩溃画像匹配。假如有一个配置列表,上面描述了所有的需要被保护的异常,当一个异常被抛出时,本地拿着配置的需要被保护的异常列表一个一个的去做比对,如果发现这个异常在我们的配置里,就对其进行保护,否则则让默认的 handler 去处理,也就是杀掉进程。

  • 问:为什么我的标题中强调了可远程配置化呢?

答:因为可远程配置化能够为框架本身赋能更多。

  • 问:比如?

答:可以提供一种简易的 线上容灾机制 ,假如线上在某个页面发生了一个崩溃,这个崩溃突然发生而且崩溃发生的点本身对业务来说无关紧要(比如有个开发手贱,Integer.parse 整了个汉字,抛异常了),通过热修复来修吧,流程复杂,要改代码、打补丁包、配补丁包。

紧急发版吧,成本比热修高了不知多少倍,这时如果有一个可配置化的 Looper 兜底框架,我通过更新我的配置,保护住这个 Integer.parse 异常,就能很轻松的解决线上问题。

可配置化配置的是什么东西?

首先这是一个崩溃保护的框架,那么配置的肯定是能描述崩溃的内容,那么什么东西能描述一个崩溃呢?无非就是以下元素:

  1. throwable class name
  2. throwable message
  3. throwable stacktrace
  4. Android version
  5. app version
  6. model
  7. brand
  8. ...

大致就是对崩溃做个标签匹配:这是个什么崩溃,发生在哪个 Android 版本,发生在哪个 App 版本,发生在哪个厂商哪个系统版本上。

我们怎么做的?

我们的画像标签大致长下面这样:

[  {    "class": "",    "message": "No space left on device",    "stack": [],
    "app_version": [],
    "clear_cache": 1,
    "finish_page": 0,
    "toast": "",
    "os_version": [],
    "model": []
  },
  {
    "class": "BadTokenException",
    "message": "",
    "stack": [],
    "app_version": [],
    "clear_cache": 0,
    "finish_page": 0,
    "toast": "",
    "os_version": [],
    "model": []
  }
]

配置里还加了一些额外的东西,比如:

  1. 崩溃被保护住的时候,要不要清理下 app 的缓存
  2. 崩溃被保护住的时候,要不要弹个 toast 告知用户
  3. 崩溃被保护住的时候,要不要退出当前页面

就这样,我们的可配置化的 Looper 兜底框架的全貌就描述完了,最后再总结一下具体的工作流程吧。

  • Looper 兜底流程

我们会注入自己的 UncaughtExceptionHandler,当 App 产生了一个未捕获的异常时,我们通过对这个异常进行几个标签的匹配来判断当前的崩溃是否要进行保护,当需要保护时,接管 Looper.loop,让线程继续运行。

  • 配置更新、生效流程:

当 App 启动时,拉取远程的崩溃画像配置,当未捕获的异常发生时,读取本地最新的配置,进行标签匹配,如果标签匹配成功,进行 Looper 兜底。

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年12月23日 0

暂无评论

UfRZbSG3rhJ9