概述

现在有好多应用需要做一个悬浮窗的功能或者说是可以在其他应用的上面显示自己的界面的时候,大多数的操作是(WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE),这样做就避免不了需要申请权限 ,最重要的是在Build.VERSION.SDK_INT >= 23的时候,还需要用户自己手动去给予这种权限。就是说你需要这种权限的时候还需要跳到权限赋予界面,让用户去操作,一旦用户不同意,那你的功能也许就不能用了,那这个时候我们怎么办呢?那就来看看我的Toast方式之悬浮窗(PS:前方高能!不要走神!)。

前瞻

我们都知道只要你的应用不挂,你就可以弹Toast,这样的话,我们就可以考虑把我们的悬浮窗用Toast实现,不设置它的显示时间,从而让它一直显示,这样的话就能达到我们的要求了。既然要用Toast,那我们就得看看Toast的源码了,470行实在是少,下面我们就来看一看。

第一,看看的就是构造函数

/* Construct an empty Toast object.  You must call {@link #setView} before you
 * can call {@link #show}.
 *
 * @param context  The context to use.  Usually your {@link android.app.Application}
 *                 or {@link android.app.Activity} object.
 */
public Toast(Context context) {
    mContext = context;
    mTN = new TN();
    mTN.mY = context.getResources().getDimensionPixelSize(
            com.android.internal.R.dimen.toast_y_offset);
    mTN.mGravity = context.getResources().getInteger(
            com.android.internal.R.integer.config_toastDefaultGravity);
}

这里面有一个非常重要的内部类那就是TN,我们得保持关注,其他的就是设置位置和对其方式什么的,可以不关注。

第二,看看我们经常用的show()方法

/*
 * 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
    }
}

这里看看就知道是把当前Toast的TN对象加入了INotificationManager管理的Taost队列,让它去操作的,这个我们不需要关心。

既然到处都是与TN相关,那我们就来看一看TN类吧

同样来看看TN的构造函数

TN() {
        // XXX This should be changed to use a Dialog, with a Theme.Toast
        // defined that sets up the layout params appropriately.
        final WindowManager.LayoutParams params = mParams;
        params.height = WindowManager.LayoutParams.WRAP_CONTENT;
        params.width = WindowManager.LayoutParams.WRAP_CONTENT;
        params.format = PixelFormat.TRANSLUCENT;
        params.windowAnimations = com.android.internal.R.style.Animation_Toast;
        params.type = WindowManager.LayoutParams.TYPE_TOAST;
        params.setTitle("Toast");
        params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
    }

在这里我们需要注意的是params.type,我们平常用到有TYPE_SYSTEM_DIALOG,TYPE_SYSTEM_OVERLAY,TYPE_SYSTEM_ALERT,TYPE_PHONE,TYPE_TOAST,在这里我们不说他们的区别了。再来看看params.flags,这个大家都熟悉,什么焦点、触摸等等,你可以根据你的需要去设置不同的标志位。 params.windowAnimations,这样的话我们还可以设置Toast的进出动画啦,真是开心。

看到TN类的时候我发现了这个 private static class TN extends ITransientNotification.Stub,我猜想Toast的显示与消失可能交给TN去处理了,我们继续接着往下看是不是有show()方法。

/*
     * schedule handleShow into the right thread
     */
    @Override
    public void show(IBinder windowToken) {
        if (localLOGV) Log.v(TAG, "SHOW: " + this);
        mHandler.obtainMessage(0, windowToken).sendToTarget();
    }

    final Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            IBinder token = (IBinder) msg.obj;
            handleShow(token);
        }
    };

果然有,这样我就验证了我的猜想,那现在就主要的就是去看看这个神奇的handShow(token)方法了。

public void handleShow(IBinder windowToken) {
        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;
            mParams.hideTimeoutMilliseconds = mDuration ==
                Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
            mParams.token = windowToken;
            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();
        }
    }

看到这里大家都知道了吧,TN做的就是把自己的配置信息同步给Toast,然后mWM.addView(mView, mParams),这样就可以显示了。Toast的取消就是调用TN的handleHide(),最后mWM.removeViewImmediate(mView)就可以了。在这里就不过多赘述了。

Demo

既然是要Toast按照我们的要求走,那就不能继承,直接暴力反射改变Toast的相关的成员变量的值,下面就来看看吧。

public View showToast(int layoutId, Context context, int x, int y) {
    LayoutInflater inflater = LayoutInflater.from(context);
    View view = inflater.inflate(layoutId, null);

    WindowManager.LayoutParams params = new WindowManager.LayoutParams();

    params.flags =  WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
    params.width =  WindowManager.LayoutParams.WRAP_CONTENT;
    params.height =  WindowManager.LayoutParams.WRAP_CONTENT;
    params.dimAmount = 0.6F;

    try {
        mToast = new Toast(view.getContext().getApplicationContext());
        mToast.setGravity(Gravity.CENTER, x, y);

        Field field = mToast.getClass().getDeclaredField("mTN");
        field.setAccessible(true);
        mTN = field.get(mToast);

        field = mTN.getClass().getDeclaredField("mNextView");
        field.setAccessible(true);
        field.set(mTN, view);

        if (params != null) {
            // TYPE_TOAST 18以下收不到触摸事件, 24以上禁止用
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M + 1) {
                return null;
            } else if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) {
                params.type = WindowManager.LayoutParams.TYPE_TOAST;
            } else {
                params.type = WindowManager.LayoutParams.TYPE_PHONE;
            }
            Field field1 = mTN.getClass().getDeclaredField("mParams");
            field1.setAccessible(true);
            WindowManager.LayoutParams p = (WindowManager.LayoutParams) field1.get(mTN);

            p.copyFrom(params);
            Method method = mTN.getClass().getDeclaredMethod("show");
            method.setAccessible(true);
            method.invoke(mTN);
            mIsDialogInShow = true;

        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return view;
}

