深入理解Java注解(注解在Android中的应用)

       在许多程序设计语言里,比如 Java C #,依赖注入是一种较流行的设计模式,在 Android开发中也有很多实用的依赖注入框架,可以帮助我们少写一些样板代码,达到各个类之间解耦的目的,因此掌握并深入理解注解对于一个Java工程师是来说是很有必要的事。本篇我们将通过以下几个角度来分析注解的相关知识点。

一 注解

        JDK 5 开始, Java 增加了注解,注解是代码里的特殊标记,这些标记可以在编译、类加载、运行时被读取,并执行相应的处理。通过使用注解,开发人员可以在不改变原有逻辑的情况下,在源文件中嵌入一些补充的信息。代码分析工具、 开发工具和部署工具可以通过这些补充信息进行验证、处理或者进行部署。

1.1 注解分类

       注解分为标准注解和元注解,下面分别介绍它们。

       1. 标准注解,标准注解有以下4种。

      •@Override:对覆盖超类中的方法进行标记,如果被标记的方法并没有实际覆盖超类中的方法,则编

译器会发出错误警告。
       •@Deprecated:对不鼓励使用或者已过时的方法添加注解,当编程人员使用这些方法时,将会在编译
时显示提示信息。
       •@SuppressWarnings:指示编译器去忽略注解中声明的警告。
       •@SafeVarargs: JDK 7 新增,用来声明使用了可变长度参数的方法,其在与泛型类一起使用时不会出现
类型安全问题。

       2. 元注解,除了标准注解,还有元注解,它用来注解其他注解,从而创建新的注解。元注解有以下几种。

       •@Targe:注解所修饰的对象范围。

       •@Inherited: 标记这个注解是继承于哪个注解类

       •@Documented:表示这个注解应该被JavaDoc工具记录(即该注解包含在用户文档中)。

       •@Retention:用来声明注解的保留策略。

       •@Repeatable:JDK 8 新增,允许一个注解在同一声明类型(类、属性或方法)上多次使用。
 
       其中@Targe注解取值是一个ElementType 类型的数组,其中有以下几种取值,对应不同的对象范围。
       • ElementType.TYPE:能修饰类、接口或枚举类型。
       • ElementType.FIELD:能修饰成员变量。
       • ElementType.METHOD:能修饰方法。
       • ElementType.PARAMETER:能修饰参数。
       • ElementType.CONSTRUCTOR:能修饰构造方法。
       • ElementType.LOCAL_VARIABLE:能修饰局部变量。
       • ElementType.ANNOTATION_TYPE:能修饰注解。
       • ElementType.PACKAGE:能修饰包。
       • ElementType.TYPE_PARAMETER:类型参数声明。
       • ElementType.TYPE_USE:使用类型。
       其中 @Retention 注解有 3 种类型,分别表示不同级别的保留策略。
       • RetentionPolicy.SOURCE:源码级注解。注解信息只会保留在.java源码中,源码在编译后,注解信息
被丢弃,不会保留在.class中。
       • RetentionPolicy.CLASS:编译时注解。注解信息会保留在.java 源码以及.class 中。当运行 Java 程序时,
JVM 会丢弃该注解信息,不会保留在 JVM 中。
       • RetentionPolicy.RUNTIME:运行时注解。当运行 Java 程序时, JVM 也会保留该注解信息,可以通过反 射获取该注解信息。

1.2 定义注解

       1.基本定义

       定义新的注解类型使用@interface关键字,这与定义一个接口很像,如下所示:

public @interface AnnotationDemo{
    ......
}

       定义完注解后,就可以在程序中使用该注解:

@AnnotationDemo
public class AnnotationTest{
    ......
}
       2. 定义成员变量
       注解只有成员变量,没有方法。注解的成员变量在注解定义中以“无形参的方法”形式来声明,其“方法名”定义了该成员变量的名字,其返回值定义了该成员变量的类型:
public @interface AnnotationDemo{
    String name();
    int length();
}
      上面的代码定义了两个成员变量,定义了成员变量后,使用该注解时就应该为该注解的成员变量指定值:
public class AnnotationTest{
    @AnnotationDemo(naem = "我是注解名字",length = 6)
    public void test(){
        ......
    }
}
      也可以在定义注解的成员变量时,使用 default 关键字为其指定默认值,如下所示:
public @interface AnnotationDemo{
    String name() default "我是注解名字";
    int length() default 6;
}
       因为注解定义了默认值,所以使用时可以不为这些成员变量指定值,而是直接使用默认值:
