2024年Android最新抖音Android包体积优化探索:从Class字节码入手精简DEX体积,最新美团点评Android团队面试题

总结

现在新技术层出不穷,如果每次出新的技术,我们都深入的研究的话,很容易分散精力。新的技术可能很久之后我们才会在工作中用得上,当学的新技术无法学以致用,很容易被我们遗忘,到最后真的需要使用的时候,又要从头来过(虽然上手会更快)。

我觉得身为技术人,针对新技术应该是持拥抱态度的,入了这一行你就应该知道这是一个活到老学到老的行业,所以面对新技术,不要抵触,拥抱变化就好了。

Flutter 明显是一种全新的技术,而对于这个新技术在发布之初,花一个月的时间学习它,成本确实过高。但是周末花一天时间体验一下它的开发流程,了解一下它的优缺点、能干什么或者不能干什么。这个时间,并不是我们不能接受的。

如果有时间,其实通读一遍 Flutter 的文档,是最全面的一次对 Flutter 的了解过程。但是如果我们只有 8 小时的时间,我希望能关注一些最值得关注的点。

(跨平台开发(Flutter)、java基础与原理,自定义view、NDK、架构设计、性能优化、完整商业项目开发等)

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

5: iconst_0

6: putfield      #Field MyClass.aBoolean:Z. // 3.发现是该Class的域,且赋值为iconst_0,标记往上三个指令可以删除

7: aload_0

8: iconst_1

9: putfield      #Field MyClass.aBoolean:Z  // 4.发现是该Class的域,且赋值不为iconst_0,则在遍历过的Key的集合中添加MyClass-aBoolean,继续往下

10: aload_0

11: iconst_0

12: putfield     #Field MyClass.aBoolean:Z  // 5.发现是该Class的域,但在遍历过的Key的集合中发现存在MyClass-aBoolean,继续往下

15: return

最终发现上述字节码中,标红的部分可以删除,删除对应的字节码指令,优化完成。

使用抖音之前开源的字节码处理框架 ByteX,可以比较方便地获取 Field 的 Class,遍历 Class 的所有方法,以及所有方法的字节码。我们也已经将此方案进行了开源,有兴趣的同学可以前往查看详细代码:

  • https://github.com/bytedance/ByteX/blob/master/field-assign-opt-plugin

删除无副作用代码


冗余赋值是利用了虚拟机在类加载时为字段默认赋值的特性,从而删除多余的的赋值指令,而我们代码中本身也有一些对线上包是没有作用的,最常见的就是日志打印,除了占用包体积之外,还会造成性能问题以及安全风险,因此一般都会将其移除掉,接下来我们以 Log.i 调用为例来介绍如何删除代码中的无用函数调用。比如下面代码中的日志打印语句:

public static void click() {

clickSelf();

Log.i(“Logger”, “click time:” + System.currentTimeMillis());

}

一开始我们尝试了 proguard 的 -assumenosideeffects,这个指令需要我们假定要删除的方法调用没有任何的副作用,并且从程序分析的角度来说这个方法是不会修改堆上某个对象或者栈上方法参数的值。使用如下配置,proguard 就会在 optimize 阶段帮我们删除 Log 相关的方法调用。

-assumenosideeffects class android.util.Log {

public static boolean isLoggable(java.lang.String, int);

public static int v(…);

public static int i(…);

public static int w(…);

public static int d(…);

public static int e(…);

}

但是这种删除并不彻底,它只会删除方法调用指令本身,比如上面的代码中删除 Log.i 方法调用之后,会遗留一个 StringBuilder 对象的创建:

public static void click() {

clickSelf();

new StringBuilder(“click time:”)).append(System.currentTimeMillis();

}

这个对象的创建我们人为判断的话也是无用的,但是仅从简单的静态程序指令分析的角度并不能判定其是无用的,因此 proguard 并没有将其删除。

既然 assumenosideeffects 删除不干净,我们就自己来实现更加彻底的优化方案。

优化思路

public static void click();

Code:

0: invokestatic  #6                  // Method clickSelf:()V

