AndroidToast问题深度剖析(一)

欢迎你们前往云+社区,获取更多腾讯海量技术实践干货哦~java

做者:QQ音乐技术团队

题记

Toast 做为 Android 系统中最经常使用的类之一,因为其方便的api设计和简洁的交互体验,被咱们所普遍采用。可是,伴随着咱们开发的深刻,Toast 的问题也逐渐暴露出来。本文章就将解释 Toast 这些问题产生的具体缘由。 本系列文章将分红两篇:android

  • 第一篇,咱们将分析 Toast 所带来的问题
  • 第二篇,将提供解决 Toast 问题的解决方案

(注:本文源码基于Android 7.0)api

1. 异常和偶尔不显示的问题

当你在程序中调用了 ToastAPI,你可能会在后台看到相似这样的 Toast 执行异常:bash

android.view.WindowManager$BadTokenException
    Unable to add window -- token android.os.BinderProxy@7f652b2 is not valid; is your activity running?
    android.view.ViewRootImpl.setView(ViewRootImpl.java:826)
    android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:369)
    android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
    android.widget.Toast$TN.handleShow(Toast.java:459)复制代码

另外,在某些系统上,你没有看到什么异常,却会出现 Toast 没法正常展现的问题。为了解释上面这些问题产生的缘由,咱们须要先读一遍 Toast 的源码。网络

2. Toast 的显示和隐藏

首先,全部 Android 进程的视图显示都须要依赖于一个窗口。而这个窗口对象,被记录在了咱们的 WindowManagerService(后面简称 WMS) 核心服务中。WMS 是专门用来管理应用窗口的核心服务。当 Android 进程须要构建一个窗口的时候,必须指定这个窗口的类型。 Toast 的显示也一样要依赖于一个窗口, 而它被指定的类型是:机器学习

public static final int TYPE_TOAST = FIRST_SYSTEM_WINDOW+5;//系统窗口复制代码

能够看出, Toast 是一个系统窗口,这就保证了 Toast 能够在 Activity 所在的窗口之上显示,并能够在其余的应用上层显示。那么,这就有一个疑问:ide

“若是是系统窗口,那么,普通的应用进程为何会有权限去生成这么一个窗口呢?”函数

实际上,Android 系统在这里使了一次 “偷天换日” 小计谋。咱们先来看下 Toast 从显示到隐藏的整个流程:源码分析

复制代码
// code Toast.java
public void show() {
        if (mNextView == null) {
            throw new RuntimeException("setView must have been called");
        }

        INotificationManager service = getService();//调用系统的notification服务
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;//本地binder
        tn.mNextView = mNextView;
        try {
            service.enqueueToast(pkg, tn, mDuration);
        } catch (RemoteException e) {
            // Empty
        }
    }复制代码
复制代码

咱们经过代码能够看出,当 Toastshow 的时候,将这个请求放在 NotificationManager 所管理的队列中,而且为了保证 NotificationManager 能跟进程交互, 会传递一个 TN 类型的 Binder 对象给 NotificationManager 系统服务。而在 NotificationManager 系统服务中:post

复制代码
//code NotificationManagerService
public void enqueueToast(...) {
    ....
    synchronized (mToastQueue) {
                    ...
                    {
                        // Limit the number of toasts that any given package except the android
                        // package can enqueue.  Prevents DOS attacks and deals with leaks.
                        if (!isSystemToast) {
                            int count = 0;
                            final int N = mToastQueue.size();
                            for (int i=0; i<N; i++) {
                                 final ToastRecord r = mToastQueue.get(i);
                                 if (r.pkg.equals(pkg)) {
                                     count++;
                                     if (count >= MAX_PACKAGE_NOTIFICATIONS) {
                                         //上限判断
                                         return;
                                     }
                                 }
                            }
                        }

                        Binder token = new Binder();
                        mWindowManagerInternal.addWindowToken(token,
                                WindowManager.LayoutParams.TYPE_TOAST);//生成一个Toast窗口
                        record = new ToastRecord(callingPid, pkg, callback, duration, token);
                        mToastQueue.add(record);
                        index = mToastQueue.size() - 1;
                        keepProcessAliveIfNeededLocked(callingPid);
                    }
                    ....
                     if (index == 0) {
                        showNextToastLocked();//若是当前没有toast,显示当前toast
                    }
                } finally {
                    Binder.restoreCallingIdentity(callingId);
                }
            }
}复制代码
复制代码

(不去深究其余代码的细节,有兴趣能够自行研究,挑出咱们所关心的Toast显示相关的部分)

咱们会获得如下的流程(在 NotificationManager系统服务所在的进程中):

  • 判断当前的进程所弹出的 Toast 数量是否已经超过上限 MAX_PACKAGE_NOTIFICATIONS ,若是超过,直接返回
  • 生成一个 TOAST 类型的系统窗口,而且添加到 WMS 管理
  • 将该 Toast 请求记录成为一个 ToastRecord 对象

