Android 一种异步监控和阻断InputDispatching ANR的方法

前言

如何在Java层实现异步监控和阻断InputDispatching ANR?我相信这是很多开发者都想要的功能。

Android版本发展已经趋于稳定,各种AMP工具都已经很成熟了,甚至很多人都能背出来具体实现。但是,仍然有一些东西我们要回过头去看,过去我们认为不能或者很难实现的东西,或许是因为我们很少去质疑。

任何时候都要重新审视一下过去的方法。

有时候解决问题的方法并不只有一种,我们要质疑为什么选的是不是最好用的一种。一些人的代码,提前引入现有需求不需要的逻辑是否合理?还有就是,为了解决一个小问题,比如解决相似图片的问题,结果完整引入了opencv,引入这样一个很大的框架是否合理?这些都需要去质疑。

本篇前奏

这里,我们简单了解下事件传递和一些尝试方案,如果不看本节,其实影响不大,可直接跳至下一节。

我们回到本篇主题,我们如何才能使用Java代码实现InputEvent ANR 监控和阻断呢,我们先来看这样一张图。我为什么选择这一张图呢,因为它很经典,虽然我在上面稍微改造了一下。

当然,上图缺少WindowSesssion的角色,实际上,ViewRootImpl和WindowManagerService通信少不了WindowSession,那么WindowSession是如何通信的呢,我们继续往下看。

java 复制代码
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
      ...
      if ((mWindowAttributes.inputFeatures
          & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) == 0) {
          mInputChannel = new InputChannel(); //创建InputChannel对象
      }
      //通过Binder调用,进入system进程的Session[见小节2.4]
      res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
                  getHostVisibility(), mDisplay.getDisplayId(),
                  mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
                  mAttachInfo.mOutsets, mInputChannel);
      ...
      if (mInputChannel != null) {
          if (mInputQueueCallback != null) {
              mInputQueue = new InputQueue();
              mInputQueueCallback.onInputQueueCreated(mInputQueue);
          }
          //创建WindowInputEventReceiver对象[见3.1]
          mInputEventReceiver = new WindowInputEventReceiver(mInputChannel,
                  Looper.myLooper());
      }
    }
}

在这里我们可以看到,事件传递是通过InputChannel实现,而InputChannel负责事件发送、事件应答两部分,因此,肯定能双向通信,那么是不是Binder呢?

实际上,InputChannel在底层是Socket实现

java 复制代码
status_t InputChannel::openInputChannelPair(const String8& name,
        sp<InputChannel>& outServerChannel, sp<InputChannel>& outClientChannel) {
    int sockets[2];
    //真正创建socket对的地方【核心】
    if (socketpair(AF_UNIX, SOCK_SEQPACKET, 0, sockets)) {
        ...
        return result;
    }

    int bufferSize = SOCKET_BUFFER_SIZE; //32k
    setsockopt(sockets[0], SOL_SOCKET, SO_SNDBUF, &bufferSize, sizeof(bufferSize));
    setsockopt(sockets[0], SOL_SOCKET, SO_RCVBUF, &bufferSize, sizeof(bufferSize));
    setsockopt(sockets[1], SOL_SOCKET, SO_SNDBUF, &bufferSize, sizeof(bufferSize));
    setsockopt(sockets[1], SOL_SOCKET, SO_RCVBUF, &bufferSize, sizeof(bufferSize));

    String8 serverChannelName = name;
    serverChannelName.append(" (server)");
    //创建InputChannel对象
    outServerChannel = new InputChannel(serverChannelName, sockets[0]);

    String8 clientChannelName = name;
    clientChannelName.append(" (client)");
    //创建InputChannel对象
    outClientChannel = new InputChannel(clientChannelName, sockets[1]);
    return OK;
}

InputChannel既创建Server又创建Client,看着是很奇怪的行为,事实上,在Linux中通信是通过Fd就能实现,而InputChannel是Parcelable的子类,可以把FD发送至WMS.

失败的Socket FD 监听方案

其实上面的这些代码和本篇关系不大,为什么要贴出代码呢,主要原因是我之前尝试过监听Socket的FD,可问题是InputChannel的FD拿不到,除非ChannelName为空,但是上面两个都有ChannelName,然后我就去找有没有让Name为空的方法,很遗憾也没有。

