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

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;
            }
            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方法完成初始化工作。

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

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

推荐阅读更多精彩内容