上面的代码就不用我解释了把,我需要解释的就是我代码中唯一的一行注释,TYPE_TOAST 18以下收不到触摸事件, 24以上禁止用
先来解释API18 以下收不到触摸事件,因为在4.0.1以前, 当我们使用TYPE_TOAST时, Android会偷偷给我们加上FLAG_NOT_FOCUSABLE和FLAG_NOT_TOUCHABLE, 从4.0.1开始, 会额外再去掉FLAG_WATCH_OUTSIDE_TOUCH, 这样当然接收不到触摸事件,而且是任何事件. 而4.4开始, TYPE_TOAST就被移除那些Android加的flag, 所以从4.4开始, 使用TYPE_TOAST的同时还可以接收触摸事件和按键事件了。大家可以去看一下PhoneWindowManager的adjustWindowParamsLw()方法,WindowManager.addView()最后调用的就是这个方法。

//Android 2.0 - 2.3.7 PhoneWindowManager 
@Override
public void adjustWindowParamsLw(WindowManager.LayoutParams attrs) {
    switch (attrs.type) {
        case TYPE_SYSTEM_OVERLAY: 
        case TYPE_SECURE_SYSTEM_OVERLAY: 
        case TYPE_TOAST: 
            // These types of windows can't receive input events. 
            attrs.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; 
            break; } 
} 
//Android 4.0.1 - 4.3.1 PhoneWindowManager 
@Override
public void adjustWindowParamsLw(WindowManager.LayoutParams attrs) { 
    switch (attrs.type) { 
        case TYPE_SYSTEM_OVERLAY: 
        case TYPE_SECURE_SYSTEM_OVERLAY: 
        case TYPE_TOAST: 
            // These types of windows can't receive input events. 
            attrs.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; 
            attrs.flags &= ~WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH; 
            break; 
    } 
} 
//Android 4.4 PhoneWindowManager 
@Override 
public void adjustWindowParamsLw(WindowManager.LayoutParams attrs) { 
    switch (attrs.type) { 
        case TYPE_SYSTEM_OVERLAY:
        case TYPE_SECURE_SYSTEM_OVERLAY: 
            // These types of windows can't receive input events. 
            attrs.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE; 
            attrs.flags &= ~WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH; 
            break;
        case TYPE_TOAST:
            // While apps should use the dedicated toast APIs to add such windows
            // it possible legacy apps to add the window directly. Therefore, we
            // make windows added directly by the app behave as a toast as much
            // as possible in terms of timeout and animation.
            if (attrs.hideTimeoutMilliseconds < 0
                    || attrs.hideTimeoutMilliseconds > TOAST_WINDOW_TIMEOUT) {
                attrs.hideTimeoutMilliseconds = TOAST_WINDOW_TIMEOUT;
            }
            attrs.windowAnimations = com.android.internal.R.style.Animation_Toast;
            break;
    } 
}

现在来解释API24 为什么不能用TYPE_TOAST,因为时谷歌规定,没办法,那我们就来看看谷歌官方给的说明

/*
     * Prevent apps to overlay other apps via toast windows

     It was possible for apps to put toast type windows
     that overlay other apps which toast winodws aren't
     removed after a timeout.

     Now for apps targeting SDK greater than N MR1 to add a
     toast window one needs to have a special token. The token
     is added by the notificatoion manager service only for
     the lifetime of the shown toast and is then removed
     including all windows associated with this token. This
     prevents apps to add arbitrary toast windows.

     Since legacy apps may rely on the ability to directly
     add toasts we mitigate by allowing these apps to still
     add such windows for unlimited duration if this app is
     the currently focused one, i.e. the user interacts with
     it then it can overlay itself, otherwise we make sure
     these toast windows are removed after a timeout like
     a toast would be.

     We don't allow more that one toast window per UID being
     added at a time which prevents 1) legacy apps to put the
     same toast after a timeout to go around our new policy
     of hiding toasts after a while; 2) modern apps to reuse
     the passed token to add more than one window; Note that
     the notification manager shows toasts one at a time.
     */

这就我就没办法了,看以后有什么办法吧,对了,似乎忘记说TYPE_TOAST为什么不需要权限了,因为再添加view的时候,PhoneWindowManager会根据TYPE来判断是否需要权限,大家可以去看看PhoneWindowManager的checkAddPermission()方法。

总结

到这里,悬浮窗之Toast实现就讲完了,大家有没有收获呢。如果大家还有其他的方法来实现,也希望多多指教,谢谢!