因此,这种实现只能借助Native Hook暴露接口,难度也有些大,因此,只能放弃这种方案了。

失败InputEventReceiver中间件方案

于是我找到另一种方案,在ViewRootImple#WindowInputEventReceiver 和 InputChannel之间插入一个MiddleWareInputEventReceiver,经过大量推断,将ViewRootImple#WindowInputEventReceiver dispose了,然后会发现,事件消费问题无法处理,因为ViewRootImple#WindowInputEventReceiver 调用finishInputEvent的方法无法调用到MiddleWareInputEventReceiver。

为什么做这种尝试呢,主要还是下面一段代码,我们可以看到Looper,这个类是可以传入Looper的,InputChannel之间插入一个MiddleWareInputEventReceiver异步监听,然后转发给dispose后的WindowInputEventReceiver。

java 复制代码
public InputEventReceiver(InputChannel inputChannel, Looper looper) {
    if (inputChannel == null) {
        throw new IllegalArgumentException("inputChannel must not be null");
    }
    if (looper == null) {
        throw new IllegalArgumentException("looper must not be null");
    }

    mInputChannel = inputChannel;
    mMessageQueue = looper.getQueue();
    mReceiverPtr = nativeInit(new WeakReference<InputEventReceiver>(this),
            inputChannel, mMessageQueue);

    mCloseGuard.open("dispose");
}

本篇实现

上面的方案中,实现复杂且稳定性很差,或许只有通过HOOK手段或者替换一些方法地址(ArtMethod)才能解决一些问题。

我们本篇利用一种比较新颖的方案,纯java实现 具体怎么实现的呢?

我们要先来确定以下几种关系。

ViewRootImpl 与 WindowSession关系

先来看一张图

在这张图中,我们可以清楚的看到,ViewRootImpl和WindowManagerService是多对一的关系,但是我们也要知道,他们之间的IWindow和IWindowSesssion和ViewRootImpl也是一对一的关系,也就是说,一个ViewRootImpl对应一个IWindow和IWindowSession。

因此,我们要明白,Activity中的PhoneWindow和WindowManagerService是没有任何关系的,Activity中PhoneWindow也不负责管理如Dialog、PopWindow这样的组件,最终是WindowManager负责管理的。

好了,我们再看下一个知识点

Window 层级

在Android中,Window是有层级关系的,当然这种关系被google改来改去,如果要使用的话需要处理一些兼容性问题。

目前来说,除了OVERlAY类型外,其他的都需要window Token来与Activity强行绑定,但这不是本篇的重点,重点是,我们要知道为什么Dialog作为Activity的组件,会展示在Activity的上面。

主要原因是Activity的WindowType小于Dialog的WindowType (dialog的为TYPE_APPLICATION_ATTACHED_DIALOG),因此他能展示Activity上面。

java 复制代码
 public int subWindowTypeToLayerLw(int type) {
       switch (type) {
       case TYPE_APPLICATION_PANEL:
       case TYPE_APPLICATION_ATTACHED_DIALOG:
           return APPLICATION_PANEL_SUBLAYER;//返回值是1
       case TYPE_APPLICATION_MEDIA:
           return APPLICATION_MEDIA_SUBLAYER;//返回值是-2  
       case TYPE_APPLICATION_MEDIA_OVERLAY:
           return APPLICATION_MEDIA_OVERLAY_SUBLAYER;//返回值是-1  
       case TYPE_APPLICATION_SUB_PANEL:
           return APPLICATION_SUB_PANEL_SUBLAYER;//返回值是2 
       case TYPE_APPLICATION_ABOVE_SUB_PANEL:
           return APPLICATION_ABOVE_SUB_PANEL_SUBLAYER;//返回值是3  
       }
       Log.e(TAG, "Unknown sub-window type: " + type);
       return 0;
   }

那么展示在上面意味着什么?

我们要知道,在Android系统中,Window层级越高,意味着权限越大,假设你的弹窗能展示在系统弹窗(如指纹识别弹窗)的上面,那么你就可以做一些看不见的事。当然google是不会让你这么做的,Google大费周折关联Window Token,就是为了修复此类风险。

那么,还意味着什么?

我们还知道,层级越高,SurfsceFlinger中展示顺序的优先级越高,主线程和RenderThread线程优先级越高,同时线程调度的优先级越高,当然,和本篇有关的是,接收【事件】顺序的优先级越高。

