Java 注解

Java的注解是个很神奇的东西,它既可以帮你生成代码,又可以结合反射来在运行时获得注解标识的对象,进行逻辑处理,它能帮助我们完成很多很多不可能完成的任务,这回我们就来一起来了解下它。

一、什么可以被注解修饰

Java中的类、方法、变量、参数、包都可以被注解,在java8中注解可以被运用到任何地方。比如:

myString = (@NonNull String) str;
class UnmodifiableList<T> implements @Readonly List<@Readonly T> { ... }
new @Interned MyObject();

需要注意的是,类型注解只是语法而不是语义,并不会影响java的编译时间,加载时间,以及运行时间。在Java8没有普及的情况下,本文仅仅讨论在jdk1.7中可被用于实践的注解方案。

 

二、注解的类型

2.1 引子

我们先从我们最熟悉的@Override说起

复制代码
/**
 * Annotation type used to mark methods that override a method declaration in a
 * superclass. Compilers produce an error if a method annotated with @Override
 * does not actually override a method in a superclass.
 *
 * @since 1.5
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
复制代码

我们注意到了@Target和@Retention这两个注解,这两个家伙就是专门用来修饰注解的注解,看起来吊吊的,但在实际开发中我们都不会去用到他们,所以我们不是很熟悉。但今天我们已经开始学习注解了,姑且就和他们打个招呼吧,先照猫画虎写一个自己的注解,注解的名字叫做classInfo:

复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface ClassInfo {

 String value() default "default";

}
复制代码

先不管这个注解有什么用,我们就看这个注解类的标识,其实上面两行标识就是起一个说明作用,和他们一样的还有@Document等。

@Documented 是否会保存到 Javadoc 文档中

@Retention 保留时间,可选值 SOURCE(源码时),CLASS(编译时),RUNTIME(运行时),默认为 CLASS。

如果值为 SOURCE 大都为 Mark Annotation,这类 Annotation 大都用来校验,比如 Override, Deprecated, SuppressWarnings

@Target 来指定这个注解可以修饰哪些元素,如 TYPE, METHOD, CONSTRUCTOR, FIELD, PARAMETER 等,未标注则表示可修饰所有类型

@Inherited 是否可以被继承,默认为 false

 

2.2 详细说明

我们来详细说说看:

@Documented 这个东西如果加在了注解上面,就会在生成java doc时有相关注解的文档,在小项目开发过程中,这个注解意义不大,可以忽略。

@Retention 它里面的值都是以RetentionPolicy开头的,来看看源码是怎么写的:

复制代码
public enum RetentionPolicy {
    /**
     * Annotation is only available in the source code.
     */
    SOURCE,
    /**
     * Annotation is available in the source code and in the class file, but not
     * at runtime. This is the default policy.
     */
    CLASS,
    /**
     * Annotation is available in the source code, the class file and is
     * available at runtime.
     */
    RUNTIME
}
复制代码
  • 如果是SOURCE,注解保留范围为源代码,在编译时将会被编译器丢弃。这类 Annotation 大都用来校验,比如 Override, Deprecated, SuppressWarnings。
  • 如果是CLASS,这个注解保留范围是源代码和类文件中,但并非作用于运行时,所以JVM不会识别此。如果你在自定义注解时,不写@Retention,默认就是CLASS的。这类的注解和SOURCE的注解都可以配合AbstractProcessor进行使用,用于在编译时进行自动处理一些事物或者生成一些文件。
  • 如果是RUNTIME,这个注解的保留范围是源代码、类文件和运行时,这类的注解一般会和反射配合使用。可以在运行时通过反射查看被这个注解标识的方法,然后得到被标识的元素,接着进行处理。
复制代码
        final Method[] allMethods = clazz.getDeclaredMethods();
            for (Method method : allMethods) {
                // 根据注解来解析函数
                Subscriber annotation = method.getAnnotation(Subscriber.class);
       }
复制代码

