Android 自定义注解详解

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

  • 28

  • 29

  • 30

  • 31

  • 32

  • 33

  • 34

  • 35

  • 36

  • 37

  • 38

  • 39

  • 40

  • 41

  • 42

  • 43

  • 44

  • 45

  • 46

  • 47

  • 48

  • 49

  • 50

  • 51

  • 52

  • 53

  • 54

  • 55

  • 56

  • 57

  • 58

  • 59

  • 60

  • 61

  • 62

  • 63

  • 64

  • 65

  • 66

  • 67

  • 68

  • 69

  • 70

  • 71

  • 72

  • 73

  • 74

  • 75

  • 76

  • 77

  • 78

  • 79

  • 80

  • 81

  • 82

  • 83

  • 84

  • 85

  • 86

  • 87

  • 88

  • 89

  • 90

  • 91

  • 92

  • 93

  • 94

5. 自定义运行期注解(RUNTIME)

我们在开发中经常会需要计算一个方法所要执行的时间,以此来直观的比较哪个实现方式最优,常用方法是开始结束时间相减

System.currentTimeMillis()

但是当方法多的时候,是不是减来减去都要减的怀疑人生啦,哈哈,那么下面我就来写一个运行时注解来打印方法执行的时间。

1.首先我们先定义一个注解,并给注解添加我们需要的元注解:

/** * Created by zmj on 2017/6/12. * * 这是一个计算方法执行时间的注解 * 只能作用于方法之上 * 属于运行时注解,能被VM处理,可以通过反射得到注解信息 * */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface CalculateMethodRunningTime { //要计算时间的方法的名字 String methodName() default "no method to set"; }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

2.利用反射方法在程序运行时,获取被添加注解的类的信息:

/** * Created by zmj on 2017/6/12. * * 注解实现过程中所需要的工具方法 * */ public class AnnotationUtils { //使用反射通过类名获取类的相关信息 public static void getClassInfo(String className) { try { Class c = Class.forName(className); //获取所有公共的方法 Method[] methods = c.getMethods(); for(Method m : methods) { Class<CalculateMethodRunningTime> ctClass = CalculateMethodRunningTime.class; if(m.isAnnotationPresent(ctClass)){ CalculateMethodRunningTime anno = m.getAnnotation(ctClass); //当前方法包含查询时间的注解时 if(anno != null){ long beginTime = System.currentTimeMillis(); m.invoke(c.newInstance(),null); long endTime = System.currentTimeMillis(); long time = endTime - beginTime; Log.i("Tag",anno.methodName()+"方法执行所需要时间:" + time + "ms"); } } } } catch (Exception e) { e.printStackTrace(); } } }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

  • 28

  • 29

  • 30

  • 31

  • 32

  • 33

  • 34

  • 35

  • 36

  • 37

3.在 activity 中使用注解,注意咱们的注解是作用于方法之上的:

public class ActivityAnnotattion extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_anno); AnnotationUtils.getClassInfo("com.annotation.zmj.annotationtest.ActivityAnnotattion"); } @CalculateMethodRunningTime(methodName = "method1") public void method1() { long i = 100000000L; while(i > 0) { i--; } } }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

4.运行结果:

这里写图片描述

6. 自定义编译期注解(CLASS)

为什么我要最后说编译期注解呢,因为相对前面的自定义注解来说,编译期注解有些难度,涉及到的东西比较多,但其却是平时用到的最多的注解,因为编译期注解不存在反射,所以对性能没有影响。

本来也想用绑定 view 的例子讲解,但是现在这样的 demo 网上真是各种泛滥啊,而且还有各路大牛写的,所以我就没必要班门弄斧了。在我们的实际开发中,肯定都被跳转界面烦过,几乎每个界面都会来个:

Intent intent = new Intent (this,NextActivity.class);

startActivity (intent);

好烦人,所以本着方便就是改进的原则,让我们定义一个编译期注解,来自动生成上述的代码,想想每次需要的时候只需要一个注解就能跳转到想要跳转的界面是不是很刺激。

1.首先新建一个 android 项目,在创建两个 java module(File -> New -> new Module ->java Module),因为有的类在android项目中不支持,建完后项目结构如下:

这里写图片描述

其中 annotation 中盛放自定义的注解,annotationprocessor 中创建注解处理器并做相关处理,最后的 app 则为我们的项目。

注意:MyFirstProcessor类为上文讲解 AbstractProcessor 所建的类,可以删去,跟本项目没有关系。

2.新建后的三个工程进行依赖,注解处理器必须依赖注解 module ,而app 需要同时依赖注解 module 和 注解处理器 module,这个很好理解,这三个都是独立的,你如果不进行依赖怎么去调用。

annotation

processor

app

3.编写自定义注解,这是一个应用到字段之上的注解,被注解的字段为传递的参数。

/** * 这是一个自定义的跳转传值所用到的注解 * value 表示要跳转到哪个界面activity的元素,传入那个界面的名字 */ @Retention(RetentionPolicy.CLASS) @Target(ElementType.FIELD) public @interface IntentField { String value () default " "; }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

3.自定义注解处理器,获取被注解元素的类型,进行相应的操作,方法不懂的上文应该都有解释。

/** * 这是一个自定义注解处理器 */ @AutoService(javax.annotation.processing.Processor.class) public class MyProcessot extends AbstractProcessor{ private Map<Element, List<VariableElement>> items = new HashMap<>(); private List<Generator> generators = new LinkedList<>(); /** * 做一些初始化工作 */ @Override public synchronized void init(ProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); Utils.init(); generators.add(new ActivityEnterGenerator()); generators.add(new ActivityInitFieldGenerator()); } @Override public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) { //获取所有注册IntentField注解的元素 for (Element elem : roundEnvironment.getElementsAnnotatedWith(IntentField.class)) { //主要获取ElementType 是不是null,即class,interface,enum或者注解类型 if (elem.getEnclosingElement() == null) { //直接结束处理器 return true; } //如果items的key不存在,则添加一个key if (items.get(elem.getEnclosingElement()) == null) { items.put(elem.getEnclosingElement(), new LinkedList<VariableElement>()); } //我们这里的IntentField是应用在一般成员变量上的注解 if (elem.getKind() == ElementKind.FIELD) { items.get(elem.getEnclosingElement()).add((VariableElement)elem); } } List<VariableElement> variableElements; for (Map.Entry<Element, List<VariableElement>> entry : items.entrySet()) { variableElements = entry.getValue(); if (variableElements == null || variableElements.isEmpty()) { return true; } //去通过自动javapoet生成代码 for (Generator generator : generators) { generator.genetate(entry.getKey(), variableElements, processingEnv); generator.genetate(entry.getKey(), variableElements, processingEnv); } } return false; } /** * 指定当前注解器使用的Java版本 */ @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); } /** * 指出注解处理器 处理哪种注解 */ @Override public Set<String> getSupportedAnnotationTypes() { Set<String> annotations = new LinkedHashSet<>(2); annotations.add(IntentField.class.getCanonicalName()); return annotations; } }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

  • 28

  • 29

  • 30

  • 31

  • 32

  • 33

  • 34

  • 35

  • 36

  • 37

  • 38

  • 39

  • 40

  • 41

  • 42

  • 43

  • 44

  • 45

  • 46

  • 47

  • 48

  • 49

  • 50

  • 51

  • 52

  • 53

  • 54

  • 55

  • 56

  • 57

  • 58

  • 59

  • 60

  • 61

  • 62

  • 63

  • 64

  • 65

  • 66

  • 67

  • 68

  • 69

  • 70

  • 71

  • 72

  • 73

  • 74

  • 75

4.这是一个工具类方法,提供了本 demo 中所用到的一些方法,其实实际里面的方法都很常见,只不过做了一个封装而已。

public class Utils { private static Set<String> supportTypes = new HashSet<>(); /** * 当getIntent的时候,每种类型写的方式都不一样,所以把每种方式都添加到了Set容器中 */ static void init() { supportTypes.add(int.class.getSimpleName()); supportTypes.add(int[].class.getSimpleName()); supportTypes.add(short.class.getSimpleName()); supportTypes.add(short[].class.getSimpleName()); supportTypes.add(String.class.getSimpleName()); supportTypes.add(String[].class.getSimpleName()); supportTypes.add(boolean.class.getSimpleName()); supportTypes.add(boolean[].class.getSimpleName()); supportTypes.add(long.class.getSimpleName()); supportTypes.add(long[].class.getSimpleName()); supportTypes.add(char.class.getSimpleName()); supportTypes.add(char[].class.getSimpleName()); supportTypes.add(byte.class.getSimpleName()); supportTypes.add(byte[].class.getSimpleName()); supportTypes.add("Bundle"); } /** * 获取元素所在的包名 * @param element * @return */ public static String getPackageName(Element element) { String clazzSimpleName = element.getSimpleName().toString(); String clazzName = element.toString(); return clazzName.substring(0, clazzName.length() - clazzSimpleName.length() - 1); } /** * 判断是否是String类型或者数组或者bundle,因为这三种类型getIntent()不需要默认值 * @param typeName * @return */ public static boolean isElementNoDefaultValue(String typeName) { return (String.class.getName().equals(typeName) || typeName.contains("[]") || typeName.contains("Bundle")); } /** * 获得注解要传递参数的类型 * @param typeName 注解获取到的参数类型 * @return */ public static String getIntentTypeName(String typeName) { for (String name : supportTypes) { if (name.equals(getSimpleName(typeName))) { return name.replaceFirst(String.valueOf(name.charAt(0)), String.valueOf(name.charAt(0)).toUpperCase()) .replace("[]", "Array"); } } return ""; } /** * 获取类的的名字的字符串 * @param typeName 可以是包名字符串,也可以是类名字符串 * @return */ static String getSimpleName(String typeName) { if (typeName.contains(".")) { return typeName.substring(typeName.lastIndexOf(".") + 1, typeName.length()); }else { return typeName; } } /** * 自动生成代码 */ public static void writeToFile(String className, String packageName, MethodSpec methodSpec, ProcessingEnvironment processingEnv, ArrayList<FieldSpec> listField) { TypeSpec genedClass; if(listField == null) { genedClass = TypeSpec.classBuilder(className) .addModifiers(Modifier.PUBLIC, Modifier.FINAL) .addMethod(methodSpec).build(); }else{ genedClass = TypeSpec.classBuilder(className) .addModifiers(Modifier.PUBLIC, Modifier.FINAL) .addMethod(methodSpec) .addFields(listField).build(); } JavaFile javaFile = JavaFile.builder(packageName, genedClass) .build(); try { javaFile.writeTo(processingEnv.getFiler()); } catch (IOException e) { e.printStackTrace(); } } }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

  • 28

  • 29

  • 30

  • 31

  • 32

  • 33

  • 34

  • 35

  • 36

  • 37

  • 38

  • 39

  • 40

  • 41

  • 42

  • 43

  • 44

  • 45

  • 46

  • 47

  • 48

  • 49

  • 50

  • 51

  • 52

  • 53

  • 54

  • 55

  • 56

  • 57

  • 58

  • 59

  • 60

  • 61

  • 62

  • 63

  • 64

  • 65

  • 66

  • 67

  • 68

  • 69

  • 70

  • 71

  • 72

  • 73

  • 74

  • 75

  • 76

  • 77

  • 78

  • 79

  • 80

  • 81

  • 82

  • 83

  • 84

  • 85

  • 86

  • 87

  • 88

  • 89

  • 90

  • 91

  • 92

  • 93

  • 94

  • 95

  • 96

  • 97

  • 98

  • 99

  • 100

  • 101

  • 102

  • 103

5.自定义一个接口,目的是为了把我们需要自动生成的每个java文件的方法都独立出去,使代码更清晰。

/** * Created by zmj on 2017/6/16. * * 定义一个接口,能让每个需要自动生成代码的类都抽象出去 * */ public interface Generator { void genetate(Element typeElement , List<VariableElement> variableElements , ProcessingEnvironment processingEnv); }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

6.编写自动生成文件的格式,生成后的类格式如下:

跳转类格式

上图为本例中的MainActivity$Enter类,如果你想生成一个类,那么这个类的格式和作用肯定已经在你的脑海中有了定型,如果你自己都不知道想要生成啥,那还玩啥。

/** * 这是一个要自动生成跳转功能的.java文件类 * 主要思路:1.使用javapoet生成一个空方法 * 2.为方法加上实参 * 3.方法的里面的代码拼接 * 主要需要:获取字段的类型和名字,获取将要跳转的类的名字 */ public class ActivityEnterGenerator implements Generator{ private static final String SUFFIX = "$Enter"; private static final String METHOD_NAME = "intentTo"; @Override public void genetate(Element typeElement, List<VariableElement> variableElements, ProcessingEnvironment processingEnv) { MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(METHOD_NAME) .addModifiers(Modifier.PUBLIC) .returns(void.class); //设置生成的METHOD_NAME方法第一个参数 methodBuilder.addParameter(Object.class, "context"); methodBuilder.addStatement("android.content.Intent intent = new android.content.Intent()"); //获取将要跳转的类的名字 String name = ""; //VariableElement 主要代表一般字段元素,是Element的一种 for (VariableElement element : variableElements) { //Element 只是一种语言元素,本身并不包含信息,所以我们这里获取TypeMirror TypeMirror typeMirror = element.asType(); //获取注解在身上的字段的类型 TypeName type = TypeName.get(typeMirror); //获取注解在身上字段的名字 String fileName = element.getSimpleName().toString(); //设置生成的METHOD_NAME方法第二个参数 methodBuilder.addParameter(type, fileName); methodBuilder.addStatement("intent.putExtra(\"" + fileName + "\"," +fileName + ")"); //获取注解上的元素 IntentField toClassName = element.getAnnotation(IntentField.class); String name1 = toClassName.value(); if(name != null && "".equals(name)){ name = name1; } //理论上每个界面上的注解value一样,都是要跳转到的那个类名字,否则提示错误 else if(name1 != null && !name1.equals(name)){ processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "同一个界面不能跳转到多个活动,即value必须一致"); } } methodBuilder.addStatement("intent.setClass((android.content.Context)context, " + name +".class)"); methodBuilder.addStatement("((android.content.Context)context).startActivity(intent)"); /** * 自动生成.java文件 * 第一个参数:要生成的类的名字 * 第二个参数:生成类所在的包的名字 * 第三个参数:javapoet 中提供的与自动生成代码的相关的类 * 第四个参数:能够为注解器提供Elements,Types和Filer */ Utils.writeToFile(typeElement.getSimpleName().toString() + SUFFIX, Utils.getPackageName(typeElement), methodBuilder.build(), processingEnv,null); } }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

  • 28

  • 29

  • 30

  • 31

  • 32

  • 33

  • 34

  • 35

  • 36

  • 37

  • 38

  • 39

  • 40

  • 41

  • 42

  • 43

  • 44

  • 45

  • 46

  • 47

  • 48

  • 49

  • 50

  • 51

  • 52

  • 53

  • 54

  • 55

  • 56

  • 57

  • 58

  • 59

  • 60

  • 61

  • 62

  • 63

  • 64

  • 65

当我们定义了跳转的类,那么接下来肯定就是在另一个界面获取传递过来的数据了,参考格式如下,这是本demo中自动生成的MainActivity$Init 类。

获取参数格式

/** * 要生成一个.Java文件,在这个Java文件里生成一个获取上个界面传递过来数据的方法 * 主要思路:1.使用Javapoet生成一个空的的方法 * 2.为方法添加需要的形参 * 3.拼接方法内部的代码 * 主要需要:获取传递过来字段的类型 */ public class ActivityInitFieldGenerator implements Generator { private static final String SUFFIX = "$Init"; private static final String METHOD_NAME = "initFields"; @Override public void genetate(Element typeElement, List<VariableElement> variableElements, ProcessingEnvironment processingEnv) { MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(METHOD_NAME) .addModifiers(Modifier.PROTECTED) .returns(Object.class); ArrayList<FieldSpec> listField = new ArrayList<>(); if(variableElements != null && variableElements.size() != 0){ VariableElement element = variableElements.get(0); //当前接收数据的字段的名字 IntentField currentClassName = element.getAnnotation(IntentField.class); String name = currentClassName.value(); methodBuilder.addParameter(Object.class, "currentActivity"); methodBuilder.addStatement(name + " activity = (" + name + ")currentActivity"); methodBuilder.addStatement("android.content.Intent intent = activity.getIntent()"); } for (VariableElement element : variableElements) { //获取接收字段的类型 TypeName currentTypeName = TypeName.get(element.asType()); String currentTypeNameStr = currentTypeName.toString(); String intentTypeName = Utils.getIntentTypeName(currentTypeNameStr); //字段的名字,即key值 Name filedName = element.getSimpleName(); //创建成员变量 FieldSpec fieldSpec = FieldSpec.builder(TypeName.get(element.asType()),filedName+"") .addModifiers(Modifier.PUBLIC) .build(); listField.add(fieldSpec); //因为String类型的获取 和 其他基本类型的获取在是否需要默认值问题上不一样,所以需要判断是哪种 if (Utils.isElementNoDefaultValue(currentTypeNameStr)) { methodBuilder.addStatement("this."+filedName+"= intent.get" + intentTypeName + "Extra(\"" + filedName + "\")"); } else { String defaultValue = "default" + element.getSimpleName(); if (intentTypeName == null) { //当字段类型为null时,需要打印错误信息 processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "the type:" + element.asType().toString() + " is not support"); } else { if("".equals(intentTypeName)){ methodBuilder.addStatement("this."+ filedName +"= (" +TypeName.get(element.asType())+ ")intent.getSerializableExtra(\"" + filedName + "\")"); }else{ methodBuilder.addParameter(TypeName.get(element.asType()), defaultValue); methodBuilder.addStatement("this."+ filedName +"= intent.get" + intentTypeName + "Extra(\"" + filedName + "\", " + defaultValue + ")"); } } } } methodBuilder.addStatement("return this"); Utils.writeToFile(typeElement.getSimpleName().toString() + SUFFIX, Utils.getPackageName(typeElement), methodBuilder.build(), processingEnv,listField); } }

  • 1

  • 2

  • 3

  • 4

  • 5

  • 6

  • 7

  • 8

  • 9

  • 10

  • 11

  • 12

  • 13

  • 14

  • 15

  • 16

  • 17

  • 18

  • 19

  • 20

  • 21

  • 22

  • 23

  • 24

  • 25

  • 26

  • 27

  • 28

  • 29

  • 30

  • 31

  • 32

  • 33

  • 34

  • 35

  • 36

  • 37

  • 38

  • 39

  • 40

  • 41

  • 42

  • 43

  • 44

  • 45

  • 46

  • 47

  • 48
    自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

写在最后

对程序员来说,很多技术的学习都是“防御性”的。也就是说,我们是在为未来学习。我们学习新技术的目的,或是为了在新项目中应用,或仅仅是为了将来的面试。但不管怎样,一定不能“止步不前”,不能荒废掉。

![
[]


文章以下内容会给出阿里与美团的面试题(答案+解析)、面试题库、Java核心知识点梳理等

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

直到现在。**

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-LLjNvdyq-1712527846274)]

[外链图片转存中…(img-ycRwdPFn-1712527846275)]

[外链图片转存中…(img-VMIVsZGr-1712527846276)]

[外链图片转存中…(img-DhF406ft-1712527846276)]

[外链图片转存中…(img-ayU5aZLJ-1712527846276)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

写在最后

对程序员来说,很多技术的学习都是“防御性”的。也就是说,我们是在为未来学习。我们学习新技术的目的,或是为了在新项目中应用,或仅仅是为了将来的面试。但不管怎样,一定不能“止步不前”,不能荒废掉。

[外链图片转存中…(img-2y8MPfuV-1712527846277)]

[外链图片转存中…(img-ARuxKwr9-1712527846277)]
[]

[外链图片转存中…(img-AVZTiAT6-1712527846277)]
[外链图片转存中…(img-2f2d1H0I-1712527846278)]

文章以下内容会给出阿里与美团的面试题(答案+解析)、面试题库、Java核心知识点梳理等

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值