登录

去注册

登录

注册

去登录

注册

每日一问 SDK 的问题 怪我咯?

xiaoyang   2019-10-23   收藏

上一道题目,关于 BadTokenException,很多同学指出 Toast 可能会出现这个问题,我们需要修复一下。

我们自己的代码,没问题,可以修复,假设这个 toast 在 SDK里面调用的,我们也没有源码,怎么处理呢?

有什么思路吗?

8

可以用移花接木之法
只是我们的手法与大家熟知的手法不一样,因为现在的场景是在SDK中,无法直接修改它的代码,所以用反射替换掉Handler,也是于事无补的。
除非,你能拿到NotificationManagerService的实例,替换掉它的mHandler,这个Handler的handleMessage方法中有接收一个what为MESSAGE_TIMEOUT的消息,收到此消息后,会取消掉 刚刚已经通知给Toast.TN那边显示的Toast的View 所对应的Token。
没错,也是通过上一个问题中提到的removeWindowToken方法来从Map中移除对应的<Binder, WindowToken>键值对了。
问题就出在这里,先来看一下NotificationManagerService的showNextToastLocked方法:

    void showNextToastLocked() {
        ToastRecord record = mToastQueue.get(0);
        while (record != null) {
            try {
                record.callback.show(record.token);
                scheduleTimeoutLocked(record);
                return;
            } catch (RemoteException e) {
            }
        }
    }

record.callback,就是Toast.TN的实例,TN的这个show方法中,会send一条带有token的message到它的mHandler,mHandler收到消息之后,就会调用大家熟知的那个会报错的方法:handleShow。
现在看回上面的showNextToastLocked方法,可以看到在回调了show方法之后,还调用了一个scheduleTimeoutLocked方法,这个方法会给NotificationManagerService中的mHandler发一个延时消息,延时的时长就是刚刚调用show方法的Toast的实际时长,这条消息的what,就是上面说的MESSAGE_TIMEOUT。
好,再回到刚刚说的那个会报错的handleShow方法,如果这时候,主线程有很多任务,等待处理的时间超出了当前要显示的Toast的时长(这时候对应的token已经被移除了),当调用WindowManager的addView方法准备把这个Toast的View显示出来时,WindowSession的addToDisplay方法就不能根据这个token找到WindowToken了,所以会返回一个ADD_BAD_APP_TOKEN的Flag,也就是"xxx is not valid; is your activity running?"这条描述了。

emmm,那现在会萌生一个想法:替换掉NotificationManagerServiced的mHandler,在收到MESSAGE_TIMEOUT消息之后,不立即remove,而是给主线程post这个remove任务,这样的话,就能保证Toast能在token被remove之前show出来了,也就避免了BadTokenException。
但实际上,你是拿不到NotificationManagerService的实例的,只能拿到它的代理类。所以这个想法,就让它石沉大海吧。


正确的解决方法:
我们说的移花接木,既不是替换掉Toast.TN中的mHandler,也不是替换NotificationManagerService的mHandler,更不是替换掉原生的Toast,而是替换掉Application的WindowManager!
为什么呢?
先来看看那个会报错的handleShow方法:

    public void handleShow(IBinder windowToken) {

        ......

        Context context = mView.getContext().getApplicationContext();
        if (context == null) {
            context = mView.getContext();
        }
        mWM = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        mWM.addView(mView, mParams);

        ......

    }

看到没有?那个WindowManager它是通过context的getSystemService来获取的,而这个context实例,就是Application!
它还把View的Context当作第二选择,以防万一。
不过在日常开发中,我们使用Toast的时候,无非就借助Activity、Fragment、Service的Context来显示。
Fragment的Context也是Activity,所以Toast的View的Context,就只有Activity和Service了。而这两个类的getApplicationContext方法:

    @Override
    public Context getApplicationContext() {
        return (mPackageInfo != null) ?
                mPackageInfo.getApplication() : mMainThread.getApplication();
    }

在非不可预测的特殊情况下,是不会为空的,mPackageInfo和mMainThread这两个类里面,都没有把Application引用置空的代码。