3: ldc           #7                  // String Logger

5: new           #8                  // class java/lang/StringBuilder

8: dup

9: invokespecial #9                  // Method java/lang/StringBuilder.“”😦)V

12: ldc           #10                 // String click time:

14: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

17: invokestatic  #12                 // Method java/lang/System.currentTimeMillis:()J

20: invokevirtual #13                 // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;

23: invokevirtual #14                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;

26: invokestatic  #2                  // Method android/util/Log.i:(Ljava/lang/String;Ljava/lang/String;)I

29: pop

如上可以看到一行Log.i("Logger", "click time:" + System.currentTimeMillis());在编译完成之后会生成多条指令(从 ldc 到 pop),除了目标方法 Log.i 调用 invokestatic 指令外,还有很多参数创建和入栈指令。

我们要删除相关方法的调用的话,主要是就是找到这行代码所产生的起始指令和终止指令,然后起始到终止位置之间的指令就是我们要删除的全部指令。

1. 查找终止指令位置

终止指令的查找相对简单,主要就是找到要删除的目标方法调用指令,再根据方法的返回值类型确定是否要包含其后的 pop 或 pop2 指令。

比如上述代码我们通过遍历就能找到目标方法调用invokestatic #2 的位置,因为 Log.i 的返回值类型是 int,终止指令就是下一条的 pop。

注意 pop 指令的作用是主动让 int 类型的值出栈,也就是不会使用该方法的返回值,只有这种情况下我们才能安全删除目标方法,否则不能删除。当然如果方法的返回值类型是 void,就不会有 pop 指令。

2. 查找起始指令位置

起始指令的查找则需要我们对于 java 字码指令设计有基本的认识: java 字节码指令是基于堆栈设计的,每一条字节码指令会对应操作数栈的若干参数的入栈和出栈,并且一个完整独立代码/代码块执行前和执行后操作数栈应该是一样的。

因此我们找到终止指令后,倒序遍历指令,根据指令的作用进行反向的入栈和出栈操作,当我们的栈中 size 减为 0 时,就找到了起始指令的位置。注意在入栈时候要记录参数的类型,并在出栈时候做类型匹配校验。如上面的示例:

  • pop 指令效果是单 slot 参数(像 int,float)出栈 ,那我们就在栈存入一个 slot 类型的参数

  • invokestatic 要看方法的参数和返回值,正常效果是对应方法的参数从右至左依次出栈,方返回值 int 入栈。我们就根据方法返回值出栈一个 int 类型的参数,发现栈顶目前是 slot,类型匹配。然后按照方法参数从左至右依次入栈两个 String 类型的参数。

  • invokevirtual 指令正常方法调用参数依次从右至左依次出栈,然后 this 对象出栈,最后方法返回值 String 入栈。我们弹出栈顶一个参数,发现其和 String 匹配,然后依次入栈 this 对应的类型 StringBuilder,这里调用的是 toString 方法没有参数就不用再入栈。

  • 中间其他的指令类似,直到 ldc 指令,本身是向栈中放入一个 int,float 或 String 常量,我们这里弹出一个参数,发现其是 String 匹配,并且此时栈的大小变为 0,也就找到了起始指令的位置。

方案缺陷

不过上述方案存在两个缺陷:

  1. 因为分析只在单个方法内分析,针对 Log 方法封装的情况,必须需要配置封装方法作为目标方法,才能删除完全删除,比如下面的方法需要配置 AccountLog.d 才能删除其调用处的 StringBuilder 创建。

object AccountLog {

@JvmStatic

fun d(tag: String, msg: String) = Log.d(tag, msg)

}

  1. 可能会误删除一些有用的指令,因为无法认为 Log.i 的两个参数的构建指令都是没有用的,我们只能确定 StringBuilder 的创建是没用的,但是一些其他的方法调用可能会改变一些对象的状态,因此存在一定风险。

Proguard 方案

在我们上述方案在线上运行一年之后,尝试针对上述弊端进行优化,然后发现 proguard 还提供了 assumenoexternalsideeffects 指令,它可以让我们指定没有任何外部副作用的方法。