ViewRootImpl异步渲染

实际上,很多时候容易被忽略的一件事是,ViewRootImpl其实是支持异步渲染的,同样Choreographer也是支持异步的。为什么这样说呢?

因为现成的例子:android.app.Dialog

在Android系统中,Dialog是支持异步弹出的,这也就是为什么其内部的Handler是没有绑定主线程Looper的原因。

核心原理

通过上面3个知识点,我们就可以做到一件事

在Activity ViewRootImpl上面加一个异步创建的Dialog,然后将Dialog接收的事件通过主线程Handler转发给Activity。

很显然,上面的方法是可行的。

那么,我们是不是可以做更多的事情呢?

答案是:是的。

阻断ANR 产生

我们可以为了避免InputEventDispatcher ANR,在Dialog异步线程中,提前让InputEventReceiver的finishInputEvent方法调用,这样就能避免ANR。

延长ANR 阈值

我们知道,InputEventDispatcher Timeout时间为5s,我们可以主线程第4s的还没完成的时候,提前finishInputEvent,然后我们自行启动异步监控,比如我们决定在第6s ANR,如果主线程的任务在第6s没有结束,我们就下面的方法,来触发ANR。

android.app.ActivityManager#appNotResponding

java 复制代码
public void appNotResponding(@NonNull final String reason) {
    try {
        getService().appNotResponding(reason);
    } catch (RemoteException e) {
        throw e.rethrowFromSystemServer();
    }
}

监控ANR

很多ANR的监控都在Native 层监控Sig_Quit信号,也有通过Looper.Printer进行检测到异常后,轮询AMS的的相关接口。

但是这里都可以做到对ANR的控制了,角色由消费者变成生产者,这种情况下自身就不需要监控了,只需要通知是否产生ANR。

实现逻辑

首先,我们我们来定义一个Dialog,实际上,Dialog会影响状态栏和底部导航栏的样式,因此,对于Activity而言,为了避免Dialog和Activity的点击位置没法对齐,我们需要将Activity的一些样式同步到dialog上,下面是同步了全屏和非全屏两种,实际过程可能还需要同步其他几种。

java 复制代码
public class AnrMonitorDialog extends Dialog {

    private static HandlerThread AnrMonitorThread = new HandlerThread("ANR-Monitor");

    static {
        AnrMonitorThread.start();
    }

    private static Handler sAnrMonitorHandler = new Handler(AnrMonitorThread.getLooper());
    private final Window.Callback mHost;
    private final Handler mainHandler;
    private boolean isFullScreen = false;

    AnrMonitorDialog(Context context, Window hostWindow) {
        super(context);
        this.mainHandler = new Handler(Looper.getMainLooper());
        this.mHost = hostWindow.getCallback();
        this.isFullScreen = (WindowManager.LayoutParams.FLAG_FULLSCREEN & hostWindow.getAttributes().flags) != 0;
    }


  
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Window window = getWindow();
        window.requestFeature(Window.FEATURE_NO_TITLE);
        View view = new View(getContext());
        view.setFocusableInTouchMode(false);
        view.setFocusable(false);
        setContentView(view);
        if (isFullScreen) {
            window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
        } else {
            window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
        }
        WindowManager.LayoutParams attributes = window.getAttributes();
        attributes.format = PixelFormat.TRANSPARENT;
        attributes.dimAmount = 0f;
        attributes.flags |= FLAG_NOT_FOCUSABLE;
        window.setBackgroundDrawable(new ColorDrawable(0x00000000));
        window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);

        setCancelable(false);
        setCanceledOnTouchOutside(false);

    }


    public static void hideDialog(DialogInterface dialog) {
        if (dialog == null) return;
        sAnrMonitorHandler.post(new Runnable() {
            @Override
            public void run() {
                dialog.dismiss();
            }
        });
    }


    public static void showDialog(final Activity activity, final Window window, OnShowListener onShowListener) {
        sAnrMonitorHandler.post(new Runnable() {
            @Override
            public void run() {
          
                if(activity.isFinishing()){
                    return;
                }
                AnrMonitorDialog anrMonitorDialog = new AnrMonitorDialog(activity, window);
                anrMonitorDialog.setOnShowListener(onShowListener);
                anrMonitorDialog.show();
            }
        });
    }
    
   // 省略一堆关键代码
}

