前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Android Toast使用技巧--提升展示能力

Android Toast使用技巧--提升展示能力

原创
作者头像
Jeffery
发布2019-06-16 14:23:35
2K0
发布2019-06-16 14:23:35
举报

在Android系统中有一个窗口的概念,我们所看到的所有界面都是窗口,由系统的WindowManagerService(WMS)管理着。App在没有悬浮窗权限下,不能添加系统级窗口,但却可以显示Toast。这说明Toast不在悬浮窗权限控制下,系统中某些机制保证了Toast的显示。

Toast 工作原理

NotificationManagerService

1、打开Toast源码,在调用Toast.show()后,Toast取出了系统服务NWS,与通知栏一样,Toast算是通知的一种。

代码语言:txt
复制
    /**
     * 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
        }
    }

其中TN是一个Binder对象,用于App与NMS间通信,对于NMS来说一个TN代表了一个Toast;mNextView是用于显示Toast内容的View,在使用Toast.makeText方法创建的Toast中,nNextView是一个包含TextView的LinearLayout。

代码语言:txt
复制
    /**
     * Make a standard toast to display using the specified looper.
     * If looper is null, Looper.myLooper() is used.
     * @hide
     */
    public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
            @NonNull CharSequence text, @Duration int duration) {
        Toast result = new Toast(context, looper);

        LayoutInflater inflate = (LayoutInflater)
                context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
        TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
        tv.setText(text);

        result.mNextView = v;
        result.mDuration = duration;

        return result;
    }

在show()中调用了NMS.enqueueToast(),相当于将Toast都集合到NMS中进行调度。当NMS调度到当次Toast时,则通过TN的Binder代理调用TN.show()方法。其中参数IBinder是一个窗口Token,用于添加窗口的鉴权。

代码语言:txt
复制
        /**
         * schedule handleShow into the right thread
         */
        @Override
        public void show(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "SHOW: " + this);
            mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
        }
WindowManagerService

上述通过mHandler调度最终执行到TN.handleShow()方法,通过WMS.addView将mNextView添加到新的窗口中,并设置该窗口属性,达到Toast显示的目的。

代码语言:txt
复制
        public void handleShow(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                    + " mNextView=" + mNextView);
            // If a cancel/hide is pending - no need to show - at this point
            // the window token is already invalid and no need to do any work.
            if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
                return;
            }
            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);
                // Since the notification manager service cancels the token right
                // after it notifies us to cancel the toast there is an inherent
                // race and we may attempt to add a window after the token has been
                // invalidated. Let us hedge against that.
                try {
                    mWM.addView(mView, mParams);
                    trySendAccessibilityEvent();
                } catch (WindowManager.BadTokenException e) {
                    /* ignore */
                }
            }
        }

Toast时长到达后,由NMS调度并调用TN.hide()

代码语言:txt
复制
        /**
         * schedule handleHide into the right thread
         */
        @Override
        public void hide() {
            if (localLOGV) Log.v(TAG, "HIDE: " + this);
            mHandler.obtainMessage(HIDE).sendToTarget();
        }

最终也是通过WMS.removeView()达到Toast消失的目的。

代码语言:txt
复制
        public void handleHide() {
            if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
            if (mView != null) {
                // note: checking parent() just to make sure the view has
                // been added...  i have seen cases where we get here when
                // the view isn't yet added, so let's try not to crash.
                if (mView.getParent() != null) {
                    if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                    mWM.removeViewImmediate(mView);
                }


                // Now that we've removed the view it's safe for the server to release
                // the resources.
                try {
                    getService().finishToken(mPackageName, this);
                } catch (RemoteException e) {
                }

                mView = null;
            }
        }

我们再看Toast与一般悬浮窗窗口的区别:Toast的窗口类型是TYPE_TOAST,其他使用悬浮窗使用的时TYPE_PHONE/TYPE_SYSTEM_ALTER。在系统侧WMS中针对不同的窗口类型有不同的限制:由源码中可以看到,在处理TYPE_TOAST类型的窗口时直接跳过了悬浮窗权限检查(在Android O及以上则需要一个有NMS分配的token),而TYPE_PHONE/TYPE_SYSYTEM_ALTER则需要权限检查。