指定了以后,它只会修改调用这个方法的实例本身,但不会修改其他的对象。通过如下的配置可以删除无用的 StringBuilder 创建。

-assumenoexternalsideeffects class java.lang.StringBuilder {

public java.lang.StringBuilder();

public java.lang.StringBuilder(int);

public java.lang.StringBuilder(java.lang.String);

public java.lang.StringBuilder append(java.lang.Object);

public java.lang.StringBuilder append(java.lang.String);

public java.lang.StringBuilder append(java.lang.StringBuffer);

public java.lang.StringBuilder append(char[]);

public java.lang.StringBuilder append(char[], int, int);

public java.lang.StringBuilder append(boolean);

public java.lang.StringBuilder append(char);

public java.lang.StringBuilder append(int);

public java.lang.StringBuilder append(long);

public java.lang.StringBuilder append(float);

public java.lang.StringBuilder append(double);

public java.lang.String toString();

}

-assumenoexternalreturnvalues public final class java.lang.StringBuilder {

public java.lang.StringBuilder append(java.lang.Object);

public java.lang.StringBuilder append(java.lang.String);

public java.lang.StringBuilder append(java.lang.StringBuffer);

public java.lang.StringBuilder append(char[]);

public java.lang.StringBuilder append(char[], int, int);

public java.lang.StringBuilder append(boolean);

public java.lang.StringBuilder append(char);

public java.lang.StringBuilder append(int);

public java.lang.StringBuilder append(long);

public java.lang.StringBuilder append(float);

public java.lang.StringBuilder append(double);

}

不过,这个配置只适用于 Log 里只传入 String 的情况。如果是int Log.w (String tag, Throwable tr)这种情况,就无法把Throwable参数也一起去掉。那还是应该采用我们自己实现的插件才能优化干净。

此优化对抖音包体积收益,约为 520KB。

短方法内联


上面介绍的两个优化是从去除无用的指令的角度出发,开篇 DEX 优化思路中我们有讲过,减少定义方法或者字段数从而减少 DEX 数量也是我们常用优化思路之一,短方法内联就是精简代码指令的情况下,同时减少定义方法数。

在和海外竞品的对比过程中,我们发现单个 DEX 文件中的定义方法数远比竞品要多,进一步对 DEX 进行分析,发现抖音的 DEX 中有大量的 access,getter-setter 方法,而竞品中几乎没有。因此我们打算针对短方法做一些内联优化,减少定义方法数。

在介绍优化方案前,先来了解下内联的基础知识,内联作为最常见的代码优化手段,被称为优化之母。一些语言像 C++、Kotlin 提供了 inline 关键字给程序员做函数的内联,而 Java 语言本身并没有给程序员提供控制或建议 inline 的机会,甚至 javac 编译过程中也没有做方法内联。为了便于理解,我们通过一个简单的例子来看内联是如何工作的,如下代码中 callMethod 调用 print 函数:

public class InlineTest {

public static void callMethod(int a) {

int result = a + 5;

print(result);

}

public static void print(int result) {

System.out.println(result);

}

}

在内联之后 inlineMethod 的内容直接被展开到 callMethod 中, 从字节码的角度看变化如下:

内联前:

public static void callMethod(int);

Code:

0: iload_0

1: iconst_5

2: iadd

3: istore_1

4: iload_1

5: invokestatic  #2                  // Method print:(I)V

8: return

内联后:

public static void callMethod(int);

Code:

0: iload_0

1: iconst_5

2: iadd

3: dup

4: istore_0

5: istore_0

6: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;

9: iload_0

10: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V

13: return

从执行时间的角度看,减少了一次函数调用,从而提升了执行性能。从空间占用角度看,减少了一处函数声明,从而减少了代码体积。

那是不是所有的方法都适合内联呢?

显然不是的,对于单次调用的方法说内联能同时取得时间和空间的收益;对于多次调用的的方法则需要考虑方法本身的长短,比如上面的 print 方法展开之后的指令是比 invokestatic 指令本身要长很多的,但是像 access、getter-setter 方法本身比较短就很适合内联。