上面这段代码会遍历这个类中被标识了@Subscriber的方法。但如果我们把@Subscriber设定为@Retention(RetentionPolicy.CLASS),这时这个注解就不会被保留到运行时的代码中了,因此我们用反射就获取不到,就会报出如下错误。

@Target 指定注解可以被标识于哪种Java元素上,指定类型(ElementType)如下:

  • ElementType.ANNOTATION_TYPE 注释类型声明。
  • ElementType.CONSTRUCTOR 构造方法声明。
  • ElementType.FIELD 字段声明(包括枚举常量)。
  • ElementType.LOCAL_VARIABLE 局部变量声明。
  • ElementType.METHOD 方法声明。
  • ElementType.PACKAGE 包声明。
  • ElementType.PARAMETER 参数声明。
  • ElementType.TYPE 类、接口(包括注释类型)或枚举声明。

这个注解标识仅仅做个标识,没有任何代码逻辑,它的目的是避免使用者随便标识注解,从而造成处理注解时出现错误。

 

三、自定义编译时注解

3.1 编译时注解

所谓编译时注解就是在你写代码时,就能产生作用的注解,一旦程序运行成apk,你的注解就没用了,所以它的生命周期在于你写代码到编译的过程之间。我们先来看看一个Android特有的注解方式,这种注解方式属于特殊的编译时注解。

复制代码
   public static final int VANILLA = 0;
    public static final int CHOCOLATE = 1;
    public static final int STRAWBERRY = 2;
 
    @IntDef({VANILLA, CHOCOLATE, STRAWBERRY})
    public @interface Flavour {
    }
复制代码

首先我们定义了三个常量,然后定义了一个注解 @Flavour,在这个注解上用@IntDef标识了这个注解的作用。说明用@Flavour标识的变量,必须是0,1,2这三个int类型值,是不是很像枚举类型呢?其实它就是为了替代枚举而出现的(Android中枚举的效率稍低)。在使用的时候,我们只需要像如下标识,编译器就会自动进行判断,从而提升代码质量。

复制代码
  @Flavour
    public int getFlavour() {
        return flavour;
    }
 
    public void setFlavour(@Flavour int flavour) {
        this.flavour = flavour;
    }
复制代码

如果在使用的时候传入了错误的值(不是0,1,2),编译器自动会提示警告:

好了,上面仅仅是小试牛刀,现在我们开始真正写一个编译时注解。

我希望有个注解可以帮助我们自动生成网络请求的代码,之前我们的网络请求代码是这样的:

复制代码
public Observable post041(String create_time, String user_name) {
        HashMap<String, String> map = new HashMap<>();
        map.put("create_time", create_time);
        map.put("user_name", user_name);
        map.put("name", "kale");
        map.put("user", "aaaa3");

        return (Observable) mHttpRequest.doPost("http://image.baidu.com", map, null);
}
复制代码

这种代码就是模板式代码,注解最适合干掉这样的代码了。高兴之余,先分析下需求,拆分可变部分和不可变部分是主要需求,我们的可变部分在于定义url,请求的参数,其中包含默认的请求参数还有从外部传入的请求参数,还有进行json解析的model类、是get请求还是post请求。

分析完毕,分分钟定义一个注解:

复制代码
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.METHOD)
public @interface HttpPost {

    String url();

    Class<?> model() default HttpProcessor.class;
}
复制代码

这个注解中包含两个方法,一个是url,这个是必须传入的(使用者不写就会报错)。如果你这个请求没有解析的model,那么就不用传入model对象,所以这里给一个默认的model对象。这个注解我希望出现在java doc里面,所以加上了@documented。这个注解标识的是java中的method,所以写了method,而且我希望它仅仅是在编译时有效,所以用了source。

现在定义好了,开始使用:

 @HttpPost(url =  "http://image.baidu.com?user=aaaa3&name=kale", model = String.class)
 Observable post041(String create_time, String user_name);