代码语言:txt
复制
    @Override
    public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
        final int type = attrs.type;
        final boolean isRoundedCornerOverlay =
                (attrs.privateFlags & PRIVATE_FLAG_IS_ROUNDED_CORNERS_OVERLAY) != 0;

       if (isRoundedCornerOverlay && mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
                != PERMISSION_GRANTED) {
            return ADD_PERMISSION_DENIED;
        }

        outAppOp[0] = AppOpsManager.OP_NONE;

        if (!((type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW)
                || (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW)
                || (type >= FIRST_SYSTEM_WINDOW && type <= LAST_SYSTEM_WINDOW))) {
            return WindowManagerGlobal.ADD_INVALID_TYPE;
        }

        if (type < FIRST_SYSTEM_WINDOW || type > LAST_SYSTEM_WINDOW) {
            // Window manager will make sure these are okay.
            return ADD_OKAY;
        }

        if (!isSystemAlertWindowType(type)) {
            switch (type) {
                case TYPE_TOAST:
                    // Only apps that target older than O SDK can add window without a token, after
                    // that we require a token so apps cannot add toasts directly as the token is
                    // added by the notification system.
                    // Window manager does the checking for this.
                    outAppOp[0] = OP_TOAST_WINDOW;
                    return ADD_OKAY;
                case TYPE_DREAM:
                case TYPE_INPUT_METHOD:
                case TYPE_WALLPAPER:
                case TYPE_PRESENTATION:
                case TYPE_PRIVATE_PRESENTATION:
                case TYPE_VOICE_INTERACTION:
                case TYPE_ACCESSIBILITY_OVERLAY:
                case TYPE_QS_DIALOG:
                    // The window manager will check these.
                    return ADD_OKAY;
            }
            return mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
                    == PERMISSION_GRANTED ? ADD_OKAY : ADD_PERMISSION_DENIED;
        }

        // Things get a little more interesting for alert windows...
        outAppOp[0] = OP_SYSTEM_ALERT_WINDOW;

        final int callingUid = Binder.getCallingUid();
        // system processes will be automatically granted privilege to draw
        if (UserHandle.getAppId(callingUid) == Process.SYSTEM_UID) {
            return ADD_OKAY;
        }

        ApplicationInfo appInfo;
        try {
            appInfo = mContext.getPackageManager().getApplicationInfoAsUser(
                            attrs.packageName,
                            0 /* flags */,
                            UserHandle.getUserId(callingUid));
        } catch (PackageManager.NameNotFoundException e) {
            appInfo = null;
        }

        if (appInfo == null || (type != TYPE_APPLICATION_OVERLAY && appInfo.targetSdkVersion >= O)) {
            /**
             * Apps targeting >= {@link Build.VERSION_CODES#O} are required to hold
             * {@link android.Manifest.permission#INTERNAL_SYSTEM_WINDOW} (system signature apps)
             * permission to add alert windows that aren't
             * {@link android.view.WindowManager.LayoutParams#TYPE_APPLICATION_OVERLAY}.
             */
            return (mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
                    == PERMISSION_GRANTED) ? ADD_OKAY : ADD_PERMISSION_DENIED;
        }

        // check if user has enabled this operation. SecurityException will be thrown if this app
        // has not been allowed by the user
        final int mode = mAppOpsManager.noteOpNoThrow(outAppOp[0], callingUid, attrs.packageName);
        switch (mode) {
            case AppOpsManager.MODE_ALLOWED:
            case AppOpsManager.MODE_IGNORED:
                // although we return ADD_OKAY for MODE_IGNORED, the added window will
                // actually be hidden in WindowManagerService
                return ADD_OKAY;
            case AppOpsManager.MODE_ERRORED:
                // Don't crash legacy apps
                if (appInfo.targetSdkVersion < M) {
                    return ADD_OKAY;
                }
                return ADD_PERMISSION_DENIED;
            default:
                // in the default mode, we will make a decision here based on
                // checkCallingPermission()
                return (mContext.checkCallingOrSelfPermission(SYSTEM_ALERT_WINDOW)
                        == PERMISSION_GRANTED) ? ADD_OKAY : ADD_PERMISSION_DENIED;
        }
    }

实现原理

切入点

从上述的观察中可以发现,Toast机制是由NMS和WMS共同完成。但NMS和WMS两者间除了一个token外其实关联性并不强,从调度到最终展示需要App作为桥梁来完成,这给开发者提供了介入空间。虽然Toast的整个过程被封装在SDK中且公开接口很少,但还是发现了可切入的点:在TN.handleShow()方法中,有一段获取WindowManager的代码

