所以上述例子中,最后的j的值为2。前面说过,类加载进行类初始化的时候,会去调用 clinit()
,一个类仅加载一次。以下三种情况都会尝试去加载一个类:
-
创建一个类的对象(new-instance指令)
-
调用类的静态方法(invoke-static指令)
-
获取类的静态域的值(sget指令)
首先判断这个类有没有被加载过,如果没有被加载过,执行 dvmResolveClass->dvmLinkClass->dvmInitClass
的流程,类的初始化时在dvmInitClass
中。dvmInitClass这个函数首先会尝试会对父类进行初始化,然后调用本类的 clinit方法,所以此时 静态field得到初始化并且静态代码块得到执行。
上面的示例中,能够明显的看到非静态 field初始化和非静态代码块被编译翻译在 <init>
默认无参构造函数中。非静态field和非静态代码块在init方法中的先后顺序也跟两者在源码中出现的顺序一致,所以上述示例中最后 i == 1。
实际上如果存在有参构造函数,那么每个有参构造函数都会执行一个非静态域的初始化和非静态代码块。
构造函数都会被Android编译器自动翻译成
<init>
方法
前面介绍过 clinit方法在类加载初始化的时候被调用,那么<init>
构造函数方法肯定是对类对象进行初始化时候被调用的,简单来说创建一个对象就会对这个对象进行初始化,并调用这个对象相应的构造函数,看下这行代码 String s = new String("test")
编译之后的样子。
new-instance v0, Ljava/lang/String;
invoke-direct {v0}, Ljava/lang/String;->()V
首先执行 new-instance
指令,主要为对象分配堆内存空间,同时如果类之前没有被加载过,尝试加载类。然后执行 invoke-direct
指令调用类的 init构造函数方法执行对象的初始化。
由于不支持 <clinit>
方法的热部署,所以任何静态field初始化和静态代码块的变更都会被编译到clinit方法中,导致最后热部署失败,只能冷启动生效。如上所见,非静态field和非静态代码块的变更被编译到 <init>
构造函数中,热部署模式下只是视为一个普通方法的变更,此时对热部署是没有影响的。
==================================================================================
final static
域首先是一个静态域,所以我们自然会认为其会编译到 clinit
方法中,所以在自然热部署下也是不能变更,但是测试发现,final static修饰的基本类型或者 String常量类型,匪夷所思的竟然没有被编译到 clinit方法中去,见以下分析。
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 = 2
和 final static String s2 = "haha"
这两个静态域竟然没有被初始化,而其他的非 final静态域均在clinit函数中得到初始化。
这里注意下 “haha”
和 new String("heihei")
的区别,前者是字符串常量,后者是引用类型。那这两个final static域(i2和s2)究竟在何处会初始化?
事实上,类加载初始化 dvmInitClass在执行clinit方法之前,首先会执行 <initSFields>
,这个方法的作用主要就是给 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方法中。
另外一方面,我们经常会看到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_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数据域)
-
修改final static基本类型或者String类型域(非引用类型域),由于在编译期间引用到基本类型的地方被立即数替换,引用到String类型的类型 的地方被常量池索引ID替换,所以在热部署模式下,最终所有引用到该 final static域的方法都会被替换。实际上此时仍然可以执行热部署方案
-
修改final static引用类型域,是不允许的,因为这个field的初始化会被编译到clinit方法中,所以此时没法走热部署。
==========================================================================
除了以上内部类和匿名内部类可能会造成method新增之后,我们发现项目如果应用了混淆方法编译,可能导致方法的内联和裁剪,那么最后也可能导致 method的新增或减少,以下介绍在哪些场景中会造成方法的内联或裁剪。
实际上有好几种情况可能导致方法被内联掉。
-
方法没有被其他任何地方引用,毫无疑问,该方法会被内联掉。
-
方法足够简单,比如一个方法的实现就只有一行代码,该方法会被内联掉,那么任何调用该方法的地方都会被该方法的实现替换掉。
-
方法只被一个地方引用,这个地方会被方法的实现替换掉。
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语句很可能会被优化掉的。
实际上只要混淆配置文件加上 -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
这一项。
==================================================================================
由于在实现资源修复方案热部署的过程中(后面章节会讲到),要做新旧资源的ID替换,我们竟然发现存在switch case语句中的ID不会被替换掉的情况,所以有必要来探索下 switch case
语句编译的特殊性。
public void testContinue() {
int temp = 2;
int result = 0;
switch (temp) {
case 1:
result = 1;
break;
case 3:
result = 3;
break;
case 5:
result = 5;
break;
}
}
public void testNotContinue() {
int temp = 2;
int result = 0;
switch (temp) {
case 1:
result = 1;
break;
case 3:
result = 3;
break;
case 10:
result = 10;
}
}
看看上面两个方法编译出来有什么不同:
.method public testContinue()V
…
const/4 v1, 0x2
.local v1, “temp”:I
const/4 v0, 0x0
.local v0, “result”:I
packed-switch v1, :pswitch_data_c
:pswitch_5
return-void
:pswitch_6
const/4 v0, 0x1
:pswitch_8
const/4 v0, 0x3
:pswitch_a
const/4 v0, 0x5
:pswitch_data_c
.packed-switch 0x1
:pswitch_6
:pswitch_5
:pswitch_8
:pswitch_5
:pswitch_a
.end packed-switch
.end method
.method public testNotContinue()V
…
const/4 v1, 0x2
.local v1, “temp”:I
const/4 v0, 0x0
.local v0, “result”:I
sparse-switch v1, :sswitch_data_e
:sswitch_6
const/4 v0, 0x1
:sswitch_8
const/4 v0, 0x3
:sswitch_a
const/16 v0, 0xa
:sswitch_data_e
.sparse-switch
0x1 -> :sswitch_6
0x3 -> :sswitch_8
0xa -> :sswitch_a
.end sparse-switch
.end method
testContinue() 的switch case语句被编译成 packed-switch
指令
testNotContinue() 的switch case语句被编译成 sparse-switch
指令。
比较两者的差异:
①testContinue的switch语句的case项是连续的几个比较相近的值1、3、5,。所以被编译为 packed-switch
指令,可以看到几个连续的数中间的差值用: pswitch_5 补齐, :pswitch_5
标签处直接return-void、
②testNotContinue的switch语句的case分别是1、3、10,很明显不够连续,所以被编译为 sparse-switch
指令。编译器会决定怎样的值才算是连续的case。
一个资源ID肯定是 const final static
变量,此时恰好switch case语句被编译 packed-switch
指令,所以这个时候如果不做任何处理就会存在资源ID替换不完全的情况。
解决这种情况方案其实很简单,修改smali反编译流程,碰到 packed-switch
指令强制转为 sparse-switch
指令, :pswitch_N
等相关标签指令也需要强转为 :sswitch_N
指令。然后做资源ID暴力替换,然后再回编译 smali为dex。再做类方法变更的检测,所以就需要经过反编译->资源ID替换->回编译,这也会使打补丁变得稍慢一些。
=======================================================================
泛型是从Java5才引入的,我们发现泛型的使用,也可能导致method的新增,所以是时候了解一下泛型的编译过程了。
-
Java语言中泛型基本上完全在编译器中实现,由编译器执行类型检查和类型推断,然后生成普通的非泛型字节码,就是虚拟机完全无感知泛型的存在。这种技术称为泛型擦除。编译器使用泛型类型信息保证类型安全,然后在生成字节码之前将其清除。
-
Java5才引入泛型,所以扩展虚拟机指令集来支持泛型被认为是无法接受的,因为这会为Java厂商升级其JVM造成难以逾越的障碍。因此采用了可以完全在编译器中实现的擦除方法。
我们知道了以上的两点,其中最重要的是类型擦除的理解,先来通过一个例子说明Java为什么需要泛型。Java5之前,要实现类似“泛型”的功能,由于Java类都是以Object为最上层的父类别,所以可以用Object
来实现似“泛型”的功能。
public class ObjectFoo {
private Object foo;
public Object getFoo() {
return foo;
}
public void setFoo(Object foo) {
this.foo = foo;
}
}
// 代码调用示例
ObjectFoo foo = new ObjectFoo();
foo.setFoo(new Boolean(true));
Boolean b = (Boolean) foo.getFoo(); //正确
String s = (String) foo.getFoo(); //运行时,类型转换失败, ClassCastException异常
由于语法上并没有错误,所以编译器检查不出上面程序有误,真正的错误要在执行器才会发生。
所以可以看到使用Obejct来实现“泛型”存在一些问题,因为必须要强制类型转换,但很多人可能忘记强制类型转换,或者是强转用错类型,然而由于语法上可以的,所以编译器检查不出错误。Java5之后,提出了针对泛型设计的解决方案。该方案在编译器进行类型安全监测,允许程序员在编译器就能监测到非法的类型,泛型解决方案如下:
public class GenericFoo {
private T foo;
public T getFoo() {
return foo;
}
public void setFoo(T foo) {
this.foo = foo;
}
}
// 代码调用示例
GenericFoo foo = new GenericFoo();
foo.setFoo(new Boolean(true));
Boolean b = foo.getFoo(); //正确
String s = (String) foo.getFoo(); //编译不通过
很明显此时使用泛型的优势就体现出来了,可以在编译期就检查到了可能的异常。
我们来反编译一下上述 GenericFoo<T>
的字节码:
.method public getFoo()Ljava/lang/Object;
.method public setFoo(Ljava/lang/Object;)V
可以看到它是被编译成了Object,如果此时再定义一个 setFoo<Object foo>
方法是行不通的,编译期会报重复方法定义。
如果这样的 <T extends Numble>
,那么是这样子的:
.method public setFoo(Ljava/lang/Number;)V
.method public getFoo()Ljava/lang/Number;
所以我们知道 new T()
这样使用泛型是编译不过的,因为类型擦除会导致实际上是 new Object()
,所以是错误的
class A {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
class B extends A {
private Number n;
@Override //跟父类返回值不一样,为什么重写父类get方法
public Number get() {
return n;
}
@Override //跟父类方法参数类型不一样,为什么重写父类set方法
public void set(Number number) {
this.n = number;
}
}
class C extends A {
private Number n;
@Override //跟父类返回值不一样,为什么重写父类get方法
public Number get() {
return n;
}
//@Override 重写父类get方法,因为方法参数类型不一样,这里没问题
public void set(Number o) {
this.n = o;
}
}
按照前面类型擦除的分析,为什么类B的 set和get方法可以用 @Override而不报错。@Override
表明这个方法是重写,我们知道重写的意思是子类的方法与父类中的某一方法具有相同的方法名,返回类型和参数表。
但是根据前面的分析,基类A由于类型擦除的影响,set(T t)
在字节码中实际上是 set(Object t)
,那么类B的方法 set(Number n)
方法参数不一样,此时类B的set方法理论上来说应该重载而不是重写基类的set方法。但是我们的本意是重写,实现多态,可是类型擦除后,只能变为重载,这样,类型擦除就和多态有了冲突。
实际上JVM采用了一个特殊的方法,来完成这项重写功能,那就是桥接。看下类B的字节码表示:
.method public get()Ljava/lang/Number;
.method public bridge synthetic get()Ljava/lang/Object;
invoke-virtual {p0}, Lcom/rikkatheworld/hotfix/Main$B;->get()Ljava/lang/Number;
move-result-object v0
return-object v0
.end method
.method public set(Ljava/lang/Number;)V
.method public bridge synthetic set(Ljava/lang/Object;)V
check-cast p1, Ljava/lang/Number;
invoke-virtual {p0, p1}, Lcom/rikkatheworld/hotfix/Main$B;->set(Ljava/lang/Number;)V
return-void
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
尾声
如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。
对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。
这里,笔者分享一份从架构哲学的层面来剖析的视频及资料给大家梳理了多年的架构经验,筹备近6个月最新录制的,相信这份视频能给你带来不一样的启发、收获。
Android进阶学习资料库
一共十个专题,包括了Android进阶所有学习资料,Android进阶视频,Flutter,java基础,kotlin,NDK模块,计算机网络,数据结构与算法,微信小程序,面试题解析,framework源码!
大厂面试真题
PS:之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)
《2019-2021字节跳动Android面试历年真题解析》
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
712017159327)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
尾声
如果你想成为一个优秀的 Android 开发人员,请集中精力,对基础和重要的事情做深度研究。
对于很多初中级Android工程师而言,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。 整理的这些架构技术希望对Android开发的朋友们有所参考以及少走弯路,本文的重点是你有没有收获与成长,其余的都不重要,希望读者们能谨记这一点。
这里,笔者分享一份从架构哲学的层面来剖析的视频及资料给大家梳理了多年的架构经验,筹备近6个月最新录制的,相信这份视频能给你带来不一样的启发、收获。
[外链图片转存中…(img-p99kx1Ju-1712017159328)]
Android进阶学习资料库
一共十个专题,包括了Android进阶所有学习资料,Android进阶视频,Flutter,java基础,kotlin,NDK模块,计算机网络,数据结构与算法,微信小程序,面试题解析,framework源码!
[外链图片转存中…(img-TnhZQ7rc-1712017159328)]
大厂面试真题
PS:之前因为秋招收集的二十套一二线互联网公司Android面试真题 (含BAT、小米、华为、美团、滴滴)和我自己整理Android复习笔记(包含Android基础知识点、Android扩展知识点、Android源码解析、设计模式汇总、Gradle知识点、常见算法题汇总。)
[外链图片转存中…(img-lDHVgpld-1712017159328)]
《2019-2021字节跳动Android面试历年真题解析》
[外链图片转存中…(img-riVxUQPP-1712017159328)]