access 方法内联

public class Foo {

private int mValue;

private void doStuff(int value) {

System.out.println("Value is " + value);

}

private class Inner {

void stuff() {

Foo.this.doStuff(Foo.this.mValue);

}

}

}

如上述代码,大家都知道 Java 可以在内部类 Foo$Inner 中直接访问外部类 Foo 的私有成员,但是 JVM 并没有什么内部类外部类的概念,认为一个类直接访问另一个类的私有成员是非法的。编译器为了能实现这种语法糖,会在编译期生成以下静态方法:

static int Foo.access$100(Foo foo) {

return foo.mValue;

}

static void Foo.access$200(Foo foo, int value) {

foo.doStuff(value);

}

内部类对象创建时候会传入外部类的引用,这样当内部类需要访问外部类的mValue 或调用doStuff()方法时,会通过调用这些静态方法来实现。这里需要生成静态的方法的原因,是因为被访问的成员是私有的,而私有访问控制更多地是在源码层面去约束,防止破坏程序的设计。在字节码层面只要不破坏语法逻辑,因此我们完全可以将这些私有成员改成 public 的,直接删除掉编译器生成的桥接静态方法。

优化思路

具体的优化分为分为以下几步:

  1. 收集字节码中的 access 方法。

static int access$000(com.bytedance.android.demo.inline.Foo);

descriptor: (Lcom/bytedance/android/demo/inline/Foo;)I

flags: ACC_STATIC, ACC_SYNTHETIC

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: getfield      #2                  // Field mValue:I

4: ireturn

static void access$100(com.bytedance.android.demo.inline.Foo, int);

descriptor: (Lcom/bytedance/android/demo/inline/Foo;I)V

flags: ACC_STATIC, ACC_SYNTHETIC

Code:

stack=2, locals=2, args_size=2

0: aload_0

1: iload_1

2: invokespecial #1                  // Method doStuff:(I)V

5: return

如上面的字节码所示,它的特征非常明显,因为是编译生成的方法,它有 synthetic 标记,并且是静态方法,方法名字以"access$"开头,通过这些特征在 ClassVisitor visitMethod 时就很容易匹配到相关方法。

  1. 分析并记录 access 方法调用处要替换的目标指令。

access 桥接的访问只有字段和方法两种,相对应的指令是方法访问指令(invokvirtual, invokspecial 等)和字段访问指令(getfield, putfield 等) ,只需遍历方法找到相应的指令,同时解析出指令访问的字段或方法信息,然后再将对应的 private 成员改为 public。比如 access$000 方法会找到如下指令,访问的字段是类 Foo 的 mValue。

getfield      #2                  // Field mValue:I

  1. 替换 access 方法调用处的 invokestatic 为对应的目标指令,并删除 access 方法的定义。

遍历查找所有对 access 方法的调用点,如下面的 invokestatic 指令,其调用方法在我们第一步收集的 access 方法中,将它替换为 getfield,然后便可以删除 Foo.access$000 方法本身。

invokestatic  #3                  // Method com/bytedance/android/demo/inline/Foo.access$000:(Lcom/bytedance/android/demo/inline/Foo;)I

getter-setter 内联

封装是面向对象编程(OOP)的基本特性之一,使用 getter 和 setter 方法是在程序设计中常见的封装方法之一。在日常开发中,我们常常会为一些类写一些 getter-setter 方法,如下代码所示:

public class People {

private int age;

public int getAge() {

return this.age;

}

public void setAge(int age) {

this.age = age;

}

}

这些方法完全就是短方法内联的最佳 case。

优化思路

getter-setter 内联整体实现和 access 方法大同小异,整体也分为收集、分析和删除三步。

public int getAge();

descriptor: ()I

flags: ACC_PUBLIC

Code:

stack=1, locals=1, args_size=1

0: aload_0

1: getfield      #2                  // Field age:I

4: ireturn

public void setAge(int);

descriptor: (I)V

flags: ACC_PUBLIC

Code:

stack=2, locals=2, args_size=2

0: aload_0

1: iload_1