public class AnnotationTest{
    @AnnotationDemo
    public void test(){
        ......
    }
}
       3. 定义运行时注解
       可以用 @Retention 来设定注解的保留策略,这 3 个策略的生命周期长度为 SOURCE CLASS < RUNTIME。生命周期短的能起作用的地方生命周期长的一定也能起作用。一般如果需要在运行时去动态 获取注解信息,那只能用RetentionPolicy.RUNTIME;如果要在编译时进行一些预处理操作,比如生成一些辅助代码,就用 RetentionPolicy.CLASS;如果只是做一些检查性的操作,比如@Override 和 @SuppressWarnings,则可选用 RetentionPolicy.SOURCE。当设定为 RetentionPolicy.RUNTIME时,这个注解
就是运行时注解,如下所示:
@Retention(RetentionPolicy.RUNTIME)
public @interface AnnotationDemo {
    String name() default "我是注解名字";
    int length() default 6;
}
       4. 定义编译时注解
       同样地,如果将 @Retention 的保留策略设定为 RetentionPolicy.CLASS,这个注解就是编译时注解,如下 所示:
@Retention(RetentionPolicy.CLASS)
public @interface AnnotationDemo {
    String name() default "我是注解名字";
    int length() default 6;
}
       5. 定义源码时注解 

       同样地,如果将@Retention的保留策略设定为RetentionPolicy.SOURCE,这个注解就是编译时注解,如下 所示:

@Retention(RetentionPolicy.SOURCE)
public @interface AnnotationDemo {
    String name() default "我是注解名字";
    int length() default 6;
}

1.3 注解处理器

       如果没有处理注解的工具,那么注解也不会有什么大的作用。对于不同的注解有不同的注解处理器。 虽然注解处理器的编写会千变万化,但是其也有处理标准,比如:针对运行时注解会采用反射机制处理, 针对编译时注解会采用 AbstractProcessor 来处理。本篇就针对前面讲到的运行时注解和编译时注解来编写注解处理器。
 
       1. 运行时注解处理器
       处理运行时注解需要用到反射机制。首先我们要定义运行时注解,如下所示:
@Documented
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Get {
    String value() default "";
}

      上面的代码定了@Get注解,使用了@TargetElementType.METHOD),意味着Get注解应用于方法。接下来应用该注解,如下所示:

public class AnnotationTest {
    @Get(value = "欢迎学习Java注解")
    public String getMessage() {
        return "";
    }
}
      上面的代码为 @Get 的成员变量赋值。接下来写一个简单的注解处理器,如下所示:
public class AnnotationProcessor {
    public static void processor(){
        Method[] methods = AnnotationTest.class.getDeclaredMethods();
        for (Method method:methods) {
            Get get = method.getAnnotation(Get.class);
            if(get!=null){
                System.out.println("get.value() = " + get.value());
            }
        }
    }
}
      上面的代码用到了两个反射方法: getDeclaredMethods getAnnotation, 调用 getAnnotation 方法返回指定类型的注解对象,也就是Get 。最后调用 Get value 方法返回从 Get对象中提取元素的值。在MainActivity中调用AnnotationProcessor 的processor()方法,代码如下:
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AnnotationProcessor.processor();
    }
}

      输出结果如下:

      2. 编译时注解处理器
   ( 1 )定义注解 这里首先在项目中新建一个Java Library 来专门存放注解,这个 Library 名为 annotations 。接下来定义注
解,如下所示:
@Documented
@Target(value = ElementType.FIELD)
@Retention(value = RetentionPolicy.CLASS)
public @interface BindView {
    int value() default 1;
}
      上面代码中定义的注解类似于 ButterKnife @BindView 注解。
 
    ( 2 )编写注解处理器
      我们在项目中再新建一个Java Library 来存放注解处理器,这个 Library 名为 processor 。我们来配置 processor库的 build.gradle:
apply plugin: 'java-library'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project(path: ':annotaions')
}

sourceCompatibility = "1.7"
targetCompatibility = "1.7"
      接下来编写注解处理器 ClassProcessor ,它继承 AbstractProcessor ,如下所示:
public class ClassProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        super.init(processingEnvironment);
    }

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        ...
        return false;
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> annotations = new LinkedHashSet<>();
        annotations.add(BindView.class.getCanonicalName());
        return annotations;
    }

    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.latestSupported();
    }
}
       process 方法的实现会在后文讲到,这里首先分别介绍这 4 个方法的作用。
       • init:被注解处理工具调用,并输入 ProcessingEnviroment 参数。 ProcessingEnviroment 提供很多有用的 工具类,比如Elements Types Filer Messager 等。
       • process:相当于每个处理器的主函数 main (),在这里写你的扫描、评估和处理注解的代码,以及生成Java 文件。输入参数 RoundEnviroment ,可以让你查询出包含特定注解的被注解元素。
       • getSupportedAnnotationTypes:这是必须指定的方法,指定这个注解处理器是注册给哪个注解的。注 意,它的返回值是一个字符串的集合,包含本处理器想要处理的注解类型的合法全称。
       • getSupportedSourceVersion:用来指定你使用的 Java 版本,通常这里返回 SourceVersion.latestSupported()。
 
       在 Java 7 以后,也可以使用注解来代替 getSupportedAnnotationTypes 方法和 getSupportedSourceVersion 方 法,如下所示:
@SupportedAnnotationTypes(value = "com.lx.annotaions.BindView")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ClassProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        super.init(processingEnvironment);
    }

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        ...
        return false;
    }
}
       但是考虑到 Android 兼容性的问题,这里不建议采用这种注解的方式。接下来编写还未实现的 process 方法,如下所示:
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
    Messager messager = processingEnv.getMessager();
    for (Element element : roundEnvironment.getElementsAnnotatedWith(BindView.class)) {
        if (element.getKind() == ElementKind.FIELD) {
            messager.printMessage(Diagnostic.Kind.NOTE, "printMessage:"
                    + element.toString());
        }
    }
    return false;
}
       这里用到 Messager printMessage 方法来打印出注解修饰的成员变量的名称,这个名称会在 Android Studio的 Gradle Console 窗口中打印出来。
 
     ( 3 )注册注解处理器
       为了能使用注解处理器,需要用一个服务文件来注册它,我们 可以使用 Google 开源的 AutoService ,它用来帮助开发者注册注解处理器。首先我们添加该开源库,在processor build.gradle中 直接添加如下代码:
dependencies {
    ......
    implementation('com.google.auto.service:auto-service:1.0-rc2')
}
       最后在注解处理器 ClassProcessor 中添加 @AutoService Processor.class )就可以了:
@AutoService(Processor.class)
public class ClassProcessor extends AbstractProcessor {
    ......
}
     ( 4 )应用注解
       接下来在我们的主工程项目( app )中引用注解。首先要在主工程项目的 build.graldle中引用 annotations 和processor 这两个库:
dependencies {
    ......
    implementation project(path: ':processor')
    implementation project(path: ':annotaions')
}

       接下来在MainActivity中应用注解,如下所示:

public class MainActivity extends AppCompatActivity {

    @BindView(value = R.id.tv_text)
    TextView tv_text;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}
      最后,我们先 Clean Project Make Project ,在 Gradle Console 窗口中打印的结果如图 所示。

       可以发现编译时会打印出 @BindView 注解修饰的成员变量名: tv_text
 

注解搭配Javapoet在Android的应用

      这里通过ButterKinfe的@BindView的使用做讲解,

     1. 使用运行时注解的方式

    (1)定义@BindView注解,代码如下:

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BindView {
    int value();
}

  (2)应用注解,在MainActivity中使用注解,代码如下

public class MainActivity extends AppCompatActivity {
    @BindView(value = R.id.tv_text)
    TextView tv_text;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

    }
}

    (3)定义注解处理器,代码如下

public class ButterKnife {
    public static void bind(@Nullable Activity activity){
        Field[] fields = activity.getClass().getDeclaredFields();
        for (int i = 0; i < fields.length; i++) {
           Field field = fields[i];
           if(field.isAnnotationPresent(BindView.class)){
               BindView bindView = field.getAnnotation(BindView.class);
               int resId = bindView.value();
               View view = activity.findViewById(resId);
               field.setAccessible(true);
               Class<?> targetType = field.getType();
               Class<? extends View> viewType = view.getClass();
               if (!targetType.isAssignableFrom(viewType)) {
                   continue;
               }
               try {
                   field.set(activity, view);
               } catch (IllegalAccessException e) {
                   e.printStackTrace();
               }
           }
        }
    }
}

      (4)使用,在MainActivity中调用注解处理器完成View的绑定,代码如下

 @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ButterKnife.bind(this);
        
        tv_text.setText("欢迎学习Java注解");
    }

        运行程序之后,屏幕显示“欢迎学习Java注解”,并没有报tv_text的空指针异常,说明绑定成功。

        由于是运行时处理注解,而且需要用到反射,导致效率不高,因此建议使用编译时注解,下面讲解如果使用编译时注解完成View的绑定。

        2. 使用编译时注解的方式,

           此处代码接着第一节中第三小节中2.编译时注解处理器”的代码接着往下研究

          (1)在annotations  module 中添加ViewBinder接口,代码如下

