我闻到了 Android AppCompat 代码的坏味道!

2020-02-20 13:26:16 浏览数 (1)

今天我们再来给大家讲个好玩的东西。言简意赅的,绝对不废话。。。。才怪。

喂,你好,你有一个 Crash 放楼下超市啦

话说,我最近写了一个小 Demo,之前开发调试一直都是在一台 6.0.1 的手机上,顺风顺水的。

然鹅,有那么一天我那个 6.0.1 的手机出差了,我只好遍历了我家抽屉找到了尘封已久的 Nexus 5,经典的 Android 4.4.2 Api 19,有没有很怀念 —— 旋即我就不这么想了,因为大家都知道 Google 从 Api 19 到 21 对 Android 做了什么丧尽天良的事儿,我胡乱写的那些代码 N5 大概也许都不认识了吧,时过境迁,岁月不饶人啊。

于是最可怕的事儿发生了,结果你们自己看:

什么!

ActivityCompatApi23$SharedElementCallbackImpl 不存在?

Android Support 库,你搞笑呢吧?

我当时心里咯噔了一下,这要存在了就有鬼了好吧,我这可是 Api 19 的机器呀。不慌,我们看看这厮的源码再作定夺:

代码语言:javascript复制
@RequiresApi(23)
@TargetApi(23)
class ActivityCompatApi23 {
    ...
    private static class SharedElementCallbackImpl extends SharedElementCallback {
        ...
    }
}

看这架势,我这代码也不应该会触及这块儿对吧,如果大家熟悉这个套路,就应该想到运行时如果真的用到 ActivityCompatN,会根据当前手机的版本去选择合适的类,例如 Api 19 的话应该直接使用 ActivityCompat,你 ActivityCompatApi23 又出来捣什么乱?

想必也是 Android Support 框架的某些代码写得不是怎么漂亮,不然怎么会有这等事情!

我们看到前面截图中,错误显示是在某一处的代码通过反射去获取 ActivityCompatApi23 的内部类的时候,出现的错误,不难想到,由于 Api 19 没有 SharedElementCallback 这个接口,于是 SharedElementCallbackImpl 的父接口就无法解析,于是导致了上面的错误出现。

那么现在的问题就是,哪个欠揍的熊孩子跟一个根本不可能用到的类较劲呢?

好吧,我发现出错的调用是在一个 AppCompatActivity 的子类里面,我写了这么一段代码:

代码语言:javascript复制
this::class.allSupertypes.flatMap { it.arguments }.forEach {
        ...
        }

这是个什么意思呢?就是一下子拿到当前类的所有父类和父接口,换句话说,这里面应该至少有 AppCompatActivityActivity 等等这些类型。没毛病啊,我不就是拿个父类们么,至于给我甩一脸 Error 么?

不过这倒不难猜到,一定是 AppCompatActivity 的父类里面有鬼。果不其然,我们发现它的父类的代码竟然是这样的:

代码语言:javascript复制
public class FragmentActivity extends BaseFragmentActivityJB implements
        ActivityCompat.OnRequestPermissionsResultCallback,
        ActivityCompatApi23.RequestPermissionsRequestCodeValidator {
    ...        
}

这。。简直就是不要 face 啊, ActivityCompatApi23 为啥会以这种方式出场?这时候,想静静估计也是没什么用的,我就问你们,要怎样安抚一颗受伤的心?

也就是说,我本来只是想要拿到 AppCompatActivity 的某一个子类的全部父类和接口,结果把 ActivityCompatApi23 这厮招魂似的招了出来。

谁是那个寄 Crash 的?

好吧,出来就出来了,我也没办法把你送回去,那我们来分析一下,为什么我只是想要一个父类,结果却牵扯出来父类里面的一个静态内部类的父类找不到的问题?

显然,由于我对 AppCompatActivity 父类的遍历,导致了这些父类的加载。等等,你们觉得这句话对吗?其实是有问题的,因为这段代码本身就是运行在 AppCompatActivity 的子类中的,也就是说这时候 AppCompatActivity 肯定早就完成了类加载,相应的,在它加载链接的过程中,它的父类们也通通会被加载到虚拟机中,那么我所谓的便利导致了这些父类的加载实际上是不对的。这很容易理解。

那么也就是说我对它父类的遍历并没有触发类加载?对呀,正常来说就应该是这样,如果我不用 Kotlin 反射的 allSuperTypes ,而是换用 Java 反射来遍历父类和父接口,其实是不会报错的,大家再仔细看看 FragmentActivity 的声明,人家只是引用了 ActivityCompatApi23$RequestPermissionsRequestCodeValidator 而不是真的引用了 ActivityCompatApi23,从类加载的角度讲,加载前者并不会直接引发后者的加载(除非前者引用了后者),原因也很简单,前者是一个接口,你可以把它当做一个静态内部类,从语言层面看,它对外部类通常没有直接的依赖。

也就是说, ActivityCompatApi23 本不应该被加载进来的。

可是我现在调用 Kotlin 的 allSuperType 来获取所有父类就会触发它的加载,这又是怎么回事?

原来 Kotlin 在通过反射查询这些父类的时候,会运行到这里:

也就是说要为这个接口生成一个 classId,这看上去似乎是一个比较重要的东西,姑且它就如同 Java 反射中的类全名一样,不去质疑它在的必要性了。

在 Api 19 当中,我们看到这里明确地去访问了外部类去创建 classId,这一点导致了外部类的加载。

当然,如果你有兴趣,你也可以换个版本的代码运行下,例如在 Java 8 下面运行(如果想要这样做,你需要复制很多 jar 到你的 Java 工程下),报错就会是在 simpleName.isEmpty() 这里了。显然,作为一个静态内部类,它的 enclosingMethodenclosingConstructor 都为 null,于是判断 simpleName 是否为空是一定会执行到的。

为什么 Api 19 获取 simpleName 不会报错,而等到后面那句才会报错呢?因为它的 simpleName 的实现与后面新版本不太一样:

而我们看下后面新版本的写法:

而这个 getSimpleBinaryName 第一句就是获取外围类。。

代码语言:javascript复制
private String getSimpleBinaryName() {
   Class<?> enclosingClass = getEnclosingClass();
   ...
}

不过,无论如何,这里的外部类都是要加载的了。

总结下,单纯对静态内部类、接口的加载不会直接触发外部类的加载,而 Kotlin 的反射中为内部类、接口创建 classId 的行为又不可避免的要触发外部类的加载。

再吐槽两句

关于这个问题我其实还想说的是:

  1. 我为了偷懒直接调用了一个获取所有父类和接口的 API,实际上我只是递归地遍历父类,在 FragmentActivity 之前遍历就会结束,也就是说根本不会触发对 ActivityCompatApi23 的类加载,也就不会导致前面的问题了。这个故事告诉我们,千万别偷懒!
  2. Android Support 库里面 FragmentActivity 的父接口里面居然有对涉及到兼容 Api 的显式引用,这个写法简直了,臭臭的。。
  3. 我觉得这事儿不怪 Kotlin !

0 人点赞