那现在就等于是调用Application的getSystemService方法来获取WindowManager了,我们可以在Application中重写这个方法(必须在manifest里面使用),并且判断版本和对象类型,确定是WindowManager之后,返回一个WindowManger的包装类,就像这样:

    override fun getSystemService(name: String): Any? {
        val service = super.getSystemService(name)

        if (service is WindowManager && Build.VERSION.SDK_INT == 25) {
            return object : WindowManager {
                override fun addView(view: View?, params: ViewGroup.LayoutParams?) {
                    try {
                        service.addView(view, params)
                    } catch (e: WindowManager.BadTokenException) {
                        e.printStackTrace()
                        //这里可以添加一些自定义的逻辑
                    }
                }

                override fun updateViewLayout(view: View?, params: ViewGroup.LayoutParams?) {
                    service.updateViewLayout(view, params)
                }

                override fun removeView(view: View?) {
                    service.removeView(view)
                }

                override fun getDefaultDisplay(): Display = service.defaultDisplay

                override fun getCurrentImeTouchRegion() = service.currentImeTouchRegion

                override fun removeViewImmediate(view: View?) {
                    service.removeViewImmediate(view)
                }

                override fun requestAppKeyboardShortcuts(
                    receiver: WindowManager.KeyboardShortcutsReceiver?,
                    deviceId: Int
                ) {
                    service.requestAppKeyboardShortcuts(receiver, deviceId)
                }
            }
        }
        return service
    }

可以看到,我们把WindowManger的全部方法都重写了一遍,把具体的逻辑都交给了super.getSystemService返回的WindowManager实例,只是我们在调用它的addView方法时,像其他版本那样,外面加了try catch。
这样的话,当发生BadTokenException时,也不会crash了。

这个方法还有个好处就是,其他地方的代码不需要做任何修改,就算SDK里面的方法有使用Toast,发生异常时我们也一样能catch到。

回复
陈小缘 : @残页 

赞~

2019-10-22 回复
残页 : @陈小缘 

明白了~ 另外其实可以用动态代理的方式动态生成一个实现类,只处理addView方法,其他方法可以无缝交给原来的manager来执行

2019-10-22 回复
陈小缘 : @残页 

厂商在修改API时,应该也会充分考虑到这个修改对于APP是否有兼容性问题的,不然的话,吃亏的反而是厂商自己。

2019-10-22 回复
陈小缘 : @残页 

确实会有你说的这种情况出现,不过我们可以做一些检测,以确保本次的调用是来自于Toast.TN.handleShow方法,比如根据调用栈的类名、方法名来判断。 这样的话,只要厂商不手贱删掉WindowM  ...查看更多

2019-10-22 回复
残页 : @陈小缘 

我说一下你就明白了:厂商往WindowManager里添加抽象方法,然后在自己的impl里去实现,这里没有问题;你自己写了一个WindowManager作为代理类,重写了你知道的所有方法并交给原来的来  ...查看更多

2019-10-21 回复
残页 : @陈小缘 

问题是你根本不知道会有这个方法,怎么交给真正的manager实现

2019-10-21 回复
陈小缘 : @party.job 

不是 "交给原来的去实现",是在新实现的方法中调用旧实例的对应方法,这也就是装饰者模式的应用: 调用新的WindowManager的方法,会间接调用原来旧的实例方法,只是加了一个包装而已。  ...查看更多

2019-10-21 回复
party.job : @陈小缘 

你都已经new 了一个新的WindowManager了,怎么交给原来的去实现

2019-10-21 回复
陈小缘 : @残页 

不会,WindowManager是一个接口,我们把里面的方法都交给原来的WindowManager来执行就OK了。

2019-10-21 回复
残页 : @陈小缘 

有个小问题,某些rom会乱改系统的各种Manager,往里面增加各种各样的方法,这些方法我们自己写的Manager肯定是没有实现的,然后会出现各种各样的crash  ...查看更多

2019-10-21 回复
陈小缘 : @陈小缘 

如果只是想处理Toast的操作,其他操作不想加包装的话,可以根据调用栈来判断方法名,可以加上这个条件:Thread.currentThread().stackTrace[3].methodName =  ...查看更多

2019-10-21 回复
0

try一下

回复
0

目前是想到两种方案

  1. 编译期通过类似 AspectJ 的字节码修改框架将需要用到 Toast 的地方全部修改
  2. 运行时hook show,然后拿到mTN进行处理。看了一下Toast源码,hook点比较多,拿到mTN就行

第一种方案比较稳定一些,第二种稍微差点

回复
0

可以用反射,或者把原生的toast都换成自定义view也可以

回复

删除留言

确认删除留言,会导致相关评论丢失?

取消 确定