专栏首页Jeffery的项目过程Android Toast使用技巧--提升展示能力
原创

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

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

Toast 工作原理

NotificationManagerService

1、打开Toast源码,在调用Toast.show()后,Toast取出了系统服务NWS,与通知栏一样,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
        }
    }

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

    /**
     * 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,用于添加窗口的鉴权。

        /**
         * 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显示的目的。

        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()

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

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

        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则需要权限检查。

    @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的代码

                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的时候可以进行拦截、替换、监控。

	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。

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展示。

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机型上。

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

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

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Android API Level对应版本

    Google 对外正式发布的版本,除了部分厂商(国产)会对这些版本做些修改,大部分手机预装的系统与Google正式发布版本差别不大。

    Jeffery
  • 云主机搭建Git服务器

    版本控制是一个项目不可或缺的一部分,可以帮助开发者追踪文件的变更、分支管理、版本回退、版本发布等。所以建议当开始一个项目或者有想法进行开发时,都尽量考虑用上版本...

    Jeffery
  • 云服务+红包提醒

    Jeffery
  • 金蝶、用友中期财报冰火两重天 ,南北之争已名存实亡

    作者:人称 T 客 关键词:金蝶,用友,中期财报 网站:www.tikehui.com 近来,金蝶和用友的中期财报发布,如果单从两家财报来看可谓冰火两重天,...

    人称T客
  • PsLookupProcessByProcessId function

    The PsLookupProcessByProcessId routine accepts the process ID of a process and r...

    战神伽罗
  • 浅谈Hex编码算法

    听着music睡
  • Python的 if .else.eli

    Python 编程中 if 语句用于控制程序执行 用来检测一个条件:如果条件为 (真)true,就会运行这个语法块,如果为Fales 就跳过不执行。

    py3study
  • ExtJs学习笔记(13)_Card布局

    这个布局其实最为麻烦,主要是用来实现类似"上一步","下一步"类似向导的界面 代码如下: <!DOCTYPE html PUBLIC "-//W3C//DTD...

    菩提树下的杨过
  • 帕胖展示Oculus Go完整拆卸图,揭示延长电池续航的可能性

    VRPinea
  • VB语言基础重要知识点05

    点击“显示form2窗体”按钮后,弹出询问窗口,提示信息“是否真的要打开form2窗体?”

    刘金玉编程

扫码关注云+社区

领取腾讯云代金券