2: putfield      #2                  // Field age:I

5: return

  1. 收集代码中要内联的 getter-setter 方法信息。参考上面的字节码指令,主要是找出只有参数入栈(LOAD 类指令)、字段访问(GETFIELD, PUTFIELD)、RETURN 指令 的方法。这里需要注意的是要过滤被 proguard 规则 keep 的方法,这些删除风险很大,因为可能会有插件内调用或者反射调用。

  2. 记录每个方法访问字段的指令以及目标字段,如果字段访问权限是非 public 的话,修改成 public 的。

  3. 针对调用 getter-setter 的方法的地方,直接替换为相应的字段访问指令,并删除 getter-setter 的方法的定义。

为什么不用 Proguard

Proguard 除了混淆、shrink 无用代码之外,也会对代码进行诸多的优化,其中就包括短方法内联,唯一方法内联等。那我们的 App 为什么没有直接使用呢?主要还是因为使用了 robust 热修,auto-patch 对内联层级过高以及像 builder 方法这种情况支持的不好,会导致 Patch 生成失败。但是 access 方法、getter-setter 方法本身很短,至多也就有一层内联层级,不会影响 Patch 的生成,proguard 又无法配置哪些方法内联,因此我们打算自己来实现。

抖音上两个短方法内联减少定义方法数 7 万+,DEX 文件减少一个,包体积收益达到了 1.7M。

常量字段消除


上面短方法内联是将方法内容展开到调用处去,我们代码中的一些常量也类似,可以将常量值替换使用处,从而减少字段的声明,这种优化就是常量字段消除的最简单表现。

我们知道 javac 会做一些 final 类型变量的常量字段消除优化,比如下面的代码:

public class ConstJava {

public static final int INTEGER = 1024;

public static final String STRING = “this is long  str”;

public static void constPropagation() {

System.out.println(“integer:” + INTEGER);

System.out.println(“string:” + STRING);

}

}

在编译之后 constPropagation 方法就会变成如下内容,常量直接替换成了字面值,这样相应的 final 字段就变成了无用字段,proguard 就可以将其 shrink 掉。

public static void constPropagation() {

System.out.println(“integer:1024”);

System.out.println(“string:this is long  str”);

}

但是比如下面的一些一些 kotlin 代码,编译之后如下, 并未进行传播优化。当然这里如果添加 const 关键字修改,对应地会进行优化。

class ConstKotlin {

companion object {

val INTEGER = 1024

val STRING = “this is long str”

}

private val b = 6

fun constPropagation(){

println(“a:$INTEGER”)

println(“s:$STRING”)

}

}

编译后代码:

private static final int INTEGER = 1024;

@NotNull

private static final String STRING = “this is long str”;

public final void constPropagation() {

String var1 = “a:” + INTEGER;

System.out.println(var1);

var1 = “s:” + STRING;

System.out.println(var1);

}

因此我们可以针对这种 case 进行优化。

另外我们上面说常量字段消除优化之后,对应的字段声明就可以被 proguard 删除,但是项目中有很多 keep 过度的情况,比如下面的规则会导致常量字段声明被保留,这种情况我们可以将字段删除。

-keep class com.bytedance.android.demo.ConstJava{*;}

优化思路
  1. 收集 static final 类型的变量,并记录其字面值,这里需要排除一些特殊的字段,然后最终确定能删除的字段。需要排除的字段主要有下面两种:
  • 用来表示序列化对象版本的 serialVersionUID 字段;

  • 有反射使用到的字段,一般来说不太会有反射访问 final 类型变量的情况,但这里还是会尝试分析代码中对字段的反射调用,如果有对应的访问则保留。

  1. 针对代码中 getstatic 指令的访问,分析其访问的字段,如果在第一步收集到的字段中,就把对应的指令改为 l 对应的常量入栈指令,并删除对应的字段。如下为对 INTEGER 的访 getstatic 指令,其在第一步收集到的 final 类型变量中,字面值为 1。

getstatic     #48                 // Field STRING:Ljava/lang/String;

修改为 ldc 指令:

ldc           #25                 // String s:this is long str

这里些同学会有疑问,比如一个大的字符串传播到多个类里面不是反而会增大包体积么?

的确存在这种可能,不过由于一个 Dex 中所有的类共用一个常量池,所以传播过去如果两个类在同一个 Dex 文件中的话是不会有负向的,反之则会有负向。

常量字段消除优化总体带来 400KB 左右的包体收益。

R.class 常量内联


常量字段消除优化的是常规的 final static 类型,但在我们的代码中,还有另一种类型的常量也可以内联优化。

在我们 Android 的开发中,常常会用到 R 这个类,它是我们使用资源的最平常的方式。但实际上,R 文件的生成有着许多不合理的地方,对我们的性能和包大小都造成了极大的影响。但是要理解这个问题,首先我们需要再理解一次 R 文件是什么。

我们在平时的代码开发中,常常会写出以下平常的代码:

public class MainActivity extends AppCompatActivity {

@Override

protected void onCreate(@Nullable Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

// 此处我们使用R中的id来获取MainActivity的layout资源

setContentView(R.layout.activity_main);

}

}

我们在该例中使用R.layout.activity_main来获取了 MainActivity 的 layout 资源,那我们将其转化为字节码会是如何呢?这需要分两种情况讨论:

  • 当 MainActivity 在 application module 下时,其字节码为:

protected void onCreate(android.os.Bundle);

Code:

0: aload_0

1: aload_1

2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V

5: aload_0

6: ldc           #4                  // int 2131296285

8: invokevirtual #5                  // Method setContentView:(I)V

11: return

可以看到使用R.layout.activity_main直接被替换成了常量。

  • 然而,当 MainActivity 在 library module 下时,其字节码为:

最后,面试前该准备哪些资源复习?

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

这里再分享一下我面试期间的复习路线:(以下体系的复习资料是我从各路大佬收集整理好的)

《Android开发七大模块核心知识笔记》

面试字节两轮后被完虐,字节面试官给你的技术面试指南,请查收

面试字节两轮后被完虐,字节面试官给你的技术面试指南,请查收

《960全网最全Android开发笔记》

面试字节两轮后被完虐,字节面试官给你的技术面试指南,请查收

《379页Android开发面试宝典》

历时半年,我们整理了这份市面上最全面的安卓面试题解析大全
包含了腾讯、百度、小米、阿里、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。熟悉本文中列出的知识点会大大增加通过前两轮技术面试的几率。

《507页Android开发相关源码解析》

只要是程序员,不管是Java还是Android,如果不去阅读源码,只看API文档,那就只是停留于皮毛,这对我们知识体系的建立和完备以及实战技术的提升都是不利的。

真正最能锻炼能力的便是直接去阅读源码,不仅限于阅读各大系统源码,还包括各种优秀的开源库。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

// Method setContentView:(I)V

11: return

可以看到使用R.layout.activity_main直接被替换成了常量。

  • 然而,当 MainActivity 在 library module 下时,其字节码为:

最后,面试前该准备哪些资源复习?

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

这里再分享一下我面试期间的复习路线:(以下体系的复习资料是我从各路大佬收集整理好的)

《Android开发七大模块核心知识笔记》

[外链图片转存中…(img-Z3tnY7XN-1715711901599)]

[外链图片转存中…(img-OJ0aYMfL-1715711901600)]

《960全网最全Android开发笔记》

[外链图片转存中…(img-4TR9ZJf4-1715711901600)]

《379页Android开发面试宝典》

历时半年,我们整理了这份市面上最全面的安卓面试题解析大全
包含了腾讯、百度、小米、阿里、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。熟悉本文中列出的知识点会大大增加通过前两轮技术面试的几率。

《507页Android开发相关源码解析》

只要是程序员,不管是Java还是Android,如果不去阅读源码,只看API文档,那就只是停留于皮毛,这对我们知识体系的建立和完备以及实战技术的提升都是不利的。

真正最能锻炼能力的便是直接去阅读源码,不仅限于阅读各大系统源码,还包括各种优秀的开源库。

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 26
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值