查看原文
其他

西瓜视频apk瘦身之 Java access 方法删除

风荷举 西瓜技术团队 2020-10-29
背景:什么是access方法

Java语言的封装性要求一个类的私有成员不能被其他类直接访问,然而内部类和其外部类可以直接访问对方的私有成员,这个现象显然违反了Java封装性的要求,因此为了能提供内部类和其外部类直接访问对方的私有成员的能力,又不违反封装性要求,Java编译器在编译过程中自动生成package可见性的静态access$xxx方法,并且在需要访问对方私有成员的地方改为调用对应的access方法,这就是access方法的由来。

备注: 除了因内部类和其外部类互相访问对方私有成员会生成access方法外,还有其他的情况(比如内部类 A 访问其外部类 B 的非同包父类 C 的protected方法,也会生成access方法,该access方法在 B 中),不过内部类和其外部类互相访问对方私有成员而生成access方法的情况最为常见,以西瓜视频为例,这种情况生成的access方法大概占93%,因此下文就来讲解如何在编译时安全删除这些方法。

access方法的影响
  1. dex文件格式对类、方法、字段的数目有限制,超过65535就得分包。因此额外生成的access方法数的增加会导致app对multidex依赖的增强。比如在西瓜视频中未优化前差不多有1万多个access方法,随着app中java代码量的增加,产生的access方法也会增多;

  2. 大量的access方法会使得代码体积增加,apk文件也会变大,在西瓜视频中未优化前access方法增加了几百k的包大小;

  3. access方法调用会有额外开销(比如要分配栈帧),对性能也有一定的影响。

避免产生access方法的途径

一般情况下,我们通过以下方式避免产生access方法:

  1. 开发过程中人为注意,在可能产生access方法的情况下适当调整,比如去掉private,改为package可见性;

  2. 使用ASM在编译时删除生成的access方法。


第一种方法其实也是Google在Android开发者官网上所提到的方法,但是这种方法存在几个弊端:

  1. 需要程序员在开发过程中自己分析是否会产生access方法,并且要处处注意,这样对开发效率稍有影响;

  2. 需要总结对应的规范文档,供新入职的小伙伴参考并遵守;

  3. 将private改为package之后,会导致封装性被破坏,可能会被同包下的其他类误用;

  4. 这种方法对于依赖的library无效(一些原本为jvm平台开发的库,可能更不太注意方法数的问题,里面的access方法可能很多)。


对于这些方法,我们也做了一些优化:

  1. 对于上面的 1. 2. 两个问题,之前西瓜写过一个脚本,自动删除private关键字。不过这种办法不能解决 3. 4. 两个问题,并且还可能引发一个dalvik bug(这个bug下面会提到);

  2. 对于上面第三个问题,我们开发了一个javac的插件,用于提供额外的封装性保证。我们自己提供一个 @Private 的annotation用于替换private关键字,能额外提供private的封装性约束。这种方式有一个问题是IDE依然会有提示,只是编译时禁止,对开发者可能会造成一些困扰。具体效果如下:

不过加上这两个优化之后,依然不能解决第4个问题。


因此我们选择使用ASM在编译时自动删除access方法,主要流程如下:

  1. 扫描所有字节码文件,筛选出所有要处理的access方法(这里处理的是因内部类和其外部类互相访问对方私有成员而产生的access方法,在西瓜视频中,这种access方法大概占总量的93%左右);

  2. 分析access方法体,找出它的真实操作对象(对应的字段,方法,对于字段而言,还要扫描出是读操作还是写操作);

  3. 拓展这些真实要操作对象的访问级别(从private提升到package);

  4. 将所有调用这些access方法的地方修改为对这些真实字段或者方法的直接访问,对于方法,要调整“宿主类”中对该方法的调用指令;

  5. 优化时要避免因为访问级别提升而造成错误的override行为,具体实现策略见下文。

执行完上面这些流程后,javac生成的access方法就被移除了,开发的时候该写private的地方依旧写private以保证封装性,这样鱼与熊掌即可兼得。


备注: 上面的第4步在实现的时候,在处理字段写操作的access方法时要注意一下,以oracle的javac为例,它生成的access方法是有返回值的(这个返回值可以作为赋值语句的值,事实上大多数情况下是用不到的,在用不到的情况下编译器会插入pop指令)。我们在处理这种情况时,最优解当然是直接设置对应的字段值,并且删掉后面的pop指令,但是这样做会比较麻烦:

  1. 我们需要识别原本是否有对应的pop指令(如果使用到了返回值,是没有对应的pop的),免得误删;

  2. 编译器是否会进行指令重排。


考虑到第二个问题比较难处理,因此我们的做法是对于非静态字段,如果是double或者long类型,则通过DUP2_X1指令复制操作数栈栈顶的两个元素并插入到操作数栈栈顶三个元素的下方;否则通过DUP_X1指令复制操作数栈栈顶的一个元素并插入到操作数栈栈顶两个元素的下方。对于静态字段,如果是double或者long类型,则通过DUP2指令复制操作数栈栈顶的两个元素并放入操作数栈栈顶;否则通过DUP指令复制操作数栈栈顶的一个元素并放入操作数栈栈顶。

dalvik bug 影响

上面我们的修改是完全按照Java规范来操作的,并且有 dex/d8 的辅助校验,因此改动应该很稳定。但是在内部开发中,有RD同学发现一个奇怪现象:收藏列表下拉刷新后,列表就会被清空,而且4.x设备上必现,但是5.0以上无法复现。


在查这个问题的时候发现:

  1. 经过proguard之后,清空列表的方法和拉取数据的方法签名变为一致;

  2. 清空列表的方法在子类中,拉取数据的方法在父类中,子类和父类在不同的包下面;

  3. 拉取数据的方法被优化了,从private->package。


从现象看,拉取数据的方法 被 清空列表的方法 override 了,但是根据Java规范,这是不应该发生的,因为父子类在不同的包中,而方法的可见性是package。考虑到只在4.x设备上发生,于是Google了一下,发现是dalvik的bug,在art虚拟机中修复了,具体bug信息见:dalvik bug: package private methods can be overridden from different package。

避免这个问题的方法很简单,我们在编译时构建一棵类继承树:

  1. minSdkVersion >= 21:如果在同一个包下面,存在一个子类,其中有非private,非static方法,其签名和我们要优化的方法签名一致,则跳过该方法,不优化。

  2. minSdkVersion < 21:如果存在一个子类(不考虑是哪个包),其中有非private,非static方法,其签名和我们要优化的方法签名一致,则跳过该方法,不优化。如果存在一个不同包的父类,其中有非static的package可见性的方法,其签名和我们要优化的方法签名一致,则跳过该方法,不优化。


这个就是上面提到的dalvik bug,通过脚本删源代码的private关键字的方试,就很难处理这个问题。

收益及稳定性

西瓜视频从2018年6月份上线删除access方法功能,至今没有发现异常问题。

总共删除93%左右的access方法,共12000个左右,apk体积减小400K左右。

欢迎关注「西瓜技术团队」

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存