public interface ViewBinder<T> {
    void bind(T target);
}

         (2)在processor module 中修改ClassProcessor类中的process方法,代码如下

@Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        // 输出打印工具
        Messager messager = processingEnv.getMessager();
        // 拿到整个模块中(app)用到BindView的注解的节点
        Set<? extends Element> elementsAnnotatedWith = roundEnvironment.getElementsAnnotatedWith(BindView.class);
        // key -- activity名字
        Map<String, List<VariableElement>> map = new HashMap<>();
        for (Element element : elementsAnnotatedWith) {
            if (element.getKind() == ElementKind.FIELD) {
                // 获取到成员变量的节点,也就是控件
                VariableElement variableElement = (VariableElement) element;
                // 获取到activity名字
                String activityName = variableElement.getEnclosingElement().getSimpleName().toString();
                messager.printMessage(Diagnostic.Kind.NOTE, "printMessage:" + activityName);
                List<VariableElement> variableElements = map.get(activityName);
                if (variableElements == null) {
                    variableElements = new ArrayList<>();
                    map.put(activityName, variableElements);
                }
                variableElements.add(variableElement);
            }
        }

        if (map.size() > 0) {
            Writer writer = null;
            Iterator<String> iterator = map.keySet().iterator();
            while (iterator.hasNext()) {
                String activityName = iterator.next();
                // 得到的是activity对应的控件
                List<VariableElement> variableElements = map.get(activityName);
                // 通过空间的成员变量节点,获取到他的上一个节点,也就是类节点
                Element enclosingElement = variableElements.get(0).getEnclosingElement();
                // 通过类节点获取到包名
                String packageName = processingEnv.getElementUtils().getPackageOf(enclosingElement).toString();

                try {
                    // 生成文件的对象
                    Filer filer = processingEnv.getFiler();
                    // 生成java文件
                    JavaFileObject sourceFile = filer.createSourceFile(packageName + "."
                            + activityName + "_ViewBinding");
                    writer = sourceFile.openWriter();
                    writer.write("package " + packageName + ";\n");
                    writer.write("import " + packageName + "." + activityName + ";\n");
                    writer.write("import " + ViewBinder.class.getName() + ";\n");
                    writer.write("public class " + activityName + "_ViewBinding<T extends " + activityName +
                            "> implements ViewBinder<" + activityName + ">{\n");
                    writer.write("    public void bind(" + activityName + " target){\n");
                    // 遍历所有成员变量 添加代码
                    for (VariableElement variableElement : variableElements) {
                        // 获取到控件的名字
                        String variableName = variableElement.getSimpleName().toString();
                        // 获取到控件的id
                        int resId = variableElement.getAnnotation(BindView.class).value();
                        // 获取到这个控件的类型
                        TypeMirror typeMirror = variableElement.asType();
                        writer.write("        target." + variableName + "=(" + typeMirror + ")target.findViewById(" + resId + ");\n");
                    }
                    writer.write("    }\n}\n");
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    if (writer != null) {
                        try {
                            writer.flush();
                            writer.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
        return false;
    }

       然后,我们先Clean ProjectMake Project,会生成MainAcitivty_ViewBinding.java文件,内容如下图

      (3)在app module中创建ButterKnife.java 文件,调用我们生成的MainActivity_ViewBinding中的bind方法,完成View的绑定,代码如下

public class ButterKnife {
    public static void bind(@Nullable Activity activity) {
        String name = activity.getClass().getName() + "_ViewBinding";
        try {
            Class<?> clazz = Class.forName(name);
            ViewBinder binder = (ViewBinder)clazz.newInstance();
            binder.bind(activity);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        }
    }
}

    (4)最后在MainActivity中使用@BindView注解,然后调用ButterKnife的bind方法,代码如下

public class MainActivity extends AppCompatActivity   {
    @BindView(value = R.id.tv_text)
    TextView tv_text;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ButterKnife.bind(this);

        tv_text.setText("欢迎学习Java注解");
    }

}

       运行程序之后,屏幕显示“欢迎学习Java注解”,并没有报tv_text的空指针异常,说明绑定成功。

       在这个过程中,我们使用的Filer通过拼接的形式创建的MainActivity_ViewBinding.java文件,这里很容易出错,所以我们接下来使用Javapoet来完成MainActivity_ViewBinding.java文件的生成。

        3. 使用Javapoet完成代码生成

   JavaPoet是一个动态生成代码的开源项目,在某些情况下具有特殊用处。Github地址:https://github.com/square/javapoet
Github上有JavaPoet的官方教程,权威且全面,因为太好了

    Javapoet的优点

  • JavaPoet是一款可以自动生成Java文件的第三方依赖
  •  简洁易懂的API,上手快
  •  让繁杂、重复的Java文件,自动化生成,提高工作效率,简化流程,不容易出错

    (1) Javapoet的使用,在processor module的build.gradle中添加依赖,代码如下

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project(path: ':annotaions')
    implementation('com.google.auto.service:auto-service:1.0-rc2')
    // 引入生成代码的库
    implementation 'com.squareup:javapoet:1.11.1'
}

    (2)在processor module 中修改ClassProcessor类中的process方法,代码如下

 @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        // 输出打印工具
        Messager messager = processingEnv.getMessager();
        // 拿到整个模块中(app)用到BindView的注解的节点
        Set<? extends Element> elementsAnnotatedWith = roundEnvironment.getElementsAnnotatedWith(BindView.class);
        // key -- activity名字
        Map<String, List<VariableElement>> map = new HashMap<>();
        for (Element element : elementsAnnotatedWith) {
            if (element.getKind() == ElementKind.FIELD) {
                // 获取到成员变量的节点,也就是控件
                VariableElement variableElement = (VariableElement) element;
                // 获取到activity名字
                String activityName = variableElement.getEnclosingElement().getSimpleName().toString();
                messager.printMessage(Diagnostic.Kind.NOTE, "printMessage:" + activityName);
                List<VariableElement> variableElements = map.get(activityName);
                if (variableElements == null) {
                    variableElements = new ArrayList<>();
                    map.put(activityName, variableElements);
                }
                variableElements.add(variableElement);
            }
        }

        if (map.size() > 0) {
            Writer writer = null;
            Iterator<String> iterator = map.keySet().iterator();
            while (iterator.hasNext()) {
                String activityName = iterator.next();
                // 得到的是activity对应的控件
                List<VariableElement> variableElements = map.get(activityName);
                // 通过空间的成员变量节点,获取到他的上一个节点,也就是类节点
                Element enclosingElement = variableElements.get(0).getEnclosingElement();
                // 通过类节点获取到包名
                String packageName = processingEnv.getElementUtils().getPackageOf(enclosingElement).toString();

                ClassName activityClassName = ClassName.bestGuess(activityName);
                ClassName viewBuild = ClassName.get(ViewBinder.class.getPackage().getName(), ViewBinder.class.getSimpleName());

                //构造一个方法
                MethodSpec.Builder method = MethodSpec.methodBuilder("bind")      //名称
                        .addModifiers(Modifier.PUBLIC)                         //修饰
                        .returns(void.class)                                   //返回
                        .addAnnotation(Override.class)
                        .addParameter(activityClassName, "target", Modifier.FINAL);              //参数

                // 遍历所有成员变量 添加方法体
                for (VariableElement variableElement : variableElements) {
                    // 获取到控件的名字
                    String variableName = variableElement.getSimpleName().toString();
                    // 获取到控件的id
                    int resId = variableElement.getAnnotation(BindView.class).value();
                    // 获取到这个控件的类型
                    TypeMirror typeMirror = variableElement.asType();
                    method.addStatement("target.$L=($L)target.findViewById($L)",variableName,typeMirror,resId);
                }

                //构造一个类
                TypeSpec Class_ViewBinding = TypeSpec.classBuilder(activityName + "_ViewBinding")  //名称
                        .addModifiers(Modifier.PUBLIC)                         //修饰
                        .addMethod(method.build())                                        //方法
                        .addTypeVariable(TypeVariableName.get("T",activityClassName))
                        .addSuperinterface(ParameterizedTypeName.get(viewBuild,activityClassName))
                        .build();


                //生成一个Java文件
                JavaFile javaFile = JavaFile.builder(packageName, Class_ViewBinding)
                        .build();
                try {
                    //将java写到当前项目中
                    javaFile.writeTo(System.out);    //打印到命令行中
                    javaFile.writeTo(processingEnv.getFiler());
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return false;
    }

       然后,我们先Clean ProjectMake Project,会生成MainAcitivty_ViewBinding.java文件,内容如下图

     后续操作跟用Filer生成MainAcitivty_ViewBinding.java文件一样了,这里就不在说明了。

    代码下载

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值