Toast通知栏权限填坑指南

本文章已授权鸿洋微信公众号转载:Toast不显示了?

吐司弹不出来完美的解决方案:Toaster,接下来让我们来一步步开始分析这个问题是如何出现,解决的过程,以及解决的方法

首先我们先看一下大厂 APP 的弹吐司

疑问

  • 连吐司弹不出来的手机是个什么梗?

  • 是少部分机型问题还是大多数机型的问题?

  • 为什么关闭了通知栏权限弹不出来?

  • 为什么有的机型可以弹有的却不行?

解答

  • 自从我的 Toaster 框架发布了之后,被问最多的一个问题,你的Toast框架关闭通知栏权限还能弹出来吗?我心想这 Toast 跟通知栏扯不上啥关系吧,但是既然有人这样问了,也只能半信半疑了,于是我便拿了我的小米8还有红米Note5进行了测试,发现并没有该问题,于是我统一回复,这个是兼容问题,极少数机型才可能出现的问题,为保证框架稳定性,不给予兼容

  • 于是还有人陆陆续续给我反馈了这个问题,反馈的人都是用华为机型出现的问题,我便开始重视起来,刚好有同事用的是华为 P9,我跟他借了一下手机,一借不要紧,一借一下午。估计同事的内心是崩溃的,因为这个问题被 100% 复现了,真的关闭通知栏权限后吐司弹不出来了

  • 于是我翻遍了 Toast 的源码,吐司底层是 WindowManager 实现的,但是这跟通知栏权限有什么关系呢?就算有关系也是和 NotificationManager 有关系,到底和通知栏权限扯上啥关系了呢?经过查看系统源码发现,吐司的创建是使用到了 WindowManager 去创建,但是显示吐司的时候使用了 INotificationManager ,看类名就知道肯定和 NotificationManager 有联系,这就是为什么关闭了通知栏权限后导致了吐司显示不出来的问题

  • 现在经过测试,大部分小米机型不会因为通知栏权限被关闭而原生的Toast弹不出来,而华为荣耀,三星等都会出现通知栏权限被关闭后导致原生Toast显示不出来,这可能是小米手机对这个吐司的显示做了特殊处理,这个问题在Github上排名前几的Toast框架都会出现,并且一些大厂的APP(除QQ微信和美团外)也会出现该问题

吐司弹不出来的后果

Toast是我们日常开发中最常用的类,如果我们的APP在通知栏推送的消息比较多,用户就会把我们的通知栏权限屏蔽了,但是这个会引起一个连带反应,就是应用中所有使用到 Toast 的地方都会显示不出来,彻底成为一个哑巴应用,例如以下情景:

  • 账户密码输入错误,吐司弹不出来

  • 用户网络支付失败,吐司弹不出来

  • 网络请求错误,吐司弹不出来

  • 双击退出应用,吐司弹不出来

  • 等等情况,只要用到原生 Toast 都显示不出来

其实这是一个系统的Bug,谷歌为了让应用的 Toast 能够显示在其他应用上面,所以使用了通知栏相关的 API,但是这个 API 随着用户屏蔽通知栏而变得不可用,系统错误地认为你没有通知栏权限,从而间接导致 Toast 有 show 请求时被系统所拦截

Toast 源码解析

首先看一下 Toast 的构成

再看一下 Toast 内部的 API

里面还有一个内部类,再看一下内部的 API

从这里我们不难推断,Toast 只是一个外观类,最终实现还是由其内部类来实现,由于这个内部类太长,这里放一下这个内部类的源码,简单过一遍就好

private static class TN extends ITransientNotification.Stub {
    private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();

    private static final int SHOW = 0;
    private static final int HIDE = 1;
    private static final int CANCEL = 2;
    final Handler mHandler;

    int mGravity;
    int mX, mY;
    float mHorizontalMargin;
    float mVerticalMargin;


    View mView;
    View mNextView;
    int mDuration;

    WindowManager mWM;

    String mPackageName;

    static final long SHORT_DURATION_TIMEOUT = 4000;
    static final long LONG_DURATION_TIMEOUT = 7000;

