本文作者:.西瓜技术团队
#背景:接入方式是什么?
Java语言的封装要求一个类的私有成员不能被其他类直接访问,但是内部类及其外部类可以直接访问对方的私有成员,这显然违背了Java的封装要求。因此,为了给内部类及其外部类提供在不违反封装要求的情况下直接访问对方私有成员的能力,Java编译器在编译过程中自动生成一个具有包可见性的静态访问$xxx方法,并在需要访问对方私有成员的地方调用相应的访问方法,这就是访问方法的由来。
除了内部类及其外部类访问彼此的私有成员之外,在备注:还有其他情况(例如,内部类A访问其外部类B的非包含性父类C)。
受保护的方法,还将生成访问方法,即b中的访问方法。
),但是,最常见的是内部类及其外部类访问彼此的私有成员来生成访问方法。以西瓜视频为例,这种情况产生的访问方法约占93%,所以下面的文章将解释如何在编译过程中安全删除这些方法。
#访问方法的影响。
1.dex文件格式对类、方法和字段的数量有限制,如果超过65535,就会被分包。因此,额外访问方法数量的增加将导致app对multidex依赖的增强。比如西瓜视频,优化前的访问方式差不多有一万多种。随着app中java代码的增加,生成的访问方法数量也会增加。
2.大量的访问方式会增加代码量,apk文件也会变大。在西瓜视频优化之前,访问方法会增加几百K的包大小;
3.访问方法调用会有额外的开销(比如分配堆栈帧),这也会对性能产生一定的影响。
#避免生成访问方法的方法。
通常,我们通过以下方式避免生成访问方法:
1.关注开发过程中的人,在可能出现访问方式的情况下进行适当的调整,比如去掉私有,改为包可见;
2.使用ASM在编译时删除生成的访问方法。
第一种方法其实是谷歌在安卓开发者官网上提到的,但是这种方法有几个缺点:
1.要求程序员分析开发过程中是否会产生访问方法,并处处注意,对开发效率影响不大;
2.需要总结相应的规范文件,供新入职的合伙人参考和遵循;
3.将private改为package后,封装将被销毁,并可能被同一package下的其他类滥用;
4.这种方法对于依赖库是无效的(一些最初为jvm平台开发的库可能不太注意方法的数量,其中可能有很多访问方法)。
对于这些方法,我们也做了一些优化:
1.对于上面的1。2.两个问题,西瓜之前写了一个脚本,自动删除私有关键字。然而,这种方法不能解决3中的两个问题。4.并且还可能引起dalvik bug(这个bug将在下面提到);
2.对于上面的第三个问题,我们开发了一个javac插件来提供额外的封装保证。我们提供了@private的注释来替换Private关键字,这样可以额外提供Private的封装约束。这种方法的一个问题是IDE仍然会提示,但是在编译时被禁止,这可能会给开发人员带来一些麻烦。具体效果如下:
然而,经过这两次优化,第四个问题仍然无法解决。
因此,我们选择在编译过程中使用ASM自动删除访问方法。主要过程如下:
1.扫描所有字节码文件,筛选出所有需要处理的访问方法(这里,由内部类和它们的外部类生成的访问彼此私有成员的访问方法被处理,并且在水中
2.分析访问方法体,找出其真正的操作对象(对应字段、方法,对于字段,扫描是读操作还是写操作);
3.扩展这些要操作的真实对象的访问级别(从私有到包);
4.修改所有调用这些访问方法的地方,以直接访问这些真实的字段或方法。对于方法,
要调整“宿主类”中对该方法的调用指令;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左右。
> 原文来自: 西瓜技术团队。
> 关注TechTree,关注互联网一线大厂技术干货。