zoukankan      html  css  js  c++  java
  • 彻底理解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相关源码

  • 相关阅读:
    COGS727 [网络流24题] 太空飞行计划
    Bzoj1692 洛谷P2870 [Usaco2007 Dec]队列变换
    Bzoj1029 [JSOI2007]建筑抢修
    Bzoj3168 [Heoi2013]钙铁锌硒维生素
    Bzoj4566 [Haoi2016]找相同字符
    Bzoj4771 七彩树
    Bzoj2597 [Wc2007]剪刀石头布
    Bzoj4773 负环
    HDU5772 String problem
    Bzoj1324 Exca王者之剑
  • 原文地址:https://www.cnblogs.com/lao-liang/p/5372125.html
Copyright © 2011-2022 走看看