    TN(String packageName, @Nullable Looper looper) {
        // 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;

        mPackageName = packageName;

        if (looper == null) {
            // Use Looper.myLooper() if looper is not specified.
            looper = Looper.myLooper();
            if (looper == null) {
                throw new RuntimeException(
                        "Can't toast on a thread that has not called Looper.prepare()");
            }
        }
        mHandler = new Handler(looper, null) {
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what) {
                    case SHOW: {
                        IBinder token = (IBinder) msg.obj;
                        handleShow(token);
                        break;
                    }
                    case HIDE: {
                        handleHide();
                        // Don't do this in handleHide() because it is also invoked by
                        // handleShow()
                        mNextView = null;
                        break;
                    }
                    case CANCEL: {
                        handleHide();
                        // Don't do this in handleHide() because it is also invoked by
                        // handleShow()
                        mNextView = null;
                        try {
                            getService().cancelToast(mPackageName, TN.this);
                        } catch (RemoteException e) {
                        }
                        break;
                    }
                }
            }
        };
    }

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

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

    public void cancel() {
        if (localLOGV) Log.v(TAG, "CANCEL: " + this);
        mHandler.obtainMessage(CANCEL).sendToTarget();
    }

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

    private void trySendAccessibilityEvent() {
        AccessibilityManager accessibilityManager =
                AccessibilityManager.getInstance(mView.getContext());
        if (!accessibilityManager.isEnabled()) {
            return;
        }
        // treat toasts as notifications since they are used to
        // announce a transient piece of information to the user
        AccessibilityEvent event = AccessibilityEvent.obtain(
                AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
        event.setClassName(getClass().getName());
        event.setPackageName(mView.getContext().getPackageName());
        mView.dispatchPopulateAccessibilityEvent(event);
        accessibilityManager.sendAccessibilityEvent(event);
    }

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

            mView = null;
        }
    }
}

只需要稍微简单看一下就看懂,Toast 底层就是用这个内部类去实现,请记住,这个内部类叫做 TN,字段名为 mTN,接下来先让我们看一下 Toast 中 cancel 方法的源码

cancel最终还是调用了内部类 TN 中的同名方法,接下来再看 Toast 中 show 方法的源码

仔细观察的同学就会发现了,这个 show 的方法可不是像 cancel 一样只调用了 TN 内部类中的同名方法,还调用了 INotificationManager 这个 API,其实不难发现,这个 INotificationManager 是系统的 AIDL,不信的话我们再看一下这个 INotificationManager

我相信学过 AIDL 的同学会明白,这里不再讲 AIDL 相关知识,如需了解请自行百度

重点讲一下 INotificationManager,这个 AIDL 由系统实现的一个类,不同系统这个 AIDL 所对应的类也不相同,这就充分说明了为什么导致小米的机型关闭了通知栏权限还可以显示,而华为就不行的原因,具体原因请再看源码

因为这里传了应用的包名给系统通知栏,如果这个包名对应的APP的通知栏权限被关闭了,吐司自然也就弹不出来了

那么该如何着手解决这个问题

先思考一个问题,Toast 显示是使用了 INotificationManager,和通知栏有关系,而Toast 的创建是使用了 WindowManager,和通知栏没有关系,那么我们可不可以通过 WindowManager 的方式来创建类似于 Toast 一样的东西呢,答案也是可以的,只不过在过程中会遇到非常棘手的问题,接下来让我们解决这些遇到的问题

首先创建一个 WindowManager 需要 一个 View 参数和 WindowManager.LayoutParams 参数,这里说一下 WindowManager.LayoutParams 的创建,直接复制 Toast 部分代码

WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
// 找不到 com.android.internal.R.style.Animation_Toast
// params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.windowAnimations = -1;
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;

然后使用 WindowManager 调用 addView 显示,然后报了错

android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?

其原因在于我们使用了 type,为什么不能加 TYPE_TOAST,因为通知权限在关闭后设置显示的类型为Toast会报错,所以这里我们把这句代码注释掉,然后就可以显示出来了

params.type = WindowManager.LayoutParams.TYPE_TOAST;

WindowManager 没有吐司的显示效果

其原因在于我们复制了 Toast 的部分代码,而其中的动画代码引用了系统 R 文件中资源,而我无法直接在 Java 代码中引用

params.windowAnimations = com.android.internal.R.style.Animation_Toast;

Java代码不能引用这个Style不代表XML就不行,在这里创建一个 Style 并且继承原生 Toast 样式,这里我们可以自定义,也可以直接使用系统的,为了和系统的样式统一,这里就直接使用系统的

<style name="ToastAnimation" parent="@android:style/Animation.Toast">
    <!--<item name="android:windowEnterAnimation">@anim/toast_enter</item>-->
    <!--<item name="android:windowExitAnimation">@anim/toast_exit</item>-->
</style>

然后重新指定 params.windowAnimations 即可解决该问题

params.windowAnimations = R.style.ToastAnimation;

WindowManager 没有自动消失的问题

首先 WindowManager 并不能像 Toast 显示后自动消失,如果要像 Toast 一样自动消失很容易,在 WindowManager 显示后发送一个定时关闭的任务,那么问题来了,这个显示的时间如何定义?系统 Toast 显示的时间是什么样子?首先我们需要先看一下 Toast 给我们提供的两个常量值