在实现的过程中,我们可以复写Dialog的一些方法,当然你还可以给Dialog的Window设置Window.Callback。这里要说的一点是,一些设备自定义了特殊的实现,如dispatchFnKeyEvent,显然系统类中没有这个方法,但是如果你要实现的话无法通过super关键字调用,解决办法也是有的,就是利用Java 7中的MethodHandle动态invoke,这里我们暂不实现了,毕竟这个KeyEvent一般APP也用不到。

java 复制代码
/**
 *  fixed Lenovo/Sharp Android 4.4.3 dispatchFnKeyEvent
 *  不要删除 dispatchFnKeyEvent 方法
 */
@Keep
public boolean dispatchFnKeyEvent(KeyEvent event) {
    //可以利用MethodHandle调用父类的方法
    return false;
}

这里我们复写Dialog的一些方法,我们以TouchEvent的传递为例子,当我们拿到MotionEvent的时候,我们就能将event转发给主线程。其实这里最稳妥的方法是对事件复制,因为MotionEvent是可以被recycle的,如果不复制就会被异步修改。

java 复制代码
@Override
public boolean dispatchTouchEvent(final MotionEvent event) {
    final Waiter waiter = new Waiter();

    final MotionEvent targetEvent = copyMotionEvent(event);
    mainHandler.post(new Runnable() {
        @Override
        public void run() {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    break;
            }
            boolean isHandled = mHost.dispatchTouchEvent(targetEvent);
            targetEvent.recycle();  //自己拷贝的事件,需要主动回收
            waiter.countDown(isHandled);
        }
    });
    try {
        if(!waiter.await(4000, TimeUnit.MILLISECONDS)){
            sAnrMonitorHandler.postAtTime(mAnrTimeoutTask, SystemClock.uptimeMillis() + 2000L);
            mainHandler.postAtFrontOfQueue(mCancelAnrTimeoutTask);
        }
    } catch (InterruptedException e) {
        e.printStackTrace();

    }

    return waiter.isHandled;
}
  • mAnrTimeoutTask 负责触发ActivityManager#appNotResponding
  • mCancelAnrTimeoutTask 用于取消sAnrMonitorHandler的定时逻辑
java 复制代码
private Runnable mAnrTimeoutTask = new Runnable() {
    @Override
    public void run() {
        sendAppNotResponding("Dispatching Timeout");
    }
};

private Runnable mCancelAnrTimeoutTask = new Runnable() {
    @Override
    public void run() {
        sAnrMonitorHandler.removeCallbacks(mAnrTimeoutTask);
    }
};

原理是,如果在指定的时间没有取消,说明主线程是卡住了,我们可以不抛ANR,但是点击之后卡住不动,任何人的心情都会很难受,抑制ANR发生并不可取,但是我们可以借助这些时间段收集一些线程状态和内存信息,以及业务信息,提高ANR上报率和场景覆盖。

那么Waiter是什么呢,其实是CountDownLatch的子类,我们简单封装一下,来等待事件完成。

java 复制代码
static class Waiter extends CountDownLatch {
    boolean isHandled = false;
    public Waiter() {
        super(1);
    }

    public void countDown(boolean isHandled){
        this.isHandled = isHandled;
        super.countDown();
    }
    @Override
    public void countDown() {
         throw new Exception("I like along, don't call me");
    }
}

用法

很简单,我们在BaseActivity的onCreate中加入即可

java 复制代码
AnrMonitorDialog.showDialog(this, getWindow(), new DialogInterface.OnShowListener() {
    @Override
    public void onShow(DialogInterface dialog) {
        anrMonitorDialog = dialog;  
         //由于是异步返回的的dialog,这里要做二次检测,防止InputChannel泄漏
        postToMain(new Runnable(){
          if(actvitiyIsFinish()){ 
              AnrMonitorDialog.hideDialog(anrMonitorDialog);
              anrMonitorDialog = null;
           
          }
        });
    }
});

不过,我们一定要在onDestoryed中关闭Dialog,避免InputChannel泄漏

java 复制代码
@Override
protected void onDestroy() {
    AnrMonitorDialog.hideDialog(anrMonitorDialog);
    super.onDestroy();
}