代码到这里,咱们已经看出 Toast 是如何偷天换日的。实际上,这个所须要的这个系统窗口 token ,是由咱们的 NotificationManager 系统服务所生成,因为系统服务具备高权限,固然不会有权限问题。不过,咱们又会有第二个问题:

既然已经生成了这个窗口的 Token 对象,又是如何传递给 Android进程并通知进程显示界面的呢?

咱们知道, Toast 不只有窗口,也有时序。有了时序,咱们就可让 Toast 按照咱们调用的次序显示出来。而这个时序的控制,天然而然也是落在咱们的NotificationManager 服务身上。咱们经过上面的代码能够看出,当系统并无 Toast 的时候,将经过调用 showNextToastLocked(); 函数来显示下一个Toast

复制代码
void showNextToastLocked() {
        ToastRecord record = mToastQueue.get(0);
        while (record != null) {
            ...
            try {
                record.callback.show(record.token);//通知进程显示
                scheduleTimeoutLocked(record);//超时监听消息
                return;
            } catch (RemoteException e) {
                ...
            }
        }
    }复制代码
复制代码

这里,showNextToastLocked 函数将调用 ToastRecordcallback 成员的 show 方法通知进程显示,那么 callback 是什么呢?

final ITransientNotification callback;//TN的Binder代理对象复制代码

咱们看到 callback 的声明,能够知道它是一个 ITransientNotification 类型的对象,而这个对象实际上就是咱们刚才所说的 TN 类型对象的代理对象:

private static class TN extends ITransientNotification.Stub {
    ...
}复制代码

那么 callback对象的show方法中须要传递的参数 record.token呢?实际上就是咱们刚才所说的NotificationManager服务所生成的窗口的 token。 相信你们已经对 AndroidBinder 机制已经熟门熟路了,当咱们调用 TN 代理对象的 show 方法的时候,至关于 RPC 调用了 TNshow 方法。来看下 TN 的代码:

复制代码
// code TN.java
final Handler mHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                IBinder token = (IBinder) msg.obj;
                handleShow(token);//处理界面显示
            }
        };
@Override
        public void show(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "SHOW: " + this);
            mHandler.obtainMessage(0, windowToken).sendToTarget();
        }复制代码
复制代码

这时候 TN 收到了 show 方法通知,将经过 mHandler 对象去 post 出一条命令为 0 的消息。实际上,就是一条显示窗口的消息。最终,将会调用handleShow(Binder) 方法:

复制代码
public void handleShow(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                    + " mNextView=" + mNextView);
            if (mView != mNextView) {
                ...
                mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
                ....
                mParams.token = windowToken;
                ...
                mWM.addView(mView, mParams);
                ...
            }
        }复制代码
复制代码

而这个显示窗口的方法很是简单,就是将所传递过来的窗口 token 赋值给窗口属性对象 mParams, 而后经过调用 WindowManager.addView 方法,将 Toast 中的mView 对象归入 WMS 的管理。

上面咱们解释了 NotificationManager 服务是如何将窗口 token 传递给 Android 进程,而且 Android 进程是如何显示的。咱们刚才也说到,NotificationManager 不只掌管着 Toast 的生成,也管理着 Toast 的时序控制。所以,咱们须要穿梭一下时空,回到 NotificationManagershowNextToastLocked() 方法。你们能够看到:在调用 callback.show 方法以后又调用了个 scheduleTimeoutLocked 方法:

record.callback.show(record.token);//通知进程显示
scheduleTimeoutLocked(record);//超时监听消息复制代码

而这个方法就是用于管理 Toast 时序:

复制代码
private void scheduleTimeoutLocked(ToastRecord r)
    {
        mHandler.removeCallbacksAndMessages(r);
        Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
        long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
        mHandler.sendMessageDelayed(m, delay);
    }复制代码
复制代码

scheduleTimeoutLocked 内部经过调用 HandlersendMessageDelayed 函数来实现定时调用,而这个 mHandler 对象的实现类,是一个叫作 WorkerHandler 的内部类:

复制代码
private final class WorkerHandler extends Handler
    {
        @Override
        public void handleMessage(Message msg)
        {
            switch (msg.what)
            {
                case MESSAGE_TIMEOUT:
                    handleTimeout((ToastRecord)msg.obj);
                    break;
                ....
            }
    } 
    private void handleTimeout(ToastRecord record)
    {
        synchronized (mToastQueue) {
            int index = indexOfToastLocked(record.pkg, record.callback);
            if (index >= 0) {
                cancelToastLocked(index);
            }
        }
    }复制代码
复制代码

WorkerHandler 处理 MESSAGE_TIMEOUT 消息会调用 handleTimeout(ToastRecord) 函数,而 handleTimeout(ToastRecord) 函数通过搜索后,将调用cancelToastLocked 函数取消掉 Toast 的显示:

复制代码
void cancelToastLocked(int index) {
        ToastRecord record = mToastQueue.get(index);
            ....
            record.callback.hide();//远程调用hide,通知客户端隐藏窗口
            ....

        ToastRecord lastToast = mToastQueue.remove(index);
        mWindowManagerInternal.removeWindowToken(lastToast.token, true);
        //将给 Toast 生成的窗口 Token 从 WMS 服务中删除
        ...复制代码
复制代码

cancelToastLocked 函数将作如下两件事:

  1. 远程调用 ITransientNotification.hide 方法,通知客户端隐藏窗口
  2. 将给 Toast 生成的窗口 TokenWMS 服务中删除

上面咱们就从源码的角度分析了一个Toast的显示和隐藏,咱们不妨再来捋一下思路,Toast 的显示和隐藏大体分红如下核心步骤:

  1. Toast 调用 show 方法的时候 ,其实是将本身归入到 NotificationManagerToast 管理中去,期间传递了一个本地的 TN 类型或者是ITransientNotification.StubBinder 对象
  2. NotificationManager 收到 Toast 的显示请求后,将生成一个 Binder 对象,将它做为一个窗口的 token 添加到 WMS 对象,而且类型是 TOAST
  3. NotificationManager 将这个窗口 token 经过 ITransientNotificationshow 方法传递给远程的 TN 对象,而且抛出一个超时监听消息scheduleTimeoutLocked
  4. TN 对象收到消息之后将往 Handler 对象中 post 显示消息,而后调用显示处理函数将 Toast 中的 View 添加到了 WMS 管理中, Toast 窗口显示
  5. NotificationManagerWorkerHandler 收到 MESSAGE_TIMEOUT 消息, NotificationManager 远程调用进程隐藏 Toast 窗口,而后将窗口 tokenWMS中删除

3. 异常产生的缘由

上面咱们分析了 Toast 的显示和隐藏的源码流程,那么为何会出现显示异常呢?咱们先来看下这个异常是什么呢?

Unable to add window -- token android.os.BinderProxy@7f652b2 is not valid; is your activity running?
    android.view.ViewRootImpl.setView(ViewRootImpl.java:826)
    android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:369)复制代码

首先,这个异常发生在 Toast 显示的时候,缘由是由于 token 失效。那么 token 为何会失效呢?咱们来看下下面的图:

一般状况下,按照正常的流程,是不会出现这种异常。可是因为在某些状况下, Android 进程某个 UI 线程的某个消息阻塞。致使 TNshow 方法 post 出来 0 (显示) 消息位于该消息以后,迟迟没有执行。这时候,NotificationManager 的超时检测结束,删除了 WMS 服务中的 token 记录。也就是如图所示,删除token 发生在 Android 进程 show 方法以前。这就致使了咱们上面的异常。咱们来写一段代码测试一下:

复制代码
public void click(View view) {
        Toast.makeText(this,"test",Toast.LENGTH_SHORT).show();
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
}复制代码
复制代码

咱们先调用 Toast.show 方法,而后在该 ui 线程消息中 sleep 10秒。当进程异常退出后咱们截取他们的日志能够获得:

复制代码
12-28 11:10:30.086 24599 24599 E AndroidRuntime: android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@2e5da2c is not valid; is your activity running?
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.view.ViewRootImpl.setView(ViewRootImpl.java:679)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:342)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:93)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.widget.Toast$TN.handleShow(Toast.java:434)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.widget.Toast$TN$2.handleMessage(Toast.java:345)复制代码
复制代码

果真如咱们所料,咱们复现了这个问题的堆栈。那么或许你会有下面几个疑问:

Toast.show 方法外增长 try-catch 有用么?

固然没用,按照咱们的源码分析,异常是发生在咱们的下一个 UI 线程消息中,所以咱们在上一个 ui 线程消息中加入 try-catch 是没有意义的

为何有些系统中没有这个异常,可是有时候 toast不显示?

咱们上面分析的是7.0的代码,而在8.0的代码中,Toast 中的 handleShow发生了变化:

复制代码
//code handleShow() android 8.0
                try {
                    mWM.addView(mView, mParams);
                    trySendAccessibilityEvent();
                } catch (WindowManager.BadTokenException e) {
                    /* ignore */
                }复制代码
复制代码

8.0 的代码中,对 mWM.addView 进行了 try-catch 包装,所以并不会抛出异常,但因为执行失败,所以不会显示 Toast

有哪些缘由引发的这个问题?

  1. 引发这个问题的也不必定是卡顿,当你的 TN 抛出消息的时候,前面有大量的 UI 线程消息等待执行,而每一个 UI 线程消息虽然并不卡顿,可是总和若是超过了 NotificationManager 的超时时间,仍是会出现问题
  2. UI 线程执行了一条很是耗时的操做,好比加载图片,大量浮点运算等等,好比咱们上面用 sleep 模拟的就是这种状况
  3. 在某些状况下,进程退后台或者息屏了,系统为了减小电量或者某种缘由,分配给进程的 cpu 时间减小,致使进程内的指令并不能被及时执行,这样同样会致使进程看起来”卡顿”的现象
相关文章
相关标签/搜索