美文网首页Android
系统通知栏关闭,Toast不好使了?

系统通知栏关闭,Toast不好使了?

作者: mandypig | 来源:发表于2022-05-28 13:41 被阅读0次

    toast作为android系统发布以来一直伴随的一种提示交互,可以说做android开发的无人不晓。但即使就是这么一个常见到让人觉得平平无奇的系统类,但也存在不得不需要解决的问题。

    系统bug?

    当部分品牌的手机关闭掉app的通知栏权限后,你会发现toast居然神奇的消失了,拜托我只是想关闭掉烦人的通知栏,为何你toast也跟着消失了。关闭app的通知栏权限也算一个比较常见的操作吧,以自己为例,虽然安装了不少app,但各种系统通知栏弹的也是让人神烦,所以一般我每安装一个app都会将对应的通知栏权限给关闭掉。

    实测中发现公司的三星部分测试机以及华为的pad均出现了关闭通知栏后toast弹出异常的问题。其他测试机则正常显示taost。但也仅代表公司的这部分测试机正常。toast的使用可以说是非常的常见,但是如果存在这种问题的话,那么对于toast的使用我们还能这么心安理得吗。

    一种比较常见的使用场景,账号密码登录场景,如果存在toast无法弹出的问题,那么在用户输入完毕进行登录时,如果存在密码错误的情况下一般通过toast进行提示。如果此时toast无法弹出,那么可能导致用户误以为app没有交互响应影响app的后继使用。

    原因分析

    为什么会出现部分手机在关闭通知栏权限的情况下导致toast无法弹出,其实可以在toast的源码中找到一些蛛丝马迹。首先toast之所以可以展示在屏幕上就是通过系统的windowmanager来实现,在handleShow中存在如下源码

    public void handleShow(IBinder windowToken) {
                 ...
                    try {
                        mWM.addView(mView, mParams);
                        trySendAccessibilityEvent();
                    } catch (WindowManager.BadTokenException e) {
                        /* ignore */
                    }
                }
            }
    

    通过wm的addview方法将mview展示在手机屏幕上。而mview正是toast上所要展示的ui,现在的问题就在于handleShow是如何被调用的,可以发现该方法实际上是在一个handler中被调用的

    mHandler = new Handler(looper, null) {
                    @Override
                    public void handleMessage(Message msg) {
                        switch (msg.what) {
                            case SHOW: {
                                IBinder token = (IBinder) msg.obj;
                                handleShow(token);
                                break;
                            }
                          ......
                                break;
                            }
                        }
                    }
                };
    

    而mHandler是被TN调用,关于TN就是一个binder对象用来响应远程service的指令,service发出show指令则show显示,发出hide指令则toast消失。查看toast的show源码

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

    可以发现得到NotificationManager对象,并将要显示toast的请求通过enqueueToast发送给NotificationManagerService对象,换句话说toast是否可以显示完全由NotificationManagerService说了算,完全有一种命运掌握在别人手里的味道,这也就不难理解,如果系统通知栏权限关闭的情况下为什么toast无法弹出的问题

    如何解决toast无法弹出问题

    简单分析了上述源码,已经发现toast是否弹出完全由NotificationManagerService决定,那么解决思路比较简单,直接绕过NotificationManagerService的决策,将命运掌握在自己的手里,我命由我不由天,是show是hide我自己说了算。那么通过自己编写逻辑来决定toast的显示与隐藏即可,只需解决以下两个问题
    1 如何仿系统原生效果实现toast的弹出与隐藏,包括toast的动画效果
    2 当多个toast依次弹出时,需要保证toast可以全部依次展示

    第一个问题的解决方法可以直接在toast的源码中找到,上述源码分析也提到过这个方法即handleshow,内部有关于wm一些参数的设置,参考这部分源码即可解决。

    第二个问题可以通过维护一个toast队列来解决,将需要展示的toast依次保存然后依次弹出

    只要想明白上述两个问题,剩下的就是代码的编写工作了。文末会直接给出自己实现的解决源码。但在实际编码过程中还遇到了另外的问题

    如何判断通知栏关闭后toast无法弹出

    并不是所有的手机在关闭了系统通知栏权限后都会出现toast无法弹出,相同版本的android系统,在国内不同品牌的手机上表现并不一致,应该是产商对这块的实现源码进行过修改导致的。如果采用依次收集问题品牌机的方式来判断是否采用自定义toast那么工作量也有太大,所以经过考虑通过简单直接的方式解决这个问题,判断通知栏权限是否关闭,如果关闭直接使用自定义的方式弹toast,否则使用系统的toast。这样就能做到toast肯定可以弹出

    toast弹出使用的context

    这个问题处理起来会比较麻烦一些,查看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 */
                    }
                }
            }
    

    有两个地方需要特别注意一下,首先就是handleshow中传递的参数windowToken是有用处的,查看源码可以发现最终会通过

    mParams.token = windowToken
    

    赋值给layoutparams对象,而layoutparams是WindowManager调用addview时不可缺少的传参,这个token起着校验的作用,windowmanagerservice最终会检查token是否合理,只有token合理的情况才会回调到handleshow方法,否则校验不通过的话会抛出一个大家非常熟悉的一个异常

    Fatal Exception: android.view.WindowManager$BadTokenException
    

    告诉开发者这是一个错误的token,所以这个token必须填写,toast源码中接收到了一个合适的token,这个token是和notificationManagerService交互得到的。但我们自定义的toast并没有和notificationManagerService交互,那么又该如何获取这个token。

    Toast源码通过notificationManagerService获取到token,并使用applicationContext得到一个mWM对象,然后调用addview。源码如下

    Context context = mView.getContext().getApplicationContext();
    String packageName = mView.getContext().getOpPackageName();
    if (context == null) {
            context = mView.getContext();
    }
    mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
    

    得到的context是一个applicationContext,如果我们也获取一个applicationContext,然后忽略token设置是否可以成功,经过测试发现这条路走不通。

    Activity context使用

    好在google给你关上一扇门的情况,给你另外开了一扇窗,那就是大家都非常熟悉的activity context,实测后发现直接使用activity的context得到的mWM是可以不设置token参数,并且可以正常工作的!但是这里会有一个问题就是使用activity context得到mWM弹出的toast在activity关闭之后会立即消失不见的,也就是说toast没有达到真正意义上的悬浮效果,实际上虽然我们没有显式指定token,但是使用activity context得到mWM调用addview内部是会自动给我们设置token的!!

    而这个token和activity的window相关联,当activity关闭window消失,那么附加上它上面的toast也会跟着消失了。看下源码设置token的流程

    @Override
        public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
            applyDefaultToken(params);
            mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
        }
    

    applyDefaultToken方法内部会在mDefaultToken不为null的情况下设置给params的token,实际调试发现applyDefaultToken一直为null,所以真正设置token的源码在mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);mGlobal实际上是一个WindowManagerGlobal对象,源码比较多,只看重点部分

     public void addView(View view, ViewGroup.LayoutParams params,
                Display display, Window parentWindow) {
           ......
            final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
            if (parentWindow != null) {
                parentWindow.adjustLayoutParamsForSubWindow(wparams);
            } else {
               ......
            }
            ......
     }
    

    通过adjustLayoutParamsForSubWindow对params进行设置,parentWindow实际是一个PhoneWindow对象

    void adjustLayoutParamsForSubWindow(WindowManager.LayoutParams wp) {
            CharSequence curTitle = wp.getTitle();
            if (wp.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
                    wp.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
                if (wp.token == null) {
                    View decor = peekDecorView();
                    if (decor != null) {
                        wp.token = decor.getWindowToken();
                    }
            ......
    }
    

    在该方法中我们找到了非常关键的设置代码,通过view的getWindowToken对象给params设置token,再继续深入看下

     public IBinder getWindowToken() {
            return mAttachInfo != null ? mAttachInfo.mWindowToken : null;
     }
    

    实际上就是通过mWindowToken对象得到token,而这个attachinfo又是什么时候设置的呢,如果对activity启动流程比较熟悉的话应该知道attachinfo对象是在viewrootimpl创建的时候进行设置的,而viewrootimpl对象又可以说是decorview的管理类用来发起view的测绘等各种操作,来看一下viewrootimpl创建时的逻辑

    public ViewRootImpl(Context context, Display display) {
            ...
            mWindow = new W(this);
           ...
            mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this,
                    context);
           ...
        }
    

    将mWindow对象传递给attachinfo,内部通过mWindow.asBind得到ibind对象然后赋值给attachinfo的mWindowToken对象,到此关于使用activity context为什么可以不显示设置token的问题就得到解答了。

    使用activity context的一点优化

    文章已经说过使用activity的context在activity被关闭的情况下toast是会立即消失的,这个交互相比使用toast原生的实现差了一点,不过自己确实没有太好的办法解决这个问题,好在实际使用场景影响不是很大。

    不过另一个问题需要特别留意一下,如果在某一个activity上需要连续依次弹出多个toast,在toast还没有完全弹完的情况下就关闭掉了activity那么剩下的toast该如何处理,如果直接全部抛弃掉这些toast显然不太合适,其实只要将这些toast所需要的context从原先被关闭的activity重置为当前可见activity的context即可完美解决掉这个问题,如何找到当前可见的activity,通过registerActivityLifecycleCallbacks监听每一个activity即可。

    到此关于自定义toast逻辑实现,解决系统通知栏关闭的情况下toast不弹出的问题的主要解决思路都已经在上述文章解释清楚了,剩下的就是代码的编写,主要实现就以下三个类ToastAdapter,ToastWrapper,TopActivityWatcher。

    ToastAdapter

    ToastAdapter的主要作用就是判断系统通知栏是否关闭,然后采用不同的处理方式,源码如下:

    @RestrictTo(RestrictTo.Scope.LIBRARY)
    public class ToastAdapter {
    
        private static boolean notificationEnable;
        private static ToastAdapter instance;
        private static TopActivityWatcher liveData;
        private static boolean init;
    
        private ToastAdapter() {
            if (!init) {
                throw new IllegalStateException("先调用ToastAdapter init");
            }
        }
    
        public static void init(Application application) {
            notificationEnable = areNotificationsEnabled(application);
            init = true;
            if (notificationEnable) {
                return;
            }
            liveData = ToastWrapper.registerObserver();
            application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
                @Override
                public void onActivityCreated(Activity activity, Bundle bundle) {
    //                Log.e("mandy", "onActivityCreated activity==" + activity);
                }
    
                @Override
                public void onActivityStarted(Activity activity) {
    //                Log.e("mandy", "onActivityStarted activity==" + activity);
                }
    
                @Override
                public void onActivityResumed(Activity activity) {
    //                Log.e("mandy", "onActivityResumed activity==" + activity);
                    liveData.resume(activity);
                }
    
                @Override
                public void onActivityPaused(Activity activity) {
    //                Log.e("mandy", "onActivityPaused activity==" + activity);
                }
    
                @Override
                public void onActivityStopped(Activity activity) {
    //                Log.e("mandy", "onActivityStopped activity==" + activity);
                }
    
                @Override
                public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
    
                }
    
                @Override
                public void onActivityDestroyed(Activity activity) {
    //                Log.e("mandy", "onActivityDestroyed activity==" + activity);
                    liveData.destroyed(activity);
                }
            });
        }
    
        public static void show(Toast toast) {
            getInstance().showInner(toast);
        }
    
        private static ToastAdapter getInstance() {
            if (instance == null) {
                synchronized (ToastAdapter.class) {
                    if (instance == null) {
                        instance = new ToastAdapter();
                    }
                }
            }
            return instance;
        }
    
        /**
         * 检测通知权限是否开启
         */
        private static boolean areNotificationsEnabled(Context context) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
                return manager != null && manager.areNotificationsEnabled();
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
                ApplicationInfo appInfo = context.getApplicationInfo();
                String packageName = context.getApplicationContext().getPackageName();
                int uid = appInfo.uid;
    
                try {
                    Class<?> appOpsClass = Class.forName(AppOpsManager.class.getName());
                    Method checkOpNoThrowMethod = appOpsClass.getMethod("checkOpNoThrow", Integer.TYPE, Integer.TYPE, String.class);
                    Field opPostNotificationValue = appOpsClass.getDeclaredField("OP_POST_NOTIFICATION");
                    int value = (Integer) opPostNotificationValue.get(Integer.class);
                    return ((int) checkOpNoThrowMethod.invoke(appOps, value, uid, packageName) == AppOpsManager.MODE_ALLOWED);
                } catch (ClassNotFoundException | NoSuchMethodException | NoSuchFieldException
                        | InvocationTargetException | IllegalAccessException | RuntimeException ignored) {
                    return true;
                }
            } else {
                return true;
            }
        }
    
        /**
         * 通知栏开启的情况下直接使用原生toast即可,通知栏关闭的情况下
         * 个别机型存在toast无法弹出的问题,使用ToastWrapper
         */
        private void showInner(Toast toast) {
            Toast localToast;
            localToast = reformToast(toast);
            if (localToast == null) {
                localToast = toast;
            }
            if (notificationEnable) {
                localToast.show();
            } else {
                ToastWrapper.show(localToast);
            }
        }
    
        private Toast reformToast(Toast toast) {
            if (notificationEnable && Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1) {
                return injectToast(toast);
            }
            return toast;
        }
    
        /**
         * 7.1系统存在ui线程被阻塞,toast可能崩溃问题
         */
        private Toast injectToast(Toast toast) {
            try {
                Field field = Toast.class.getDeclaredField("mTN");
                field.setAccessible(true);
                Object TN = field.get(toast);
                Field handlerField = field.getType().getDeclaredField("mHandler");
                handlerField.setAccessible(true);
                Handler handler = (Handler) handlerField.get(TN);
                handlerField.set(TN, new ToastHandler(handler));
                return toast;
            } catch (IllegalAccessException | NoSuchFieldException ignored) {
                return null;
            }
        }
    
        static class ToastHandler extends Handler {
    
            private Handler mHandler;
    
            ToastHandler(Handler handler) {
                mHandler = handler;
            }
    
            @Override
            public void handleMessage(Message msg) {
                try {
                    mHandler.handleMessage(msg);
                } catch (Exception e) {
                    //nothing
                }
            }
        }
    }
    

    TopActivityWatcher

    TopActivityWatcher用来监听当前可见activity,比较简单

    class TopActivityWatcher extends MutableLiveData<Activity> {
    
        private WeakReference<Activity> mActivity;
    
        void resume(Activity activity) {
            mActivity = new WeakReference<>(activity);
            setValue(activity);
        }
    
        /**
         * 个别三方库在启动activity之后直接finish掉,导致resume不能被调用到
         * */
        void destroyed(Activity activity) {
            if (mActivity != null && mActivity.get() == activity) {
                mActivity = null;
                setValue(null);
            }
        }
    }
    

    ToastWrapper

    ToastWrapper就是主要的自定义toast展示逻辑实现类,包括了toast队列的处理

    
    class ToastWrapper extends Handler {
    
        private final static int TOAST_HIDE = 1;
        private final static int TOAST_SHOW = 2;
        private final static long DELAYED_TIME = 300;
        private static LinkedBlockingQueue<ToastItem> toasts = new LinkedBlockingQueue<>();
        private static WeakReference<Activity> current;
    
        private ToastWrapper() {
            super(Looper.getMainLooper());
        }
    
        public static void show(Toast toast) {
            ToastItem toastItem = new ToastItem();
            if (current == null || current.get() == null) {
                return;
            }
            toastItem.currentActivity = current;
            toastItem.toast = toast;
            boolean show;
            //防止在有handler的线程出现并发问题
            synchronized (ToastWrapper.class) {
                toasts.offer(toastItem);
                show = toasts.size() == 1;
            }
            if (show) {
                ToastWrapper wrapper = new ToastWrapper();
                wrapper.show();
            }
        }
    
        static TopActivityWatcher registerObserver() {
            TopActivityWatcher liveData = new TopActivityWatcher();
            liveData.observeForever(new Observer<Activity>() {
                @Override
                public void onChanged(@Nullable Activity activity) {
                    if (activity != null) {
                        current = new WeakReference<>(activity);
                    } else {
                        current = null;
                    }
                }
            });
            return liveData;
        }
    
        public void show() {
            showInner();
        }
    
        private void showInner() {
            Message message = Message.obtain();
            message.what = TOAST_SHOW;
            sendMessage(message);
        }
    
        @Override
        public void handleMessage(Message msg) {
            try {
                if (current == null) {
                    reset();
                    return;
                }
                /**
                * 解决taost弹出时activity立即finish导致toast不显示的问题
                */
                if (current.get() != null && current.get().isFinishing()) {
                    Message message = Message.obtain();
                    message.copyFrom(msg);
                    sendMessageDelayed(message, DELAYED_TIME);
                    return;
                }
                int what = msg.what;
                if (what == TOAST_HIDE) {
                    hideToast();
                } else if (what == TOAST_SHOW) {
                    showToast();
                }
            } catch (Exception e) {
                e.printStackTrace();
                LogUtil.e("mandy", "boom");
                reset();
            }
        }
    
        private void reset() {
            toasts.clear();
        }
    
        private void showToast() {
            if (toasts.isEmpty()) {
                return;
            }
            ToastItem item = toasts.peek();
            Toast toast = item.toast;
            item.currentActivity = current;
            Activity context = item.currentActivity == null ? null : item.currentActivity.get();
            if (context == null || context.isFinishing()) {
                toasts.poll();
                next();
                return;
            }
    
            WindowManager.LayoutParams params = new WindowManager.LayoutParams();
    
            params.height = WindowManager.LayoutParams.WRAP_CONTENT;
            params.width = WindowManager.LayoutParams.WRAP_CONTENT;
            params.format = PixelFormat.TRANSLUCENT;
            params.windowAnimations = android.R.style.Animation_Toast;
            params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                    | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                    | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
            params.packageName = context.getPackageName();
            // 重新初始化位置
            params.gravity = toast.getGravity();
            params.x = toast.getXOffset();
            params.y = toast.getYOffset();
    
            WindowManager wm = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE));
            wm.addView(toast.getView(), params);
            Message message = Message.obtain();
            message.what = TOAST_HIDE;
            sendMessageDelayed(message, toast.getDuration() == Toast.LENGTH_LONG ?
                    2000 : 1000);
        }
    
        private void hideToast() {
            ToastItem toastItem = toasts.poll();
            if (toastItem != null && toastItem.currentActivity.get() != null && !toastItem.currentActivity.get().isFinishing()) {
                WindowManager wm = (WindowManager) toastItem.currentActivity.get().getSystemService(Context.WINDOW_SERVICE);
                wm.removeViewImmediate(toastItem.toast.getView());
            }
            next();
        }
    
        private void next() {
    //        Log.e("mandy", "now toasts size==" + toasts.size());
            if (!toasts.isEmpty()) {
                Message message = Message.obtain();
                message.what = TOAST_SHOW;
                sendMessageDelayed(message, DELAYED_TIME);
            }
        }
    
        private static class ToastItem {
            private Toast toast;
            //防泄露,理论应该不存在
            private WeakReference<Activity> currentActivity;
        }
    
    }
    

    使用方式

    把以上三个类拷贝到同一个文件中即可使用,常规使用方式如下

           Toast.makeText(context, "hello world!!", Toast.LENGTH_SHORT).show();
    

    只需要改成

           Toast toast=Toast.makeText(context, "hello world!!", Toast.LENGTH_SHORT);
           ToastAdapter.show(toast);
    

    记得在application的oncreate方法中调用下ToastAdapter的init方法完成初始化工作。

    剩下的就是自己慢慢去看源码的实现了,相信有这篇文章的详细解释再去理解代码就更加轻松了。

    坚持写文章不易,如果觉得文章对你有帮助不妨点个赞支持下

    相关文章

      网友评论

        本文标题:系统通知栏关闭,Toast不好使了?

        本文链接:https://www.haomeiwen.com/subject/tqmiprtx.html