测试效果

经过测试,在Touch Event模式下,基本没有出现问题,滑动和点击都难正常,也不会出现遮挡,包括Activity跳转也是正常的。

另一种可行方案: HOOK MessageQueue

然后在Native层监控MessageQueue的事件输入也是一种可行的方案,动态转发事件到主线程,我们要Hook MessageQueue中的Native Looper,这种有一定的可行性,我这里没有试过,有时间可以试一下。

总结

通过上面的实现,我们将异步线程创建的全屏Dialog覆盖到Activity上面,然后通过Dialog转发事件到Activity,从而实现了在Java层就能监控和阻断InputDispatching ANR。当然,如果是双屏异显的逻辑也可以实现,不过就得使用之前我一篇文章中实现的Dialog《Android 双屏异显自适应Dialog

不过,这里也有些可能的问题,具体我们有测试,但可能会存在。

  • 焦点问题:由于ViewRootImpl 内部有焦点处理逻辑,如果把事件直接给Window.Callback可能还不合适,因此,如果是TV版本开发,还可能需要从DecorView层面进一步兼容一下,不过测试过程中发现大部分走焦逻辑是正常的,咱没有发现特别严重的问题。
  • 一些低级别WindowType的弹窗无法拦截事件:实际上,在Android中,WindowType一样的话,后面的弹窗会覆盖到上面,但是对于一些魔改的系统,可能存在问题,但是解决办法就是调整WindowType,其次,AnrMonitorDialog要尽可能早一些弹出
  • 仅限于对Activity的事件监控: 本篇方案仅限于对Activity的的监控,但如果是想支持其他Dialog,那么要保证AnrMonitorDialog 有更高的层级,同时要能支持其他Dialog的Window.Callback获取,当然,最好的方式就是从WindowManagerGlobal中获取次一级的ViewRootImpl,然后想办法获取DecorView
  • 输入法问题:由于部分系统输入法在Dialog下面,且输入法不属于app自身的UI,因此无法点击。我们要做2件事才能实现兼容: ①监听全局焦点,如果移动到TextView或EditText上,那么需要关闭AnrMonitorDialog弹窗 ② Hook windowManager来判断是否有其他Dialog弹出,等到其他Dialog关闭后且焦点不在EidtText和TextView上之后,同时判断键盘已经收起之后,再恢复AnrMonitorDialog 。

另一个要考虑的点是实用性,目前来说,这种方法在特定场景下还是比较实用的,比如调试环境,我们遇到一类问题,就是DEBUG时间太长,一些系统中AMS直接将APP进程杀死;

还有就是一些系统,如果出现ANR,连Native层SIGQUIT信号可能都来不及接收就直接force-stop进程的情况。

总之,这属于一种Java层监控ANR的方案,目前来说还有很多不足,但是至少来说,解决调试时ANR进程被杀问题还是可以的。

引申思考

这种方法不是系统漏洞,但是我们这里要思考的一个问题是,是不是还有更加优秀的方案,我们时常能看到各种系统源码分析的博客,是不是还有其他捷径可以走呢?

相关推荐
OkeyProxy5 小时前
設置Android設備全局代理
android·代理模式·proxy模式·代理服务器·海外ip代理
刘志辉6 小时前
vue传参方法
android·vue.js·flutter
前期后期9 小时前
Android OkHttp源码分析(一):为什么OkHttp的请求速度很快?为什么可以高扩展?为什么可以高并发
android·okhttp
轻口味11 小时前
Android应用性能优化
android
全职计算机毕业设计11 小时前
基于 UniApp 平台的学生闲置物品售卖小程序设计与实现
android·uni-app
dgiij12 小时前
AutoX.js向后端传输二进制数据
android·javascript·websocket·node.js·自动化
SevenUUp13 小时前
Android Manifest权限清单
android
高林雨露13 小时前
Android 检测图片抓拍, 聚焦图片后自动完成拍照,未对准图片的提示请将摄像头对准要拍照的图片
android·拍照抓拍
wilanzai13 小时前
Android View 的绘制流程
android
EterNity_TiMe_14 小时前
【Linux基础IO】深入Linux文件描述符与重定向:解锁高效IO操作的秘密
linux·运维·服务器·学习·性能优化·学习方法