代码语言:txt
复制
                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);

1、如果可以替换WindowManager,替换成自己实现的WindowManager,则可以在后续addView()时获取到token,并且修改Window属性和替换View;在removeView()时延迟执行,达到长时间显示效果;

2、想要替换WindowManager,首先需要替换context;

3、想要替换context,首先需要对mNextView的context进行替换.。

刚好SDK API的支持第三点,使得整个过程可以实现。

实现过程

1、需要实现一个WindowManager的Proxy,功能还是由系统的WindowManager提供,但在调用API的时候可以进行拦截、替换、监控。

代码语言:txt
复制
	public class FakeWindowService implements WindowManager {

		private WindowManager mWindowManager;

		public FakeWindowService(WindowManager windowManager) {
			mWindowManager = windowManager;
		}

		@Override
		public Display getDefaultDisplay() {
			return mWindowManager.getDefaultDisplay();
		}

		@Override
		public void removeViewImmediate(View view) {
			Log.i(TAG, "removeViewImmediate");
			// 进行拦截、监控、
			mWindowManager.removeViewImmediate(view);
			
		}

		@Override
		public void addView(View view, ViewGroup.LayoutParams params) {
			Log.i(TAG, "addView");
			// 替换View,修改params的属性
			// WindowManager.LayoutParams lp1 = (WindowManager.LayoutParams) params;
			// 加上可点击
			// lp1.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
			// 改变位置
			// lp1.gravity = Gravity.TOP;
			// 改变窗口高度
			// lp1.height = xx
			mWindowManager.addView(view, params);
			
		}

		@Override
		public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
			mWindowManager.updateViewLayout(view, params);
		}

		@Override
		public void removeView(View view) {
			Log.i(TAG, "removeView");
			// 进行拦截、监控、
			mWindowManager.removeView(view);
		}
	}

2、构造Context,并重写getSystemService(),在获取WINDOW_SERVICE时返回上述WindowManager。

代码语言:txt
复制
public class FakeContext extends ContextWrapper {

	private static final String TAG = "FakeContext";

	public FakeContext(Context base) {
		super(base);
	}

	@Override
	public Context getApplicationContext() {
		Log.i(TAG, "getApplicationContext");
		return this;
	}

	@Override
	public Object getSystemService(String name) {
		Log.i(TAG, "getSystemService, name=" + name);
		if (TextUtils.equals(name, Context.WINDOW_SERVICE)) {
			WindowManager windowManager = (WindowManager) getBaseContext().getSystemService(name);
			return new FakeWindowService(windowManager);
		}
		return super.getSystemService(name);
	}
}

3、使用FakeContext创建一个View,并以该View发起一个Toast展示。

代码语言:txt
复制
public static void tryUseToast(final Context context) {
	new Handler(Looper.getMainLooper()).post(new Runnable() {
		@Override
		public void run() {
			FakeContext fakeContext = new FakeContext(context.getApplicationContext());
			ToastLayout toastLayout = new ToastLayout(fakeContext);
			Toast toast = new Toast(fakeContext);
			toast.setView(toastLayout);
			toast.setDuration(Toast.LENGTH_LONG);
			toast.show();
		}
	});
}

局限

1、Android4.4以下WMS针对TYPE_TOAST类型窗口会去掉焦点和触摸属性,只能作显示用;

2、Android7.1及以上,不能同时存在两个TYPE_TOAST窗口,且每个窗口只能最长停留3.5秒,超时后即使在代码拦截removeView()操作,窗口也会变成不可见。

适配情况

1、由于Toast机制依赖于NMS,部分机型需要开启通知栏权限才能使用Toast;

2、部分机型在App界面外无法使用Toast,如:OPPO;

3、部分机型在App界面外Toast流程正常执行,但Toast窗口不会显示,如:Vivo;

4、之前也有直接使用TYPE_TOAST进行addView()添加窗口的做法,但Android8.0之后TYPE_TOAS窗口需要NMS发放的一个token,所以不能直接添加窗口。本方案可以覆盖Android8.0机型上。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Toast 工作原理
    • NotificationManagerService
      • WindowManagerService
      • 实现原理
        • 切入点
          • 实现过程
          • 局限
          • 适配情况
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档