A node that represents a class.
用来指代一个 class 文件。
那么我们想要的 class 内部的一切,应该可以通过这个类的 API 直接或者间接的获取。
没错,是的,看一眼:
我们只要通过 class 文件构造这么个 ClassNode 对象,好像就可以为所欲为了。
来看下代码把:
首先我们编写一个 User:
public class User {
private String name;
private int age;
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
然后我们希望获取 User.class 中包含的所有方法以及字段:
public class TreeApiTest {
public static void main(String[] args) throws Exception {
Class clazz = User.class;
String clazzFilePath = Utils.getClassFilePath(clazz);
ClassReader classReader = new ClassReader(new FileInputStream(clazzFilePath));
ClassNode classNode = new ClassNode(Opcodes.ASM5);
classReader.accept(classNode, 0);
List methods = classNode.methods;
List fields = classNode.fields;
System.out.println(“methods:”);
for (MethodNode methodNode : methods) {
System.out.println(methodNode.name + ", " + methodNode.desc);
}
System.out.println(“fields:”);
for (FieldNode fieldNode : fields) {
System.out.println(fieldNode.name + ", " + fieldNode.desc);
}
}
}
上述代码有个辅助类Utils.getClassFilePath方法我贴一下,主要是可以通过Class 对象,找到其在 AS 中具体的路径。
public static String getClassFilePath(Class clazz) {
// file:/Users/zhy/hongyang/repo/BlogDemo/app/build/intermediates/javac/debug/classes/
String buildDir = clazz.getProtectionDomain().getCodeSource().getLocation().getFile();
String fileName = clazz.getSimpleName() + “.class”;
File file = new File(buildDir + clazz.getPackage().getName().replaceAll(“[.]”, “/”) + “/”, fileName);
return file.getAbsolutePath();
}
看下我们代码的流程:
-
首先我们拿到 class 文件的路径;
-
然后交给 ClassReader
-
再构造一个 ClassNode 对象
-
调用 ClassReader.accept()方法完成对 class 遍历,并把相关信息记录到 ClassNode 对象中;
这个时候,我们就能够通过 ClassNode 去拿我们所想要的信息了,看一下输出:
methods:
, ()V
getName, ()Ljava/lang/String;
getAge, ()I
fields:
name, Ljava/lang/String;
age, I
到这里,有没有发现,如果只是读取 class 文件,是不是简单得不能再简单了?
上述的 API,称为 Tree Api,即我们分析完成 class 文件,把信息存储到 ClassNode,然后通过 ClassNode 再读取即可,有点类似 xml 文件解析时,把整个 xml 文件读取到内存中的方式。
Core Api
不过大家如果看博客,其实上述写法在博客中并不多见,更多的博客上书写的还是基于“事件驱动”的 API,即解析class 文件过程中,每遇到一个“节点”,把节点信息交给你,我们类似于监听“节点”的解析事件,我们看下代码:
public class VisitApiTest {
public static void main(String[] args) throws Exception {
Class clazz = User.class;
String clazzFilePath = Utils.getClassFilePath(clazz);
ClassReader classReader = new ClassReader(new FileInputStream(clazzFilePath));
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5) {
@Override
public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
System.out.println(“visit field:” + name + " , desc = " + descriptor);
return super.visitField(access, name, descriptor, signature, value);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
System.out.println(“visit method:” + name + " , desc = " + descriptor);
return super.visitMethod(access, name, descriptor, signature, exceptions);
}
};
classReader.accept(classVisitor, 0);
}
}
我们看下输出:
visit field:name , desc = Ljava/lang/String;
visit field:age , desc = I
visit method: , desc = ()V
visit method:getName , desc = ()Ljava/lang/String;
visit method:getAge , desc = ()I
我们再次梳理下步骤:
-
首先我们拿到 class 文件的路径;
-
然后交给 ClassReader;
-
再构造一个 ClassVisitor 对象;
-
将 ClassVisitor 对象传入 ClassReader.accept()方法来接受对 class文件解析时的“节点”回调信息;
可以看到,其实和上面的 Tree Api还是比较类似的,一个是解析完成了,将所有信息保存到一个具体的对象,我们去读取;一个是参与到解析的流程中,监听解析节点的回调,输出结构。
稍微脑洞一下,假设我们自定义一个 ClassVisitor,在 visitMethod方法中,把监听到的方法声明信息存储下来,放到一个 List 结合,那么我们是不是我们就实现了一个简单的ClassNode
实现呢?
所以 ClassNode 也是 ClassVisitor 的子类?
没错!
public class ClassNode extends ClassVisitor
到这里,你应该已经掌握了如何去读取一个 class 文件的内容:
class-> ClassReader.accept(ClassVisitor)
甚至一不小心掌握了 ClassNode 的原理,真是太开心了,那,我们继续。
修改字节码,就不会这么简单了哈,做好心理准备。
我们看个官方的例子:
public class C {
public void m() throws Exception {
Thread.sleep(100);
}
}
修改为:
public class C {
public static long timer;
public void m() throws Exception {
timer -= System.currentTimeMillis();
Thread.sleep(100);
timer += System.currentTimeMillis();
}
}
有点像我们给添加耗时操作的代码。
我们前面学习了,如何读取、遍历解析一个 class 文件,我们还没有尝试过如果回写一个 class文件,即我们对 class 文件做了一些修改,我们要尝试写回去覆盖原来的 class 文件。
初识 ClassWriter
看代码前,我们想一下,如果让我们来实现读取后写入,你会怎么做:
我们首先遍历的时候,拿到了 class 中所有的信息,然后调用个writeToFile 方法,直接写入不就行了么。
所以 ClassWriter 就是个 ClassVisitor?
他做的事情,就是遍历的时候保存信息,然后支持按照 class 文件格式写文件就行了。
恩…猜测正确的七七八八了。
我们先看下 ClassWriter 是不是 ClassVisitor:
public class ClassWriter extends ClassVisitor
我脑子里面已经有接下来代码的画面了:
public class ClassWriterTest {
public static void main(String[] args) throws Exception {
Class clazz = C.class;
String clazzFilePath = Utils.getClassFilePath(clazz);
ClassReader classReader = new ClassReader(new FileInputStream(clazzFilePath));
ClassWriter classWriter = new ClassWriter(0);
classReader.accept(classWriter, 0);
// 写入文件
byte[] bytes = classWriter.toByteArray();
FileOutputStream fos = new FileOutputStream(“/Users/zhy/Desktop/copyed.class”);
fos.write(bytes);
fos.flush();
fos.close();
}
}
上述代码,我们就完成了一个类的复制,如果我们传入相同的路径,那就完成了类的修改(当然了,目前我们还没修改)。
原理刚才都猜过了,唯一不同的就是没有 writeToFile,而是有个 toByteArray 方法,毕竟 byte数组更加通用一些。
开始尝试添加字段
虽然我们学会了 ClassWriter 的基础用法,但是我好像发现了一个问题。
ClassReader.accept 方法,只能接受一个 ClassVisitor 对象,因为我们必须要修改字节码,所以 ClassWriter 肯定是要传入的。
那么我们怎么传入另一个 ClassVisitor 对象去修改字节码呢?
我们看一个 ClassVisitor 的构造方法:
public ClassVisitor(final int api, final ClassVisitor classVisitor)
是不是瞬间明白了,我们可以给 ClassVisitor 传入一个实际对象,自己作为代理对象,需要拦截的方法,我们复写做操作,有点类似 ContextWrapper。
首先我们尝试添加一个字段,如果是 Tree API,其实通过 ClassNode 去 add 一个 FieldNode 就可以了。
不过我们选择用 visit API来实现,我们先编写一个 ClassVisitor 的子类:
public class AddTimerClassVisitor extends ClassVisitor {
public AddTimerClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor);
}
}
这个时候,我们先代理下真正的 ClassVisitor 对象,我们要做的就是:
找个合适的位置插入一个字段;
剩下的都交给传入的 ClassVisitor 去做。
那么什么是合适的位置呢?
其实就是复写哪个方法了,那我们得知道 ClassVisitor 大概会执行哪些方法,这些方法的执行顺序是什么样子的:
visit visitSource? visitOuterClass? ( visitAnnotation |
visitAttribute )*
( visitInnerClass | visitField | visitMethod )*
visitEnd
可以看到 ClassVistor 在遍历一个类的时候,相关调用顺序如上,?代表这个方法可能不会调用,*标识可能会调用 0 次或者多次。
那么我们这个合适的位置,首先要选择一定会调用的地方;其次最好能只执行一次;最后因为本身其内部可能就有 field,我们可以收集信息,防止插入重名 field 的情况,所以最终我们选择 visitEnd 方法中。
选择了方法,那么如何才能插入一个 field 呢?
我们思考下:
其实我们的 class 最终是由 ClassWriter 去生成的,它会通过 visitField 去收集相关信息,也就是说,你调用一次 ClassWriter.visitField 方法,他就会以为真有这个 field,然后记录下来。
那就简单了,我们看代码:
public class AddTimerClassVisitor extends ClassVisitor {
public AddTimerClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor);
}
@Override
public void visitEnd() {
FieldVisitor fv = cv.visitField(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, “timer”,
“J”, null, null);
if (fv != null) {
fv.visitEnd();
}
cv.visitEnd();
}
}
demo 代码,实际生产环境需要做更严格的条件校验。
我们给原类添加了一个 timer 字段,访问修饰符是 public static,并且其类型是 J 也就是 long 类型。
我们把代码组装到一起:
public class ClassWriterTest {
public static void main(String[] args) throws Exception {
Class clazz = C.class;
String clazzFilePath = Utils.getClassFilePath(clazz);
ClassReader classReader = new ClassReader(new FileInputStream(clazzFilePath));
ClassWriter classWriter = new ClassWriter(0);
AddTimerClassVisitor addTimerClassVisitor = new AddTimerClassVisitor(Opcodes.ASM5, classWriter);
classReader.accept(addTimerClassVisitor, 0);
// 写入文件
byte[] bytes = classWriter.toByteArray();
FileOutputStream fos = new FileOutputStream(“/Users/zhy/Desktop/copyed.class”);
fos.write(bytes);
fos.flush();
fos.close();
}
}
然后我们运行下。
运行生成的类,反编译看一下:
开心…
接下来我们要尝试修改字节码了,为方法添加耗时信息打印了。
修改方法
通过上文的学习,我们之前对于方法的遍历,会执行 ClassVisitor的 visitMethod 方法,修改方法肯定是离不开这个方法了,所以我们详细的看下这个方法:
ClassVisitor
public MethodVisitor visitMethod(
final int access,
final String name,
final String descriptor,
final String signature,
final String[] exceptions) {
if (cv != null) {
return cv.visitMethod(access, name, descriptor, signature, exceptions);
}
return null;
}
可以看到,这个方法的参数中包含了方法所有声明相关的信息,但是没有包含实际运行的代码相关信息,即指令信息。
不过可以看到,这个方法的返回值并不是 null,而是一个 MethodVisitor,所以我们 ClassReader 遍历class 文件的思路肯定是:先给你方法声明相关信息,然后我们给它返回一个 MethodVisitor,它拿到这个 MethodVisitor,再通过 MethodVisitor开始遍历这个方法内部的所有信息。
所以…我们需要自定义一个 MethodVisitor 完成代码的插入。
先撸一点代码:
public class AddTimerClassVisitor extends ClassVisitor {
public AddTimerClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
MethodVisitor newMethodVisitor = new MethodVisitor(api, methodVisitor) {
};
return newMethodVisitor;
}
…
我们在刚才的AddTimerClassVisitor中复写了visitMethod,再其内部我们自定义了一个 MethodVisitor 代理一波本来的对象。
问题又来了?通过举一反三的思想,我们应该能够猜到 MethodVisitor 跟 ClassVisitor 设计应该是类似的,里面一堆 visitXXX 方法,我们这次修改字节码是在方法前后分别注入代码,那么到底该选择复写哪些方法呢?
这就要求我们知道 MethodVisitor 中各种 visitXXX 方法的执行顺序了:
visitAnnotationDefault?
(visitAnnotation |visitParameterAnnotation |visitAttribute )* ( visitCode
(visitTryCatchBlock |visitLabel |visitFrame |visitXxxInsn | visitLocalVariable |visitLineNumber )*
visitMaxs )? visitEnd
首先是遍历一些注解、参数相关信息;从 visitCode 开始遍历一整个方法。
我们的注入是:
-
方法开始:我们选择复写 visitCode 方法;
-
RETURN 之前:我们选择复写 visitXxxInsn,再其内部判断当前指令是否是 RETURN;
选择好了注入的时机,问题来了,我们好像还不知道注入的代码怎么写呢?
是的,这里其实要求大家对字节码是有足够的掌握,不然我怎么写估计都不太好理解,但是我尽量用推导的方式,引导大家去理解。
首先我们要了解我们添加的代码,会以字节码指令的方式注入进去,所以我们要先大概看下在字节码的层面上变化是怎样的。
所以修改之前,我们要看分别看一下修改前与修改后对应的方法字节码:
public void m() throws Exception {
Thread.sleep(100);
}
对应字节码:
public void m() throws java.lang.Exception;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: ldc2_w #2 // long 100l
3: invokestatic #4 // Method java/lang/Thread.sleep:(J)V
6: return
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/imooc/blogdemo/blog03/C;
Exceptions:
throws java.lang.Exception
}
public void m() throws Exception {
timer -= System.currentTimeMillis();
Thread.sleep(100);
timer += System.currentTimeMillis();
}
对应字节码:
我框起来这两块代码,就是我们新增的字节码指令,那么我们实际要编写的代码和这两块新增的字节码是一一对应的。
先看我们方法最前面添加的指令:
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, mOwner, “timer”, “J”);
mv.visitMethodInsn(INVOKESTATIC, “java/lang/System”,
“currentTimeMillis”, “()J”);
mv.visitInsn(LSUB);
mv.visitFieldInsn(PUTSTATIC, mOwner, “timer”, “J”);
}
你仔细观察一下,其实和我们框起来的字节码对比:
0: getstatic #2 // Field timer:J
3: invokestatic #3 // Method java/lang/System.currentTimeMillis:()J
6: lsub
7: putstatic #2 // Field timer:J
基本上没有任何区别。
不过我们还是解释下这几行字节码:
-
拿当前类静态变量 timer,压到操作数栈
-
调用 System. System.currentTimeMillis,方法返回值压到操作数栈;
-
调用 “timer - System. System.currentTimeMillis”,结果压栈
-
将 3 得到的值,再次赋值给 timer 字段;
换成代码其实就是:
timer -= System.currentTimeMillis();
同样的,我们把方法 RETURN 前的代码也写了:
@Override
public void visitInsn(int opcode) {
if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
mv.visitFieldInsn(GETSTATIC, mOwner, “timer”, “J”);
mv.visitMethodInsn(INVOKESTATIC, “java/lang/System”,
“currentTimeMillis”, “()J”);
mv.visitInsn(LADD);
mv.visitFieldInsn(PUTSTATIC, mOwner, “timer”, “J”);
}
mv.visitInsn(opcode);
}
你可以采用和上面相同的方式去对比字节码。
有一点不同的是,对于 RETURN 这个指令,我们判断了多个,因为我们并不知道当前方法的返回值情况,如果确定方法没有返回值,那么只要判断 RETURN 即可。
好了,我们贴下完整的代码:
package com.imooc.blogdemo.blog03;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.FieldVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import static org.objectweb.asm.Opcodes.*;
public class AddTimerClassVisitor extends ClassVisitor {
private String mOwner;
public AddTimerClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor);
}
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
mOwner = name;
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if (methodVisitor != null && !name.equals(“”)) {
MethodVisitor newMethodVisitor = new MethodVisitor(api, methodVisitor) {
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, mOwner, “timer”, “J”);
mv.visitMethodInsn(INVOKESTATIC, “java/lang/System”,
“currentTimeMillis”, “()J”);
mv.visitInsn(LSUB);
mv.visitFieldInsn(PUTSTATIC, mOwner, “timer”, “J”);
}
@Override
public void visitInsn(int opcode) {
if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
mv.visitFieldInsn(GETSTATIC, mOwner, “timer”, “J”);
mv.visitMethodInsn(INVOKESTATIC, “java/lang/System”,
“currentTimeMillis”, “()J”);
mv.visitInsn(LADD);
mv.visitFieldInsn(PUTSTATIC, mOwner, “timer”, “J”);
}
mv.visitInsn(opcode);
}
};
return newMethodVisitor;
}
return methodVisitor;
}
@Override
public void visitEnd() {
FieldVisitor fv = cv.visitField(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, “timer”,
“J”, null, null);
if (fv != null) {
fv.visitEnd();
}
cv.visitEnd();
}
}
注:这其实就是官方文档中的一个例子。
然后我们运行一下,在桌面生成 copyed.class 文件。
完美,反编译一下,正常。
这个时候,还不能开心的太早,可以反编译不代表你代码编写就没有错误!
我们来验证下文件的正确性:
简单修改了一下输出文件的路径:
byte[] bytes = classWriter.toByteArray();
// 修改,为了一会能 java 执行
FileOutputStream fos = new FileOutputStream(“/Users/zhy/Desktop/com/imooc/blogdemo/blog03/C.class”);
fos.write(bytes);
fos.flush();
fos.close();
然后我们切到桌面,执行下:
java com.imooc.blogdemo.blog03.C
输出:
192:Desktop zhy$ java com.imooc.blogdemo.blog03.C
Error: A JNI error has occurred, please check your installation and try again
Exception in thread “main” java.lang.VerifyError: Operand stack overflow
Exception Details:
Location:
com/imooc/blogdemo/blog03/C.m()V @3: invokestatic
Reason:
Exceeded max stack size.
Current Frame:
bci: @3
flags: { }
locals: { ‘com/imooc/blogdemo/blog03/C’ }
stack: { long, long_2nd }
Bytecode:
0x0000000: b200 12b8 0018 65b3 0012 1400 19b8 0020
0x0000010: b200 2412 26b6 002c b200 12b8 0018 61b3
0x0000020: 0012 b1
at java.lang.Class.getDeclaredMethods0(Native Method)
at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
at java.lang.Class.privateGetMethodRecursive(Class.java:3048)
at java.lang.Class.getMethod0(Class.java:3018)
at java.lang.Class.getMethod(Class.java:1784)
at sun.launcher.LauncherHelper.validateMainClass(LauncherHelper.java:544)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:526)
报错了!我们讲的头头世道,结果一运行,啪啪打脸。
为什么呢?
我们观察下报错原因:Exceeded max stack size.
看起来我们忘记了一个细节。
什么细节呢?
我们需要回归下,刚才代码修改前后的字节码,其中有个细节我们忽略了:
// 前
public void m() throws java.lang.Exception;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
// 后
public void m() throws java.lang.Exception;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=4, locals=1, args_size=1
大家注意到没有,方法有个 stack值变化了!
这个值是什么意思呢?
他指的是我们操作数栈需要的深度,我们的 Java字节码指令,其中很多指令操作都会压栈操作,然后有些指令或者方法调用会弹出栈中的操作数去执行,例如 lsub就会弹出操作数栈里面的两个 long 类型的值去做减法,完成后再把结果压栈。
在这个方法中所有的指令执行完成,所需要的栈的深度在编译期就确定了,从反编译结果来看我们需要变成 4。
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
最后
都说三年是程序员的一个坎,能否晋升或者提高自己的核心竞争力,这几年就十分关键。
技术发展的这么快,从哪些方面开始学习,才能达到高级工程师水平,最后进阶到Android架构师/技术专家?我总结了这 5大块;
我搜集整理过这几年阿里,以及腾讯,字节跳动,华为,小米等公司的面试题,把面试的要求和技术点梳理成一份大而全的“ Android架构师”面试 Xmind(实际上比预期多花了不少精力),包含知识脉络 + 分支细节。
网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。
2021年虽然路途坎坷,都在说Android要没落,但是,不要慌,做自己的计划,学自己的习,竞争无处不在,每个行业都是如此。相信自己,没有做不到的,只有想不到的。祝大家2021年万事大吉。
报错了!我们讲的头头世道,结果一运行,啪啪打脸。
为什么呢?
我们观察下报错原因:Exceeded max stack size.
看起来我们忘记了一个细节。
什么细节呢?
我们需要回归下,刚才代码修改前后的字节码,其中有个细节我们忽略了:
// 前
public void m() throws java.lang.Exception;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
// 后
public void m() throws java.lang.Exception;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=4, locals=1, args_size=1
大家注意到没有,方法有个 stack值变化了!
这个值是什么意思呢?
他指的是我们操作数栈需要的深度,我们的 Java字节码指令,其中很多指令操作都会压栈操作,然后有些指令或者方法调用会弹出栈中的操作数去执行,例如 lsub就会弹出操作数栈里面的两个 long 类型的值去做减法,完成后再把结果压栈。
在这个方法中所有的指令执行完成,所需要的栈的深度在编译期就确定了,从反编译结果来看我们需要变成 4。
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
最后
都说三年是程序员的一个坎,能否晋升或者提高自己的核心竞争力,这几年就十分关键。
技术发展的这么快,从哪些方面开始学习,才能达到高级工程师水平,最后进阶到Android架构师/技术专家?我总结了这 5大块;
我搜集整理过这几年阿里,以及腾讯,字节跳动,华为,小米等公司的面试题,把面试的要求和技术点梳理成一份大而全的“ Android架构师”面试 Xmind(实际上比预期多花了不少精力),包含知识脉络 + 分支细节。
[外链图片转存中…(img-XxHRLedU-1718811842163)]
[外链图片转存中…(img-HXQ0xIFt-1718811842163)]
[外链图片转存中…(img-AulaNwiC-1718811842163)]
网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。
2021年虽然路途坎坷,都在说Android要没落,但是,不要慌,做自己的计划,学自己的习,竞争无处不在,每个行业都是如此。相信自己,没有做不到的,只有想不到的。祝大家2021年万事大吉。