将url写入注解中,并且定义好model(如果不需要json解析,可以不定义),如果是必须传的参数,就和url写到一起,把需要从外部得到的注解写到方法的参数中。现在两行代码写了一个网络请求,是不是很简单呢?现在api、请求方法体、解析model的聚合度变得很高了。注意哦,现在调用这个方法其实根本不起作用,因为我们还没有去解析这个注解呢,下面来说说怎么解析。

 

3.2 建立解析编译时注解类

首先在as中建立一个java的lib,然后在这个lib中开始写解析类。我建立了HttpProcessor这个类,这个类继承了AbstractProcessor这个类,它会强制你实现process这个方法,这样HttpProcessor就有了解析注解的能力了。

 

复制代码
public class HttpProcessor extends AbstractProcessor{

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        return false;
    }
}
复制代码

 

接着,我在头部定义一些配置代码:

@SupportedAnnotationTypes({"kale.net.http.annotation.HttpPost"})
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class HttpProcessor extends AbstractProcessor {

上面两行代码定义了这个类能处理的注解类,并且标识了基于的java版本。写完了之后千万不要忘记了把这个注解处理类注册到项目中,注册的方法就是在resource/META-INF/services中建立一个javax.annotation.processing.Processor文件,在里面写上这个注解处理类的全名。如果你有多个注解处理类,请用回车分割。

 

3.2 解析注解

我们为了方便首先在init时定义好一个工具类,以后会用到。

复制代码
  private Elements elementUtils;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        elementUtils = processingEnv.getElementUtils();
    }
复制代码

然后,在process方法中开始处理传入的注解对象。需要注意的是,这个process方法会被调用多次,调用次数取决于你这个注解处理类能处理的注解个数。

复制代码
@SupportedAnnotationTypes({"kale.net.http.annotation.HttpPost"})
@SupportedSourceVersion(SourceVersion.RELEASE_7)
public class HttpProcessor01 extends AbstractProcessor{

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // 传入当前注解处理器可以处理的注解元素
        for (TypeElement te : annotations) {
            // 找到被标识了可处理的注解的元素
            for (Element e : roundEnv.getElementsAnnotatedWith(te)) {
                if (e.getKind() == ElementKind.INTERFACE) {
                    // 如果是接口
                    TypeElement ele = (TypeElement) e;
                    // ……
                    
                    
                } else if (e.getKind() == ElementKind.METHOD) {
                    // 如果是方法
                    ExecutableElement method = (ExecutableElement) e;
                    // ……
                   
                }
            }
        }
        return true;
    }
}
复制代码

 

代码有些复杂,我分布讲解:

复制代码
@Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // 传入当前注解处理器可以处理的注解元素
        for (TypeElement te : annotations) {
            // 找到被标识了可处理的注解的元素
            for (Element e : roundEnv.getElementsAnnotatedWith(te)) {
复制代码

在这个for循环中,我们可以利用 e.getKind() 这个方法来判断注解标识的是什么对象,如果是方法就采用方法的处理逻辑,如果标识的是类就采用类的处理逻辑,如果是接口就用接口的,根据需要进行处理即可。

复制代码
           if (e.getKind() == ElementKind.METHOD) {
                    // 如果是方法
                    ExecutableElement method = (ExecutableElement) e;
                    if (method.getAnnotation(HttpPost.class) != null) {
                        handlerHttp(mStringBuilder, e, method, true);
                   }
                }
复制代码

进入if块后,我首先将e进行了强制转换,为啥要强制转换呢,因为e是标识被@httpPost标识的元素对象,但目前程序不知道它是什么类型的。我们通过之前的判断,知道它现在是方法对象,所以在这里就强转了。那么如果是接口改强转成什么呢?如果是类应该强转什么呢?来看下面的说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
<span style= "font-size: 18px;" > package  com.example;    // PackageElement
 
public  class  Foo {        // TypeElement
 
     private  int  a;      // VariableElement
     private  Foo other;  // VariableElement
 
     public  Foo () {}    // ExecuteableElement
 
     public  void  setA (  // ExecuteableElement
                      int  newA   // TypeElement
               ) {}
}</span>

如果注解标识到了类中,就强转为TypeElement;

如果标识的是变量,就强转VariableElement;

如果标识内部类或者方法,强转ExecuteableElement;

如果标识方法中的参数,就强转为TypeElement。

说明:这里的每个强转后的对象都有自己好用的api,我就不详细说明了,大家可以在用的时候进行测试。

 

现在我们必须换个角度来看源代码,它只是结构化的文本,他不是可运行的。你可以想象它就像你将要去解析的XML文件一样(或者是编译器中抽象的语法树)。就像XML解释器一样,有一些类似DOM的元素。你可以从一个元素导航到它的父或者子元素上。

举例来说,假如你有一个代表public class Foo类的TypeElement元素,你可以遍历它的孩子,如下:

TypeElement fooClass = ... ;  
for (Element e : fooClass.getEnclosedElements()){ // iterate over children  
    Element parent = e.getEnclosingElement();  // parent == fooClass
}

正如你所见,Element代表的是源代码。TypeElement代表的是源代码中的类型元素,例如类。然而,TypeElement并不包含类本身的信息。你可以从TypeElement中获取类的名字,但是你获取不到类的信息,例如它的父类。这种信息需要通过TypeMirror获取。你可以通过调用elements.asType()获取元素的TypeMirror

 

好,现在我们回过头来扩充上面的那段代码:

复制代码
@Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // 传入当前注解处理器可以处理的注解元素
        for (TypeElement te : annotations) {
            // 找到被标识了可处理的注解的元素
            for (Element e : roundEnv.getElementsAnnotatedWith(te)) {
                if (e.getKind() == ElementKind.INTERFACE) {
                    // 如果是接口
                    TypeElement ele = (TypeElement) e;
                    if (ele.getAnnotation(ApiInterface.class) != null) {
                        String interFaceName = ele.getQualifiedName().toString();
                        mStringBuilder = createClsBlock(interFaceName, mStringBuilder);
                    } else {
                        fatalError("Should use " + ApiInterface.class.getName());
                    }
                } else if (e.getKind() == ElementKind.METHOD) {
                    // 如果是方法
                    ExecutableElement method = (ExecutableElement) e;
                    if (method.getAnnotation(HttpPost.class) != null) {
                        handlerHttp(mStringBuilder, e, method, true);
                    } else {
                        handlerHttp(mStringBuilder, e, method, false);
                    }
                }
            }
        }
        mStringBuilder.append("\n}");
        createClassFile(PACKAGE_NAME, CLASS_NAME, mStringBuilder.toString());
        return true;
    }
复制代码

如果是被标识为@HttpPost的方法体,那么就开始进入handlerHttp(…)中了。这个方法的代码和注解其实没啥关系了,就是做些字符串的拼接,拼接完毕后生成一个类文件。

复制代码
public void handlerHttp(StringBuilder sb, Element ele, ExecutableElement method, boolean isPost) {
        String url;
        String modelName;
        if (isPost) {
            HttpPost httpPost = ele.getAnnotation(HttpPost.class);
            url = httpPost.url();
            try {
                modelName = httpPost.model().getName();
            } catch (MirroredTypeException ex) {
                modelName = ex.getTypeMirror().toString();
            }
        } else {
            HttpGet httpGet = ele.getAnnotation(HttpGet.class);
            url = httpGet.url();
            try {
                modelName = httpGet.model().getName();
            } catch (MirroredTypeException ex) {
                modelName = ex.getTypeMirror().toString();
            }
        }

        if (url.equals("")) {
            fatalError("Url is null");
            return;
        }
        log("Working on method: " + method.getSimpleName());
        Map<String, String> defaultParams = UrlUtil.getParams(url);
        List<String> customParams = getCustomParams(method);
        if (modelName.equals(HttpProcessor.class.getName())) {
            modelName = null;
        }

        if (modelName != null && modelName.contains("<any?>")) {
            modelName = modelName.replace("<any?>", UrlUtil.url2packageName(url));
        }
        url = UrlUtil.getRealUrl(url);
        if (isPost) {
            sb.append(createPostMethodBlock(method.getSimpleName().toString(), url, defaultParams, customParams, modelName));
        } else {
            sb.append(createGetMethodBlock(method.getSimpleName().toString(), url, defaultParams, customParams, modelName));
        }
        log("Parse method: " + method.getSimpleName() + " completed");
    }
复制代码

 

3.3 生成文件的方法

还记得我们在init中产生的工具类么,现在我们需要靠它来生成文件了。传入类包名、类名和类内部的信息就行。

复制代码
private void createClassFile(String PACKAGE_NAME, String clsName, String content) {
        //PackageElement pkgElement = elementUtils.getPackageElement("");
        TypeElement pkgElement = elementUtils.getTypeElement(PACKAGE_NAME);

        OutputStreamWriter osw = null;
        try {
            JavaFileObject fileObject = processingEnv.getFiler().createSourceFile(PACKAGE_NAME + "." + clsName, pkgElement);
            OutputStream os = fileObject.openOutputStream();
            osw = new OutputStreamWriter(os, Charset.forName("UTF-8"));
            osw.write(content, 0, content.length());

        } catch (IOException e) {
            e.printStackTrace();
            //fatalError(e.getMessage());
        } finally {
            try {
                if (osw != null) {
                    osw.flush();
                    osw.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
                fatalError(e.getMessage());
            }
        }
    }
复制代码

 

3.4 打log的方法

注解中的log有个自己的api,封装一下就是这样了:

复制代码
   private void log(String msg) {
        if (processingEnv.getOptions().containsKey("debug")) {
            processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, TAG + msg);
        }
    }

    private void fatalError(String msg) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, TAG + " FATAL ERROR: " + msg);
    }