从这张图上我们并没有发现什么有价值的东西,我们继续往下找,看看是什么地方引用了这些常量

继续通过查看源码得知

但是通过测试,短吐司显示的时长为2-3秒,而长吐司显示的时长是3-4秒,所以这两个值并不是吐司显示时长的毫秒数,那么我们该如何得出正确的毫秒数呢?这个问题就留给大家去思考,这里不做解答

只能使用当前 Activity 创建 WindowManager 的缺陷

发现一个问题,Activity 和 Application 同样是 Context 的子类,如果使用 Activity 获取的 WindowManager 对象可以创建出来,但是如果使用 Application 获取的 WindowManager 对象却报了错

android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application

报错已经说得很清楚了,创建 WindowManager 不能使用 Application 对象去创建,也就是说只能通过 Activity 对象去创建 WindowManager

那么问题来了,每次弹这种 “Toast” 需要当前 Activity 对象,这个问题对于常年使用框架的同学是致命的

这里以我做的框架 Toaster 为例子,显示一个吐司是这样子调用的

Toaster.show("我是吐司");

如果要解决在关闭通知栏权限后吐司还能再弹出来的问题,就需要改成

Toaster.show(MainActivity.this, "我是吐司");

先说一下这个问题带来的影响吧,我是框架的作者,对于我来说,只需要在 Toaster 中 show 方法多添加一个 Activity 参数即可,但是对于使用框架的人,在更新完框架后,整个项目所有使用到这个Toaster.show()方法都会报错,需要多传入一个Activity 参数,相信他们的内心几乎是崩溃的,那么有没有一种好的办法解决这个问题,答案当然是有了,可以用一个冷门的 API

Application.registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks callback);

这个 API 是在 安卓 4.0 之后才有的,而现在大多数设备已经在 安卓 5.0 及以上,所以这个 API 还是有前途的,接下看一下 ActivityLifecycleCallbacks 这个接口有什么方法吧

public interface ActivityLifecycleCallbacks {
    void onActivityCreated(Activity activity, Bundle savedInstanceState);
    void onActivityStarted(Activity activity);
    void onActivityResumed(Activity activity);
    void onActivityPaused(Activity activity);
    void onActivityStopped(Activity activity);
    void onActivitySaveInstanceState(Activity activity, Bundle outState);
    void onActivityDestroyed(Activity activity);
}

看到这里,相信各位已经知道真相了,这个方法用于监听应用中 Activity 中的生命周期方法

那么我们就可以通过这个 API 来获取当前和用户交互的 Activity 对象,从而完成让当前 Activity 对象去创建 WindowManager

使用 WindowManager 实现 Toast 出现局限性的问题

当然用 WindowManager 创建的 View 必然也会受 Activity 的限制,因为就只能显示这个 Activity 上,如果在其他界面上则会显示不了,而系统原生的 Toast 则可以出现别的界面上,那有没有什么解决办法呢?

WindowManager 在没有悬浮窗权限的时候就只能显示依附于调用的 Activity,当有授予了悬浮窗权限之后,可以通过改变type参数来更改 WindowManager 显示范围,可以让这个 WindowManager 显示在其他界面之上,这样 Toast 就不会随着 Activity 的不可见而变得不可见

// 判断是否为 Android 6.0 及以上系统并且有悬浮窗权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(mToast.getView().getContext())) {
    // 解决使用 WindowManager 创建的 Toast 只能显示在当前 Activity 的问题
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
    }else {
        params.type = WindowManager.LayoutParams.TYPE_PHONE;
    }
}

如何在原生 Toast 和 WindowManager 中取舍

这样我们比对一组数据:

类型 显示范围 需要参数 兼容性 效率 通知栏权限 悬浮窗权限
原生 Toast 所有界面 Context子类 一般 需要 不需要
WindowManager 当前Activity Activity子类 一般 不需要 不需要

经过对比,原生的 Toast 的优势还是要大于 WindowManager 的,所以如果在有在通知栏权限的前提下,建议使用原生的 Toast,我们可以通过判断通知栏权限是否被关闭,来判断是来显示原生 Toast 还是 WindowManager,方法代码如下:

/**
 * 检查通知栏权限有没有开启
 */
public static boolean isNotificationEnabled(Context context){
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).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 pkg = 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 (Integer) checkOpNoThrowMethod.invoke(appOps, value, uid, pkg) == 0;
        } catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException | IllegalAccessException | RuntimeException | ClassNotFoundException ignored) {
            return true;
        }
    } else {
        return true;
    }
}

详细的源码地址请戳这里

Android 技术讨论 Q 群:10047167

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容