热修复原理学习(3)编译器与语言特性的影响

========================================================================

有时候我们会发现,在修改外部类某个方法逻辑为访问内部类的某个方法时,最后打出来的补丁包竟然提示新增了一个方法,这真的很匪夷所思,所以有必要了解内部类在编译期间是怎么工作的,首先我们要知道内部类在编译期会被编译为跟外部类一样的顶级类。

[](()1.1 静态内部类/非静态内部类的区别


这一点大家都很熟悉,非静态内部类持有外部类的引用,静态内部类不持有外部类的引用。

所以在Android性能优化中建议Handle的实现尽量使用静态内部类,防止外部Activity类不能被回收导致可能的OOM。

我们反编译为smali比较两者的不同点:

//静态内部类

direct methods

.method constructor ()V

return-void

.end method

//非静态内部类,编译器会自动合成this$0域表示的就是外部类的引用

.field final synthetic this$0:Lcom/rikkatheworld/demo/DexFixDemo;

#direct methods

.method constructor (Lcom/rikkatheworld/demo/DexFixDemo;)Via

.locals 1

.param p1,“this$0” #Lcom/rikkatheworld/demo/DexFixDemo;

iput-object p1, p0, Lcom/rikkatheworld/demo/DexFixDemo$A;->this$0:Lcom/rikkatheworld/demo/DexFixDemo;

return-void;

.end

[](()1.2 内部类和外部类互相访问


既然内部类实际上跟外部类一样都是顶级类,既然都是顶级类,那是不是意味着对方私有的 method/field是无法被访问到的,事实上外部类为了访问内部类私有的域/方法,编译期间自动会为内部类生成 access$数字编号相关方法

public class BaseBug {

public void test(Context context) {

InnerClass innerClass = new InnerClass(“old apk”);

Toast.makeText(context.getApplicationContext(), innerClass.s, Toast.LENGTH_SHORT).show();

}

class InnerClass {

private String s;

private InnerClass(String s) {

this.s = s;

}

}

}

此时外部类BaseBug为了能够访问内部类InnerClass的私有域s,编译器会自动为 InnerClass这个内部类合成 access$100方法,这个方法的实现简单返回私有域s的值。同样的如果此时匿名内部类需要访问外部类的私有属性/方法,那么外部类也会自动生成access$**相关方法提供给内部类使用。

[](()1.3 热部署解决方案


上面说的东西对热修复来说,就产生了一种场景:

打补丁前的test方法没访问inner.s,打补丁之后的 test方法访问了inner.s,那么补丁工具最后检测到了新增的 access$100方法。

那么我们只要防止生成 access$**相关方法,就能走热部署方案,也就是底层替换方式热修复。所以只要满足一下条件,就能避免编译器自动生成 access$**的相关方法:

  • 一个外部类如果有内部类,把所有 methos/field的私有访问权限改成 protectedpublic或者默认访问权限

  • 同时把内部类的所有 method/field 的私有访问权限改成protected或者public或者默认访问权限

[](()2. 匿名内部类编译

============================================================================

匿名内部类其实也是内部类,所以自然也有 1.1节所说明情况的影响,但是发现在新增一个匿名类,同时规避了1.1节的情况,但是最后仍然提示了method的新增,所以接下来了解匿名内部类跟非匿名内部类的区别,并且有怎么样的特殊性。

[](()2.1 匿名内部类编译命名规则


匿名内部类顾名思义是没有名字的。匿名内部类的名称格式一般是 外部类$数字编号,后面是的数字编号,是编译器根据该匿名内部类在把外部类中出现的先后关系,依次累加命名的:

public class DexFixDemo {

public static void test(Context context) {

/* new DialogInterface.OnClickListener(){

@Override

public void onClick(DialogInterface dialog, int which) {

Log.d(“Rikka”,“OnClickListener”);

}

}; */

new Thread(“thread-1”) {

@Override

public void run() {

Log.d(“Rikka”, “thread-1 thread”);

}

}.start();

}

}

修复后的APK新增 DialogInterface.OnClickListener这么一个内部类,但是最后补丁工具发现新增了 onClick方法,因为打补丁前只有一个 Thread匿名内部类,此时该类的名称是 DexFixDemo$1,然后打补丁后再test方法中新增了 DialogInterface.OnClickListener的匿名内部类。此时 DialogInterface.OnClickListener匿名内部类的名称是 DexFixDemo$1,Thread匿名内部类名称是 DexFixDemo$2,所以前后DexFixDemo$1类进行对比差异,这个时候已经完全乱套了。

同样的道理,减少一个匿名内部类也存在相同的情况。

[](()2.2 热部署解决方案


新增或减少匿名内部类,实际上对于热部署来说都是无解的,因为补丁工具拿到的是已经编译后的 .class文件,所以根本没法去区分是DexFixDemo$1或者是 DexFixDemo$2类。所以在这种情况下,如果有补丁热部署的需求,应该极力避免插入一个新的匿名内部类。当然如果 匿名内部类是插入到外部类的末尾,那么是允许的。

[](()3 有趣的域编译

=========================================================================

[](()3.1 静态field,非静态field编译


实际上在热部署中除了不支持 method/field的新增,同时也不支持 的修复,这个方法会在 DVM中类加载的时候进行类初始化时调用。

在Java源码中本身并没有 clinit这个方法,这个方法是Android编译器自动合成的。通过测试发现,静态field的初始化和静态代码块实际上就会被编译器编译在这个方法中,所以我们有必要去了解一下 field/代码块 是到底时怎么编译的。

来看个简单的实例。

public class DexFixDemo {

{

i = 2;

}

private int i = 1;

private static int j = 1;

static {

j = 2;

}

}

反编译为smali看下:

.method static constructor ()V //类初始化方法

const/4 v0, 0x1

sput v0, Lcom/rikkatheworld/hotfix;->j:I //也就是j=1

const/4 v0, 0x2

sput v0, Lcom/rikkatheworld/hotfix;->j:I //也就是j=2

return-void

.end method

.method public constructor ()V //构造方法

invoke-direct {p0}, Ljava/lang/Object;->()V //首先调用父类的默认构造函数

const/4 v0, 0x2

iput v0, p0, Locom/rikkatheworld/hotfix;->i:I //就是i=2

const/4 v0, 0x1

iput v0, p0, Lcom/rikkatheworld/hotfix;->i:I //就是i=1

return-void

.end method

[](()3.2 静态field初始化,静态代码块


上面的示例中,能够很明显的看到静态field初始化和静态代码块被编译器翻译在了 中。

静态代码块和静态域初始化在clinit()中的先后关系就是两者出现在源码中的先后关系。

所以上述例子中,最后的j的值为2。前面说过,类加载进行类初始化的时候,会去调用 clinit(),一个类仅加载一次。以下三种情况都会尝试去加载一个类:

  • 创建一个类的对象(new-instance指令)

  • 调用类的静态方法(invoke-static指令)

  • 获取类的静态域的值(sget指令)

首先判断这个类有没有被加载过,如果没有被加载过,执行 dvmResolveClass->dvmLinkClass->dvmInitClass的流程,类的初始化时在dvmInitClass中。dvmInitClass这个函数首先会尝试会对父类进行初始化,然后调用本类的 clinit方法,所以此时 静态field得到初始化并且静态代码块得到执行。

[](()3.3 非静态field初始化,非静态代码块


上面的示例中,能够明显的看到非静态 field初始化和非静态代码块被编译翻译在 默认无参构造函数中。非静态field和非静态代码块在init方法中的先后顺序也跟两者在源码中出现的顺序一致,所以上述示例中最后 i == 1。

实际上如果存在有参构造函数,那么每个有参构造函数都会执行一个非静态域的初始化和非静态代码块。

构造函数都会被Android编译器自动翻译成方法

前面介绍过 clinit方法在类加载初始化的时候被调用,那么构造函数方法肯定是对类对象进行初始化时候被调用的,简单来说创建一个对象就会对这个对象进行初始化,并调用这个对象相应的构造函数,看下这行代码 String s = new String("test")编译之后的样子。

new-instance v0, Ljava/lang/String;

invoke-direct {v0}, Ljava/lang/String;->()V

首先执行 new-instance指令,主要为对象分配堆内存空间,同时如果类之前没有被加载过,尝试加载类。然后执行 invoke-direct指令调用类的 init构造函数方法执行对象的初始化。

[](()3.4 热部署解决方案


由于不支持 方法的热部署,所以任何静态field初始化和静态代码块的变更都会被编译到clinit方法中,导致最后热部署失败,只能冷启动生效。如上所见,非静态field和非静态代码块的变更被编译到 构造函数中,热部署模式下只是视为一个普通方法的变更,此时对热部署是没有影响的。

[](()4 final static域编译

==================================================================================

final static域首先是一个静态域,所以我们自然会认为其会编译到 clinit方法中,所以在自然热部署下也是不能变更,但是测试发现,final static修饰的基本类型或者 String常量类型,匪夷所思的竟然没有被编译到 clinit方法中去,见以下分析。

[](()4.1 final static域编译规则


final static 即 静态常量域,看下 final static域被编译后的样子:

public class DexFixDemo {

static Temp t1 = new Temp();

final static Temp t2 = new Temp();

final static String s1 = new String(“heihei”);

final static String s2 = “haha”;

static int i1 = 1;

final static int i2 = 2;

}

看下反编译得到的smali文件:

static fields

.field static i1:I = 0x0

.field static final i2:I = 0x2

.field static final s1:Ljava/lang/String;

.field static final s2:Ljava/lang/String; = “haha”

.field static t1:Lcom/rikkatheworld/hotfix/Temp;

.field static final t2:Lcom/rikkatheworld/hotfix/Temp;

direct methods

.method static constructor ()V

.registers 2

.prologue

.line 8

new-instance v0, Lcom/rikkatheworld/hotfix/Temp;

invoke-direct {v0}, Lcom/rikkatheworld/hotfix/Temp;->()V //调用t1的构造方法

sput-object v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->t1:Lcom/rikkatheworld/hotfix/Temp;

.line 9

new-instance v0, Lcom/rikkatheworld/hotfix/Temp;

invoke-direct {v0}, Lcom/rikkatheworld/hotfix/Temp;->()V //调用t2的构造方法

sput-object v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->t2:Lcom/rikkatheworld/hotfix/Temp;

.line 11

new-instance v0, Ljava/lang/String;

const-string v1, “heihei”

invoke-direct {v0, v1}, Ljava/lang/String;->(Ljava/lang/String;)V //调用s1构造 “heihei”

sput-object v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->s1:Ljava/lang/String;

.line 14

const/4 v0, 0x1

sput v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->i1:I //初始化 i1 = 1

return-void

.end method

我们发现 在 clinit中final static int i2 = 2final static String s2 = "haha"这两个静态域竟然没有被初始化,而其他的非 final静态域均在clinit函数中得到初始化。

这里注意下 “haha”new String("heihei")的区别,前者是字符串常量,后者是引用类型。那这两个final static域(i2和s2)究竟在何处会初始化?

事实上,类加载初始化 dvmInitClass在执行clinit方法之前,首先会执行 ,这个方法的作用主要就是给 static域赋予默认值。

如果是引用类型,那么默认初始值为NULL。上述代码示例中,那块区域有4个默认初始值,分别是 t1==NULL,t2==NULL,s1==NULL,s2=="haha",i1==0,i2==2,即这里:

在这里插入图片描述

t1、t2、s2、i1均在 这里完成初始化,然后在 clinit中赋值。而i2、s2在 initSFields得到默认值就是程序中设置的值了。

现在我们知道了 static和 final static修饰field的区别了,简单来说:

  • final static修饰的原始类型和String类型域(非引用类型),并不会编译在 clinit方法中,而是在类初始化执行 initSFiedls()方法时得到了初始化赋值

  • final static修饰的引用类型,初始化仍然在clinit方法中。

[](()4.2 final static域优化原理


另外一方面,我们经常会看到Android性能优化相关文档中介绍过,如果一个 field是常亮,那么推荐尽量使用 static final作为修饰符。很明显这句话不太对,得到优化的仅仅是final static原始类型和 String类型域(非引用类型),如果是引用类型,实际上不会得到任何优化的

还是接着上面的示例,Temp直接引用 DexFixDemo的static变量:

class Temp {

public static void test(){

int i1 = DexFixDemo.i1;

int i2 = DexFixDemo.i2;

Temp t1 = DexFixDemo.t1;

Temp t2 = DexFixDemo.t2;

String s1 = DexFixDemo.s1;

String s2 = DexFixDemo.s2;

}

}

看下反编译后的smali文件:

.method public static test()V

sget v0, Lcom/rikkatheworld/hotfix/DexFixDemo;->i1:I // 通过sget获取到DexFixDemo中的i1并赋值给 v0

.local v0, “i1”:I //将v0赋值给 i1

const/4 v1, 0x2 //使用 const/4指令,将 0x2赋值给v1

.local v1, “i2”:I //将v1 赋值给 i2

sget-object v4, Lcom/rikkatheworld/hotfix/DexFixDemo;->t1:Lcom/rikkatheworld/hotfix/Temp;

.local v4, “t1”:Lcom/rikkatheworld/hotfix/Temp;

sget-object v5, Lcom/rikkatheworld/hotfix/DexFixDemo;->t2:Lcom/rikkatheworld/hotfix/Temp;

.local v5, “t2”:Lcom/rikkatheworld/hotfix/Temp;

sget-object v2, Lcom/rikkatheworld/hotfix/DexFixDemo;->s1:Ljava/lang/String;

.local v2, “s1”:Ljava/lang/String;

const-string v3, “haha” //使用 const-string指令获取 final static String类型,速度要比sget好一些

.local v3, “s2”:Ljava/lang/String;

return-void

.end method

首先看下 Temp怎么获取 DexFixDemo.i2(final static域),直接通过 const/4 指令:

const/4 vA, #+b //前一个字节是opcode,后一个字节前4位是寄存器v1,后4位就是立即数的值 “0x02”

HANDLE_OPCODE(OP_CONST_4 /vA, #+B/) {

s4 tmpl;

vdst = INST_A(inst);

tmp = (s4) (INST_B(inst) << 28) >>28;

SET_REGISTER(vdst, tmp);

}

FINISH(1);

OP_END;

const/4 指令的执行过程很简单,操作数在 dex文件中的位置就是在 opcode后一个字节。

怎么获取 DexFixDemo.i1(非final域),就是通过sget指令。

sget vAA, field@BBBB /* 前一个字节是opcode,后一个字节是寄存器v0,后两个字节是DexFixDemo.i1 这个field在dex文件结构中field在dex文件结构中 field区的索引值 */

HANDLE_OPCODE(OP_CONST_4 /vAA, #field@BBBB/) {

StaticField* sfield;

vdst = INST_AA(inst);

ref = FETCH(1);

sfield = (StaticField*)dvmDexGetResolvedField(methodClassDex, ref); // 1

if(sfield == NULL) { // 2

EXPORT_PC(); // 3

sfield = dvmResolveStaticFeild(curMethod->clazz, ref); // 4

if(sfield == NULL)

GOTO_exceptionThrown();

if(dvmDexGetResolvedField(methodClassDex, ref) == NULL) {

JIT_STUB_HACK(dvmJitEndTraceSelect(self, pc));

}

}

SET_REGISTER##_regisze(vdst, dvmGetStaticField##_ftype(sfield)); // 5

}

FINISH(2);

注释1: 调用 dvmDexGetResolvedField()方法得到指定的区域,在上述例子中,这个区域Lcom/rikkatheworld/hotfix/DexFixDemo;->i1:I

注释2:判断注释1中的区域有没有被解析过

注释3:如果没有被解析过,则调用EXPORT **Android开源项目《ali1024.coding.net/public/P7/Android/git》** _PC,它会调用 dvmResolveClass()解析类

注释4:通过 dvmResolveStaticFeild()拿到静态域。

注释5:返回静态域。

可见此时 sget指令比 const/4指令的解析过程要复杂,所以final static基本类型可以得到优化。

final static String类型引用 const-string指令的解析执行速度要比sget快一些。

final static String类型的变量,在编译期间引用会被优化成 const-string指令,因为 const/4 获取的值是 立即数,但是 const-string指令获取的只是 字符串常量在dex文件结构中字符串常量区的索引ID,所以需要额外的一次字符串查找。

dex文件中有一块区域存储这程序所有的字符串常量,最终这块区域会被虚拟机完整加载到内存中,这块区域也就是通常说的 “字符串常量区”内存。

final static引用类型没有得到优化,是因为不管是不是final,最后都是通过 sget-object指令去获取该值,所以此时实际上从虚拟机运行性能方面来说得不到任何优化,此时final的作用,仅仅是让编译器能在编译期间检测到该final域有没有被修改。final域修改过在编译期就会直接报错。

所以这里引出一个冷知识

final字段只在编译期间起到作用----它可以在编译期阻止任何 final类型的修改,但是到了运行期,final就冇用了,这就说明,运行时使用反射是可以更改 final字段的…(网上一搜,果然有人试验过:[利用反射修改final数据域](())

在这里插入图片描述

[](()4.3 热部署解决方案


  • 修改final static基本类型或者String类型域(非引用类型域),由于在编译期间引用到基本类型的地方被立即数替换,引用到String类型的类型 的地方被常量池索引ID替换,所以在热部署模式下,最终所有引用到该 final static域的方法都会被替换。实际上此时仍然可以执行热部署方案

  • 修改final static引用类型域,是不允许的,因为这个field的初始化会被编译到clinit方法中,所以此时没法走热部署。

[](()5 有趣的方法编译

==========================================================================

[](()5.1 应用混淆方法编译


除了以上内部类和匿名内部类可能会造成method新增之后,我们发现项目如果应用了混淆方法编译,可能导致方法的内联和裁剪,那么最后也可能导致 method的新增或减少,以下介绍在哪些场景中会造成方法的内联或裁剪。

[](()5.2 方法内联


实际上有好几种情况可能导致方法被内联掉。

  • 方法没有被其他任何地方引用,毫无疑问,该方法会被内联掉。

  • 方法足够简单,比如一个方法的实现就只有一行代码,该方法会被内联掉,那么任何调用该方法的地方都会被该方法的实现替换掉。

  • 方法只被一个地方引用,这个地方会被方法的实现替换掉。

[](()5.3 方法裁剪

------------------------------------------------ 《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》开源 -------------------------

public class BaseBug {

public static void test(Context context) {

Log.d(“BaseBug”, “test”);

}

}

查看下生成的 mApping.txt文件:

com.rikkatheworld.hotfix.BaseBug -> com.rikkatheworld.hotfix.a:

void test$faab20d() -> a //在括号中没有context参数

此时test方法context参数没有被使用,所以test方法的context参数被裁剪。

混淆任务首先生成 test$faab20d()裁剪过后的无参方法,然后再混淆。

所以如果我们想要fix test方法时,里面用到context的参数,那么test方法的context参数不会被裁剪,补丁工具检测到新增了(test(context))方法。那么补丁只能走冷启动方案。

怎么让该参数不被裁剪呢?我们只要让编译器在优化的时候认为引用了一个无用的参数就好了,可以采取的方法很多,这里介绍一种最有用的方法:

public static void test(Context context) {

if(Boolean.FALSE.booleanValue()) {

context.getApplicationContext();

}

Log.d(“BaseBug”, “test”);

}

注意,这里不能使用基本类型false,必须使用包装类Boolean,因为如过使用基本类型if语句很可能会被优化掉的。

[](()5.4 热部署解决方案


实际上只要混淆配置文件加上 -dontoptimize选项就不会去做方法的裁减和内联。

在一般情况下,项目的混淆配置都会使用到 Android SDK默认的混淆配置文件 proguard-android-optimize.txt或者 proguard-android.txt,两者的区别就是后者应用了 -dontoptimize这一项配置而前者没有用。

preverification step :针对 .class文件的预校验,在 .class文件中加上 StackMa/StackMapTable信息,这样 Hotspot VM在类加载时执行类校验阶段会省去一些步骤,因此类加载会更快。

我们知道Android虚拟机执行的是 dex文件格式,编译期间dx工具会把所有的 .class文件优化成 .dex文件,所以混淆库的域编译在Android中是没有任何意义的,反而会降低打包速度,Android虚拟机中有自己的一套代码校验逻辑(dvmVerifyClass)。所以Android中混淆配置一般都需要加上 -dontpreverify这一项。

[](()6 switch case语句编译

最后

其实Android开发的知识点就那么多,面试问来问去还是那么点东西。所以面试没有其他的诀窍,只看你对这些知识点准备的充分程度。so,出去面试时先看看自己复习到了哪个阶段就好。

下图是我进阶学习所积累的历年腾讯、头条、阿里、美团、字节跳动等公司2019-2021年的高频面试题,博主还把这些技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节

整理不易,望各位看官老爷点个关注转发,谢谢!祝大家都能得到自己心仪工作。
热部署解决方案


实际上只要混淆配置文件加上 -dontoptimize选项就不会去做方法的裁减和内联。

在一般情况下,项目的混淆配置都会使用到 Android SDK默认的混淆配置文件 proguard-android-optimize.txt或者 proguard-android.txt,两者的区别就是后者应用了 -dontoptimize这一项配置而前者没有用。

preverification step :针对 .class文件的预校验,在 .class文件中加上 StackMa/StackMapTable信息,这样 Hotspot VM在类加载时执行类校验阶段会省去一些步骤,因此类加载会更快。

我们知道Android虚拟机执行的是 dex文件格式,编译期间dx工具会把所有的 .class文件优化成 .dex文件,所以混淆库的域编译在Android中是没有任何意义的,反而会降低打包速度,Android虚拟机中有自己的一套代码校验逻辑(dvmVerifyClass)。所以Android中混淆配置一般都需要加上 -dontpreverify这一项。

[](()6 switch case语句编译

最后

其实Android开发的知识点就那么多,面试问来问去还是那么点东西。所以面试没有其他的诀窍,只看你对这些知识点准备的充分程度。so,出去面试时先看看自己复习到了哪个阶段就好。

下图是我进阶学习所积累的历年腾讯、头条、阿里、美团、字节跳动等公司2019-2021年的高频面试题,博主还把这些技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节

[外链图片转存中…(img-MqcP1NPC-1650451087028)]

整理不易,望各位看官老爷点个关注转发,谢谢!祝大家都能得到自己心仪工作。


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部