复制代码

关于详细的代码可以参考:https://github.com/tianzhijiexian/HttpAnnotation

关于用编译时注解写工厂方法的代码:http://www.codeceo.com/article/java-annotation-processor.html

类似的用注解写网络框架的文章:http://segmentfault.com/a/1190000002785541

 

四、运行时注解

有时候一些注解会配合反射进行调用,比如事件总线。

复制代码
/**
 * 事件接收函数的注解类,运用在函数上
 *
 * @author mrsimple
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Subscriber {

    /**
     * 事件的tag,类似于BroadcastReceiver中的Action,事件的标识符
     */
    String tag();

}
复制代码

这种注解必须把生命周期写到Runtime,否则反射就获取不到它了。写好了后,就可以通过反射来得到它标记的元素,从而进行处理:

复制代码
public void registerMethods(Object subscriber) {
        Class<?> clazz = subscriber.getClass();
        // 查找类中符合要求的注册方法,直到Object类
        while (clazz != null && !isSystemCls(clazz.getName())) {
            final Method[] allMethods = clazz.getDeclaredMethods();
            for (Method method : allMethods) {
                // 根据注解来解析函数
                Subscriber annotation = method.getAnnotation(Subscriber.class);
                if (annotation != null) {
                    String tag = annotation.tag();
                    // 获取方法的tag
                    if (!TextUtils.isEmpty(tag)) {
                        SubscriberBean bean = new SubscriberBean();
                        bean.setSubscriber(subscriber);
                        bean.setMethod(method);
                        if (subscriberMap.containsKey(tag)) {
                            // 如果已经有这个tag了,那么说明已经有人注册了,所以可以直接添加到注册列表中
                            subscriberMap.get(tag).add(bean);
                        } else {
                            // 如果之前没有这个tag,那么建立新的注册列表
                            List<SubscriberBean> list = new ArrayList<>();
                            list.add(bean);
                            subscriberMap.put(tag, list);
                  
                        }
                    }
                }
            } // end for
            // 获取父类,以继续查找父类中符合要求的方法
            clazz = clazz.getSuperclass();
        }
    }
