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相关源码

  • 相关阅读:
    ADB命令大全
    Backup your Android without root or custom recovery -- adb backup
    Content portal for Pocketables Tasker articles
    Is there a way to detect if call is in progress? Phone Event
    Tasker to proximity screen off
    Tasker to detect application running in background
    Tasker to create toggle widget for ES ftp service -- Send Intent
    Tasker to proximity screen on
    Tasker to answer incoming call by pressing power button
    Tasker to stop Poweramp control for the headset while there is an incoming SMS
  • 原文地址:https://www.cnblogs.com/lao-liang/p/5372125.html
Copyright © 2011-2022 走看看