彻底理解Toast原理和解决小米MIUI系统上没法弹Toast的问题

1、Toast的基本使用

  Toast在Android中属于系统消息通知,用来提示用户完成了什么操作、或者给用户一个必要的提醒。Toast的官方定义是这样的:

A toast provides simple feedback about an operation in a small popup. It only fills the amount of space required for the message and the current activity remains visible and interactive.

  它仅仅用作一个简单的反馈机制。使用也比较简单:

Context context = getApplicationContext();
CharSequence text = "Hello toast!";
int duration = Toast.LENGTH_SHORT;

Toast toast = Toast.makeText(context, text, duration);
toast.show();

  一般情况下,我们传入一个String就基本上满足大多数的需求。但要想自定义一个View,然后通过Toast进行显示,也仅仅多了设置View的操作。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:id="@+id/toast_layout_root"
              android:orientation="horizontal"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent"
              android:padding="8dp"
              android:background="#DAAA"
              >
    <ImageView android:src="@drawable/droid"
               android:layout_width="wrap_content"
               android:layout_height="wrap_content"
               android:layout_marginRight="8dp"
               />
    <TextView android:id="@+id/text"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:textColor="#FFF"
              />
</LinearLayout>

  我们把这个文件命名为toast_layout.xml,然后在代码中加载它。

LayoutInflater inflater = getLayoutInflater();
View layout = inflater.inflate(R.layout.toast_layout,
                               (ViewGroup) findViewById(R.id.toast_layout_root));

TextView text = (TextView) layout.findViewById(R.id.text);
text.setText("This is a custom toast");

Toast toast = new Toast(getApplicationContext());
toast.setGravity(Gravity.CENTER_VERTICAL, 0, 0);
toast.setDuration(Toast.LENGTH_LONG);
toast.setView(layout);
toast.show();

  其实就是这么简单。

2、Toast原理解剖

  但现实是,产品需求说你给我控制Toast显示的时间。咋一看好像也不难嘛。

  不是有个setDuration方法么?当你翻看源码的时候,你会发现它的描述参数只有以下两种:

LENGTH_SHORT
LENGTH_LONG

  这两个常量对应着2秒和3.5秒,你传个其它数字进入,效果并不是你所预料。其实这两个常量仅仅是个flag,并不是我们想的多少秒。官方API文档告诉我们:

This time could be user-definable.

  但,它又不提供一个公开的方法让你设置。抓狂!先看一下Toast的显示和隐藏在代码层面做了什么事情。

/**
 * Show the view for the specified duration.
 */
public void show() {
    if (mNextView == null) {
        throw new RuntimeException("setView must have been called");
    }

    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;

    try {
        service.enqueueToast(pkg, tn, mDuration);
    } catch (RemoteException e) {
        // Empty
    }
}
/**
 * Close the view if it's showing, or don't show it if it isn't showing yet.
 * You do not normally have to call this.  Normally view will disappear on its own
 * after the appropriate duration.
 */
public void cancel() {
    mTN.hide();

    try {
        getService().cancelToast(mContext.getPackageName(), mTN);
    } catch (RemoteException e) {
        // Empty
    }
}

  理解这两个方法,需要深挖getService()到底调用了那个类做enqueueToast的操作?TN类是干什么的?继续跟踪代码。

static private INotificationManager getService() {
    if (sService != null) {
        return sService;
    }
    sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
    return sService;
}

  看到Stub.asInterface,我们知道这是利用Binder进行跨进程调用了。而TN类就是遵循AIDL的实现。

private static class TN extends ITransientNotification.Stub

  TN类内部使用Handler机制:post一个mShow和mHide:

final Runnable mShow = new Runnable() {
        @Override
        public void run() {
            handleShow();
        }
    };

final Runnable mHide = new Runnable() {
    @Override
    public void run() {
        handleHide();
        // Don't do this in handleHide() because it is also invoked by handleShow()
        mNextView = null;
    }
};

  再来看handleShow()方法的实现:

public void handleShow() {
        if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                + " mNextView=" + mNextView);
        if (mView != mNextView) {
            // remove the old view if necessary
            handleHide();
            mView = mNextView;
            Context context = mView.getContext().getApplicationContext();
            String packageName = mView.getContext().getOpPackageName();
            if (context == null) {
                context = mView.getContext();
            }
            mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
            // We can resolve the Gravity here by using the Locale for getting
            // the layout direction
            final Configuration config = mView.getContext().getResources().getConfiguration();
            final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
            mParams.gravity = gravity;
            if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
                mParams.horizontalWeight = 1.0f;
            }
            if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
                mParams.verticalWeight = 1.0f;
            }
            mParams.x = mX;
            mParams.y = mY;
            mParams.verticalMargin = mVerticalMargin;
            mParams.horizontalMargin = mHorizontalMargin;
            mParams.packageName = packageName;
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeView(mView);
            }
            if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
            mWM.addView(mView, mParams);
            trySendAccessibilityEvent();
        }
    }

  大概意思就是通过WindowManager的addView方法实现Toast的显示。其中trySendAccessibilityEvent()方法会把当前的类名、应用的包名通过AccessibilityManager来做进一步的分发,以供后续的处理。

private void trySendAccessibilityEvent() {
        AccessibilityManager accessibilityManager =
                AccessibilityManager.getInstance(mView.getContext());
        if (!accessibilityManager.isEnabled()) {
            return;
        }
        // treat toasts as notifications since they are used to
        // announce a transient piece of information to the user
        AccessibilityEvent event = AccessibilityEvent.obtain(
                    AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
            event.setClassName(getClass().getName());
            event.setPackageName(mView.getContext().getPackageName());
        mView.dispatchPopulateAccessibilityEvent(event);
        accessibilityManager.sendAccessibilityEvent(event);
    }

  先回到前面的enqueueToast方法,看它做了什么事情。前面的INotificationManager service = getService()返回的就是NotificationManagerService,所以enqueueToast方法的最终实现在NotificationManagerService类中。

@Override
public void enqueueToast(String pkg, ITransientNotification callback, int duration)
{
    if (DBG) {
        Slog.i(TAG, "enqueueToast pkg=" + pkg + " callback=" + callback
                + " duration=" + duration);
    }

    if (pkg == null || callback == null) {
        Slog.e(TAG, "Not doing toast. pkg=" + pkg + " callback=" + callback);
        return ;
    }

    final boolean isSystemToast = isCallerSystem() || ("android".equals(pkg));

    if (ENABLE_BLOCKED_TOASTS && !noteNotificationOp(pkg, Binder.getCallingUid())) {
        if (!isSystemToast) {
            Slog.e(TAG, "Suppressing toast from package " + pkg + " by user request.");
            return;
        }
    }

    synchronized (mToastQueue) {
        int callingPid = Binder.getCallingPid();
        long callingId = Binder.clearCallingIdentity();
        try {
            ToastRecord record;
            int index = indexOfToastLocked(pkg, callback);
            // If it's already in the queue, we update it in place, we don't
            // move it to the end of the queue.
            if (index >= 0) {
                record = mToastQueue.get(index);
                record.update(duration);
            } else {
                // 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) {
                                 Slog.e(TAG, "Package has already posted " + count
                                        + " toasts. Not showing more. Package=" + pkg);
                                 return;
                             }
                         }
                    }
                }

                record = new ToastRecord(callingPid, pkg, callback, duration);
                mToastQueue.add(record);
                index = mToastQueue.size() - 1;
                keepProcessAliveLocked(callingPid);
            }
            // If it's at index 0, it's the current toast.  It doesn't matter if it's
            // new or just been updated.  Call back and tell it to show itself.
            // If the callback fails, this will remove it from the list, so don't
            // assume that it's valid after this.
            if (index == 0) {
                showNextToastLocked();
            }
        } finally {
            Binder.restoreCallingIdentity(callingId);
        }
    }
}
static final int MAX_PACKAGE_NOTIFICATIONS = 50;
static final int LONG_DELAY = 3500; // 3.5 seconds
static final int SHORT_DELAY = 2000; // 2 seconds

  这段代码主要做了以下几件事情:

  • 获取当前进程的Id。
  • 查看这个Toast是否在队列中,有的话直接返回,并更新显示时间。
  • 如果是非系统的Toast(通过应用包名进行判断),且Toast的总数大于等于50,不再把新的Toast放入队列。
  • 最后通过keepProcessAliveLocked(callingPid)方法来设置对应的进程为前台进程,保证不被销毁。
  • 如果index = 0,说明Toast就处于队列的头部,直接进行显示。
  • 我们在NotificationManagerService类中确认了前面提到的LENGTH_SHORT和LENGTH_LONG的显示时长。

  关于上述的第四点,我们通过Toast类型的定义来印证代码:

/**
 * Window type: transient notifications.
 * In multiuser systems shows only on the owning user's window.
 */
public static final int TYPE_TOAST              = FIRST_SYSTEM_WINDOW+5;

  所以一旦应用被销毁,它对应的Toast也将不会再显示:shows only on the owning user's window. 再来看这个keepProcessAliveLocked方法:

// lock on mToastQueue
void keepProcessAliveLocked(int pid)
{
    int toastCount = 0; // toasts from this pid
    ArrayList<ToastRecord> list = mToastQueue;
    int N = list.size();
    for (int i=0; i<N; i++) {
        ToastRecord r = list.get(i);
        if (r.pid == pid) {
            toastCount++;
        }
    }
    try {
        mAm.setProcessForeground(mForegroundToken, pid, toastCount > 0);
    } catch (RemoteException e) {
        // Shouldn't happen.
    }
}

  其中mAm是一个ActivityManagerService实例,所以调用最终进入到ActivityManagerService的setProcessForeground方法进行再次处理。下面我用一张序列图展示整个调用流程:

  其中第八步的scheduleTimeoutLocked()实质上就是利用Handler延时发送一个Message,回调TN类的hide()方法,最终通过WindowManager的removeView()来隐藏之前显示的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);
    }

  至此,Toast的显示和隐藏已经分析完毕。原理搞清楚了,让我们回到一开始提到的问题,如何控制Toast的显示时长?

  思路1:通过反射的方式调用TN类中的show和hide方法。

  代码大概像这样:

Object obj = message.obj;  
Method method =  obj.getClass().getDeclaredMethod("hide", null);  
method.invoke(obj, null); 

  但是很可惜,Method method =  obj.getClass().getDeclaredMethod("hide", null);  这种方法在4.0之上已经不适用了。

  思路2:不让Toast进入系统队列,我们自己维护一个队列。

  这种方式其实仿照一下TN类中的实现,结合LinkedBlockingQueue和WindowManager就可以了。关于如何实现,后面有相应的源码链接。

3、Toast在某些系统无法显示问题

  此问题常见于小米系统。MIUI上可能是出于“绿化”的考虑,在维护Toast队列的时候,Toast只能在自己进程运行在顶端的时候才能弹出来,否则就“invisible to user”。乱改系统行为,简直丧心病狂有木有,最终苦的是广大Android开发人员。不过有了上面的理论准备,要解决也是没有问题的,参照思路2。

  对于这个问题,已经有人给出了源码实现,请参考问题描述:解决小米MIUI系统上后台应用没法弹Toast的问题Github源码地址:https://github.com/zhitaocai/ToastCompat

  本来到这里就可以结束了,但笔者在实际开发中遭遇了一个小小的坑。

mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);

  这个坑就是上面的mContext,它必须是ApplicationContext,不然在小米3或小米Note(Android 4.4.4)无法起作用!

  以上。

 

参考:

Android SDK - Toast

Toast相关源码

posted @ 2016-04-10 18:20  LeoLiang  阅读(15792)  评论(0编辑  收藏  举报