复制代码

 

如果想用注解干掉findviewById也是可以的。先定义一个注入的注解:

复制代码
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface InjectView 
{
  //id就是控件id,在某一个控件上使用注解标注其id
  int id() default -1;
}
复制代码

在activity中进行反射查找,找到了后利用注解自动调用findviewById即可:

复制代码
public class MainActivity extends Activity 
{
  public static final String TAG=MainActivity;
  //标注TextView的id
  @InjectView(id=R.id.tv_img)
  private TextView mText;
   
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    try {
      autoInjectAllField(this);
    } catch (IllegalAccessException e) {
    } catch (IllegalArgumentException e) {
    }
     
    if(mText!=null)
      mText.setText(Hello Gavin);
  }
   
  public void autoInjectAllField(Activity activity) throws IllegalAccessException, IllegalArgumentException
  {
    //得到Activity对应的Class
    Class clazz=this.getClass();
    //得到该Activity的所有字段
    Field []fields=clazz.getDeclaredFields();
    Log.v(TAG, fields size-->+fields.length);
    for(Field field :fields)
    {
      //判断字段是否标注InjectView
      if(field.isAnnotationPresent(InjectView.class))
      {
        Log.v(TAG, is injectView);
        //如果标注了,就获得它的id
        InjectView inject=field.getAnnotation(InjectView.class);
        int id=inject.id();
        Log.v(TAG, id--->+id);
        if(id>0)
        {
          //反射访问私有成员,必须加上这句
          field.setAccessible(true);
          //然后对这个属性赋值
          field.set(activity, activity.findViewById(id));
        }
      }
    }
  }
 
}
复制代码

 如果你活学活用了这一特性,那么你完全可以用它做任何事情。假如你厌倦了在Android程序中打出一个完整的静态限定常量,比如:

 

public class CrimeActivity {
    public static final String ACTION_VIEW_CRIME = 
        “com.bignerdranch.android.criminalintent.CrimeActivity.ACTION_VIEW_CRIME”;
}

 

你完全可以使用一个运行时注解来帮你做这些事情。首先,创建一个注解类:

@Retention(RetentionPolicy.RUNTIME)
@Target( { ElementType.FIELD })
public @interface ServiceConstant { }

一旦定义了注解,我们接着就要写些代码来寻找并自动填充带注解的字段:

复制代码
public static void populateConstants(Class<?> klass) {
    String packageName = klass.getPackage().getName();
    for (Field field : klass.getDeclaredFields()) {
        if (Modifier.isStatic(field.getModifiers()) && 
                field.isAnnotationPresent(ServiceConstant.class)) {
            String value = packageName + "." + field.getName();
            try {
                field.set(null, value);
                Log.i(TAG, "Setup service constant: " + value + "");
            } catch (IllegalAccessException iae) {
                Log.e(TAG, "Unable to setup constant for field " + 
                        field.getName() +
                        " in class " + klass.getName());
            }
        }
    }
}
复制代码

哈哈,现在我们就可以用注解自动赋值常量了:

复制代码
public class CrimeActivity {
    @ServiceConstant
    public static final String ACTION_VIEW_CRIME;

    static {
        ServiceUtils.populateConstants(CrimeActivity.class);
}
复制代码

 

 

 

 

 

 

 

 

未完待续。。。。

 

 

参考自:

http://www.trinea.cn/android/java-annotation-android-open-source-analysis/

http://blog.zenfery.cc/archives/78.html

http://www.race604.com/annotation-processing/

http://www.2cto.com/kf/201405/302998.html

http://objccn.io/issue-11-6/

http://www.codeceo.com/article/java-annotation-processor.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值