Android 注解与注解处理器简述

  • 前言
  • 正文
    • 一、注解
      • ① 注解类型
      • ② 注解生命周期
      • ③ 注解参数
    • 二、注解处理器
      • ① 注册
      • ② 配置
    • 三、使用
      • ① 接口
      • ② 反射
      • ③ 使用
      • ④ 强化
    • 四、源码

前言

  在Android开发中,注解是非常多的,如果不去了解,你可能感受不到注解的存在,一些框架用到的注解是很多的,例如Butterknife、Retrofit、Dagger2、Hilt、ViewBinding、DataBinding等等,下面简单的来了解一下注解。

正文

  这里我们先创建一个项目,命名为 StudyAnnotation。

  点击Finish完成创建,之后我们会看到MainActivity,以及一个onCreate()方法,这似乎没有什么好说明的,但是你可以看到这里onCreate()方法的上面有一个@Override注解,这表示方法重写,怎么重写我们不需要关心,需要关心的是这里面的内容。

  注解本身是用于标注信息的,例如Butterknife,之前在ViewBinding还没有出来之前,我们做Android开发绕不开的一个东西,就是findViewById,而Butterknife就是通过注解,标注了需要进行findViewById的控件,从而在编译时生成类文件,帮我们去写了这些繁琐的代码。还记得ButterKnife的用法吗?


  这个图还是我写天气App时用的,那时候Butterknife还可以用,后面Google在Jetpack中推出组件ViewBinding,至此Butterknife不再推荐你使用,改用ViewBinding。这里就不说这个ViewBinding相比于Butterknife的优势了,因为本文主要是讲述注解的用法。

  假设我们需要自己通过注解,完成对控件findViewById代码的自动生成,需要怎么做?首先我们是不是要写一个注解呢?

一、注解

  为了区别于当前项目代码,我们可以新建一个moudle来写注解,将工程切换到项目模式,右键点击项目名称 New → Module ,然后选择Java or Kotlin Library,输入名称apt_annotation,最后点击Finish按钮,完成Module创建。

Module创建好了,我们在Module中找到com.llw.annotation包,先把默认的MyClass类删除,然后右键点击 New → Java Class,出现一个弹窗,选择@Annotation,输入名称BindView,回车创建完成。

里面的代码就很简单,看起来像是一个接口,但是前面有一个@符号,表示这是一个注解。

public @interface BindView {}

① 注解类型

  但是这还没有完,我们添加一个注解告诉开发者这个注解是用在什么地方的,这里我写入@Target注解,在括号里面输入ElementType,这表示注解使用的地方类型,然后可以点出来很多类型,如下图所示:

这里的注解可以标记的类型比较多,可以在注解、构造方法、字段、局部变量、方法、模块、包、参数等类型上进行注解,而我们就注解字段就可以了,使用ElementType.FIELD

@Target(ElementType.FIELD)
public @interface BindView {}

② 注解生命周期

  是不是没想到,注解也会有生命周期呢?我们在@Target注解下面增加一个@Retention注解,里面同样需要填写参数,这里的参数就没有注解类型那么多了,只有三个。

  SOURCE表示源码期,注解的文件在Javac编译Java代码生成class文件之后就找不到了,CLASS表示编译期,这一种包括前面的源码期,在编译器间有效,文件能找到。RUNTIME表示运行期,表示程序运行时注解信息依然存在。在编译期时Java虚拟机加载class文件的时候会忽略掉注解, 这里我们选择使用RetentionPolicy.RUNTIME

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

③ 注解参数

刚才在使用@Target@Retention注解的使用我们都需要在注解中输入一个参数,这是因为他们在注解中定义方法,而我们写入的参数就这个这个方法所需要返回的参数类型。

  这里我们看到Retention注解,里面定义了一个value()方法,返回类型是RetentionPolicy,这是一个枚举类,另外我们注意到它的上面的注解类型和注解生命周期都是刚才提到过的,下面回到我们自己的BindView,因为要给控件写findViewById,这里最重要的是拿到控件的id,这是int类型,因此我们可以在BindView注解中增加一个value()方法,返回int类型,代码如下:

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

二、注解处理器

  注解我们知道了大概的内容,之前我们提到注解用于标注信息,那么标注之后呢?就完了吗?那不是没有意义吗?所以为了使注解的标记变的有意义,我们还需要一个东西来处理标注的信息,那就是注解处理器。

  下面再创建一个Module来写注解处理器,创建的方式和之前创建注解Module一样,名称改成apt_processor,类名由MyClass改成AnnotationProcessor,这里我们使用的apt是有含义的。

  APT(Annotation Processing Tool)即注解处理器,是一种处理注解工具,确切的说它是javac的一个工具,它用来在编译时扫描和处理注解。注解处理器以Java代码(或者编译过的字节码)作为输入,生成.java文件作为输出。简单来说就是在编译期,通过注解生成.java文件。简单来说就是通过注解去插手编译期中的一些事情,达到我们的目的。类似于隔壁老王和阁下老六的结合体,当然这是Android中合法的手段,不是什么黑科技。

① 注册

  而注解处理器要正常使用的话需要注册,首先我们添加一个依赖库,在apt_processor模块下的build.gradle中添加如下代码

dependencies {implementation 'com.google.auto.service:auto-service:1.0-rc7'annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7'implementation project(path: ':apt_annotation')
}

  这里前面两行代码是注册注解处理器需要的,最后一行代码,我们需要处理注解,要依赖之前的注解模块,这很好理解,然后Sync Now就可以了,下面我们修改AnnotationProcessor类的代码如下所示:

@AutoService(Process.class)
public class AnnotationProcessor extends AbstractProcessor {@Overridepublic boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {return false;}
}

  这里我们通过注解的方式去注册这个注解处理器,里面的参数是Process.class,这是javac在编译java文件时使用的,然后继承AbstractProcessor,重写里面的process()方法,这个方法很重要,我们的注解处理就是在这里完成的。

② 配置

  要让我们的注解处理器生效还需要一些配置,你可以通过其他注解来完成配置,也可以通过代码的方式来完成,为了更好说明,这里我用代码的方式,首先我们需要配置注解处理器所支持的版本,在AnnotationProcessor中增加如下方法代码:

 @Overridepublic SourceVersion getSupportedSourceVersion() {return super.getSupportedSourceVersion();}

这里返回的是父类所支持的版本,你也可以改成最新的,使用SourceVersion.latestSupported()

 @Overridepublic SourceVersion getSupportedSourceVersion() {return SourceVersion.latestSupported();}

然后再配置一下注解类型,也就是我们的注解处理器能处理那些注解,在AnnotationProcessor中增加如下方法代码:

 @Overridepublic Set<String> getSupportedAnnotationTypes() {return super.getSupportedAnnotationTypes();}

  这里返回的是一个Set集合,里面的String类型的,那么我们可以将之前所写的注解BindView添加到集合中返回,修改getSupportedAnnotationTypes()方法代码,如下所示:

 @Overridepublic Set<String> getSupportedAnnotationTypes() {Set<String> types = new HashSet<>();types.add(BindView.class.getCanonicalName());return types;}

  通过注解处理器处理注解所标识的内容时会生成一个编译时文件,一般都在build文件夹下,这里我们需要手动去生成一个文件,在AnnotationsProcessor中定义一个变量。

Filer filer;

同时在注解处理器初始化的时候对这个变量进行赋值,方法代码如下所示:

 @Overridepublic synchronized void init(ProcessingEnvironment processingEnvironment) {super.init(processingEnvironment);filer = processingEnvironment.getFiler();}

最后我们修改process()方法的代码,如下所示:

 @Overridepublic boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {//获取App中使用了BindView注解的对象Set<? extends Element> elementsAnnotatedWith = roundEnvironment.getElementsAnnotatedWith(BindView.class);//开始对elementsAnnotatedWith进行分类Map<String, List<VariableElement>> map = new HashMap<>();for (Element element : elementsAnnotatedWith) {//获取所注解的变量元素VariableElement variableElement = (VariableElement) element;//通过变量元素获取所处的外部类(例如MainActivity中的TextView,TextView就是变量元素,MainActivity就是外部类)String activityName = variableElement.getEnclosingElement().getSimpleName().toString();//获取map集合中的变量元素列表,如果为空则new一个,再添加进入map集合。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;//获取map集合的迭代器Iterator<String> iterator = map.keySet().iterator();//如果iterator.hasNext()为true,执行循环体中的代码while (iterator.hasNext()) {//获取map的键 (键:外部类名称)String activityName = iterator.next();//通过键获取到值 (值:变量元素列表)List<VariableElement> variableElements = map.get(activityName);//获取变量元素所处的外部类,这里强转一下TypeElement enclosingElement = (TypeElement) variableElements.get(0).getEnclosingElement();//得到包名String packageName = processingEnv.getElementUtils().getPackageOf(enclosingElement).toString();//准备写文件,要处理异常try {//创建源文件JavaFileObject sourceFile = filer.createSourceFile(packageName + "." + activityName + "_ViewBinding");//打开文件输入流并赋值给writerwriter = sourceFile.openWriter();//写入包名(例如:package com.llw.annotation;)writer.write("package " + packageName + ";\n");//写入导包IBinder(例如:import com.llw.annotation.IBinder;)writer.write("import " + packageName + ".IBinder;\n");//换行writer.write("\n");//写入类实现IBinder接口(例如:public class MainActivity_ViewBinding implements// IBinder<com.llw.annotation.MainActivity>{)writer.write("public class " + activityName + "_ViewBinding implements IBinder<" +packageName + "." + activityName + "> {\n");//写入@Override注解,注意格式(例如:@Override)writer.write("\n    @Override");//写入bind方法(例如:public void bind(com.llw.annotation.MainActivity target) {)writer.write("\n    public void bind(" + packageName + "." + activityName + " target) {\n");//遍历类中每一个需要findViewById的控件for (VariableElement variableElement : variableElements) {//获取控件名称String variableName = variableElement.getSimpleName().toString();//通过注解拿到控件的Idint id = variableElement.getAnnotation(BindView.class).value();//获取控件类型TypeMirror typeMirror = variableElement.asType();//写findViewById语句(例如:target.tvText = (android.widget.TextView) target.findViewById(2131231127);)writer.write("        target." + variableName + " = (" + typeMirror + ") target.findViewById(" + id + ");");}//换行 结束writer.write("\n    }\n}");} catch (Exception e) {e.printStackTrace();} finally {if (writer != null) {try {writer.close();} catch (Exception e) {e.printStackTrace();}}}}}return false;}

  这里的代码核心逻辑就是通过注解标识获取App中使用了BindView注解的对象,这是一个对象集合,可能一个也可能多个,然后遍历集合,得到每一个对象,获取对象的变量元素,再获取元素所在的外部类,意思就是我的Activity中可能有多个控件被注解,获取到这个Activity,然后通过map进行获取,通过类名作为键去获取值,值是这个类中所有的标注控件,因此得到一个列表。接下来的一部分就是遍历map集合,写入文件,手动去拼接代码,得到一个类似这样的类,代码如下:

package com.llw.annotation;
import com.llw.annotation.IBinder;public class MainActivity_ViewBinding implements IBinder<com.llw.annotation.MainActivity> {@Overridepublic void bind(com.llw.annotation.MainActivity target) {target.tvText = (android.widget.TextView) target.findViewById(2131231127);}
}

  那么到这里我们就不用去写findViewById了,通过注解处理器在java文件,进行javac编译时处理这些注解,生成一个编译时文件,下面我们在代码中使用这个注解和注解处理器。

三、使用

  现在的情况是我们的注解处理器添加了注解模块的依赖,而我们的app模块还没有添加任何依赖,因此,我们在使用的时候首先需要在app模块下的build.gradle中dependencies{}闭包下添加如下依赖:

 implementation project(path: ':apt_annotation')annotationProcessor project(path: ':apt_processor')

这里要注意一点,那就是注解添加依赖和注解处理器添加依赖的方式不同,添加之后点击Sync Now进行同步。

① 接口

  为了能在Activity中使用,我们需要提供一个接口可以绑定Activity,在app模块下的com.llw.annotation包下,新建一个IBinder接口,代码如下:

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

② 反射

  然后我们写一个类,通过反射的方式去得到Activity的实例,再通过接口进行绑定,在com.llw.annotation包下新建一个CustomKnife类,代码如下:

public class CustomKnife {public static void bind(Activity activity) {String name = activity.getClass().getName() + "_ViewBinding";try {//通过反射生成一个类对象Class<?> aClass = Class.forName(name);//通过newInstance得到接口实例IBinder iBinder = (IBinder) aClass.newInstance();//最后调用接口bind()方法iBinder.bind(activity);} catch (Exception e) {e.printStackTrace();}}
}

这里我们就是写了一个方法进行绑定即可,下面就是使用了。

③ 使用

修改一下activity_main.xml中的TextView控件,给它一个id为tv_text,然后回到MainActivity中,修改代码如下所示:

public class MainActivity extends AppCompatActivity {@BindView(R.id.tv_text)TextView tvText;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);CustomKnife.bind(this);tvText.setText("Annotation Processor");}
}

  这里的写法就和ButterKnife很相似了,如果你之前用过它的话,这里我们通过注解去标记,注解参数填的是控件id,处理处理器通过这个id就能知道这个MainActivity,然后就可以生成编译时文件。这里布局中的TextView中的文字默认是Hello World!,在onCreate()方法中,绑定了Activity之后直接修改TextView的文字内容,这里并不会报错,因为编译时文件中写好了findViewById了,下面我们运行一下,真机或者虚拟机都可以的。

这里的文字就改变了,然后我们可以在build文件夹中找到MainActivity_ViewBinding。

  这个文件你在Clean Project时就会删除掉,你也可以Rebuild Project。

④ 强化

  虽然我们当前通过手动拼接的方式实现了编译时类的生成,只不过这样写还是有一些太Low了,所以我们需要更人性化的方式去生成编译时类,这里我们需要了解javapoet,这个很关键,下面我们将使用它,在apt_processor模块的build.gradle的dependencies{}闭包下添加如下依赖:

 implementation 'com.squareup:javapoet:1.13.0'

然后Sync Now,进入到AnnotationsProcessor类中,找到process()方法,将里面这一段代码抽离出来

放到一个新的方法中,方法代码如下:

 private void makefile(Map<String, List<VariableElement>> map) {//生成文件if (map.size() > 0) {//创建输入流Writer writer = null;//获取map集合的迭代器Iterator<String> iterator = map.keySet().iterator();//如果iterator.hasNext()为true,执行循环体中的代码while (iterator.hasNext()) {//获取map的键 (键:外部类名称)String activityName = iterator.next();//通过键获取到值 (值:变量元素列表)List<VariableElement> variableElements = map.get(activityName);//获取变量元素所处的外部类,这里强转一下TypeElement enclosingElement = (TypeElement) variableElements.get(0).getEnclosingElement();//得到包名String packageName = processingEnv.getElementUtils().getPackageOf(enclosingElement).toString();//准备写文件,要处理异常try {//创建源文件JavaFileObject sourceFile = filer.createSourceFile(packageName + "." + activityName + "_ViewBinding");//打开文件输入流并赋值给writerwriter = sourceFile.openWriter();//写入包名(例如:package com.llw.annotation;)writer.write("package " + packageName + ";\n");//写入导包IBinder(例如:import com.llw.annotation.IBinder;)writer.write("import " + packageName + ".IBinder;\n");//换行writer.write("\n");//写入类实现IBinder接口(例如:public class MainActivity_ViewBinding implements// IBinder<com.llw.annotation.MainActivity>{)writer.write("public class " + activityName + "_ViewBinding implements IBinder<" + packageName + "." + activityName + "> {\n");//写入@Override注解,注意格式(例如:@Override)writer.write("\n    @Override");//写入bind方法(例如:public void bind(com.llw.annotation.MainActivity target) {)writer.write("\n    public void bind(" + packageName + "." + activityName + " target) {\n");//遍历类中每一个需要findViewById的控件for (VariableElement variableElement : variableElements) {//获取控件名称String variableName = variableElement.getSimpleName().toString();//通过注解拿到控件的Idint id = variableElement.getAnnotation(BindView.class).value();//获取控件类型TypeMirror typeMirror = variableElement.asType();//写findViewById语句(例如:target.tvText = (android.widget.TextView) target.findViewById(2131231127);)writer.write("        target." + variableName + " = (" + typeMirror + ") target.findViewById(" + id + ");");}//换行 结束writer.write("\n    }\n}");} catch (Exception e) {e.printStackTrace();} finally {if (writer != null) {try {writer.close();} catch (Exception e) {e.printStackTrace();}}}}}}

这是之前的方式,我们还是先保留,这样你就可以自己对比一下新的方式,看看优劣,下面我们使用新的方式,在AnnotationsProcessor中我们再增加一个方法,代码如下:

 private void makefilePlus(Map<String, List<VariableElement>> map) {if (map.size() > 0) {for (String activityName : map.keySet()) {//通过键获取到值 (值:变量元素列表)List<VariableElement> variableElements = map.get(activityName);//获取变量元素所处的外部类,这里强转一下TypeElement enclosingElement = (TypeElement) variableElements.get(0).getEnclosingElement();//得到包名String packageName = processingEnv.getElementUtils().getPackageOf(enclosingElement).toString();//获取类名实例方式一ClassName iBinderName = ClassName.get(packageName, "IBinder");//获取类名实例方式二ClassName activityClassName = ClassName.bestGuess(activityName);//创建类构造器,例如MainActivity_ViewBindingTypeSpec.Builder classBuilder = TypeSpec.classBuilder(activityName + "_ViewBinding")//添加修饰符 public.addModifiers(Modifier.PUBLIC)//添加实现接口,例如 implements IBinder<MainActivity>.addSuperinterface(ParameterizedTypeName.get(iBinderName, activityClassName));//创建方法构造器 方法名bind()MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder("bind")//添加注解.addAnnotation(Override.class)//添加修饰符.addModifiers(Modifier.FINAL, Modifier.PUBLIC)//添加方法参数.addParameter(activityClassName, "target");for (VariableElement variableElement : variableElements) {//获取控件名称String variableName = variableElement.getSimpleName().toString();//通过注解拿到控件的Idint id = variableElement.getAnnotation(BindView.class).value();//在方法中添加代码,写findViewById语句(例如:target.tvText = target.findViewById(2131231127);)//$L代表的是字面量,variableName对应第一个L,i对应第二个LmethodBuilder.addStatement("target.$L = target.findViewById($L)", variableName, id);}//添加方法classBuilder.addMethod(methodBuilder.build());try {//写入文件JavaFile.builder(packageName, classBuilder.build()).build().writeTo(filer);} catch (Exception e) {e.printStackTrace();}}}}

代码看起来也很长是吧,只不过是因为我写了很多注释而已,通过javapoet我们就不用再去import了,它会自动完成,所以我们先写出来一个类,通过类构造器TypeSpec.classBuilder,会自动增加括号。然后写方法,通过方法构造器MethodSpec.methodBuilder,最后写findViewById就可以了,这里我们同样需要循环,方法写好之后添加到类构造器中,最后写入文件,通过

JavaFile.builder(packageName, classBuilder.build()).build().writeTo(filer);

最终还是写入到filer中,下面在process()方法中,调用makefilePlus()方法,注释makefile()方法。

最后我们修改一下activity_main.xml中的代码:在里面增加一个Button,放在TextView的下面:

 <Buttonandroid:id="@+id/btn_text"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_marginTop="16dp"android:text="Button"app:layout_constraintEnd_toEndOf="@+id/tv_text"app:layout_constraintStart_toStartOf="@+id/tv_text"app:layout_constraintTop_toBottomOf="@+id/tv_text" />

然后回到MainActivity中,我们为新的控件也添加注解,修改代码如下所示:

public class MainActivity extends AppCompatActivity {@BindView(R.id.tv_text)TextView tvText;@BindView(R.id.btn_text)Button btnText;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);CustomKnife.bind(this);tvText.setText("Annotation Processor");btnText.setText("This is Button");}
}

现在我们运行看看效果。

然后我们看看生成类。

四、源码

如果你觉得代码对你有帮助的话,不妨Fork或者Star一下~

源码地址:StudyAnnotation

Android 注解与注解处理器简述相关推荐

  1. 【Android APT】注解处理器 ( 根据注解生成 Java 代码 )

    文章目录 一.生成 Java 代码 二.实现 IButterKnife 接口 三.视图绑定主要操作 四.完整注解处理器代码 五.博客资源 Android APT 学习进阶路径 : 推荐按照顺序阅读 , ...

  2. 【Android APT】注解处理器 ( Element 注解节点相关操作 )

    文章目录 一.获取被 注解 标注的节点 二.Element 注解节点类型 三.VariableElement 注解节点相关操作 四.注解处理器 完整代码示例 五.博客资源 Android APT 学习 ...

  3. 【Android APT】注解处理器 ( 配置注解依赖、支持的注解类型、Java 版本支持 )

    文章目录 一.注解处理器 依赖 编译时注解 二.设置 注解处理器 支持的注解类型 三.设置 注解处理器 支持的 Java 版本 四.博客资源 Android APT 学习进阶路径 : 推荐按照顺序阅读 ...

  4. 【Android APT】注解处理器 ( 注解标注 与 初始化方法 )

    文章目录 一.注解处理器 AbstractProcessor 二.使用注解 @AutoService(Processor.class) 标注 注解处理器 三.注解处理器 init 初始化方法 四.注解 ...

  5. 【Android APT】编译时技术 ( 编译时注解 和 注解处理器 依赖库 )

    文章目录 一.编译时注解和注解处理器 二.创建 编译时注解 和 注解处理器 三.添加 编译时注解 和 注解处理器 依赖库依赖 四.博客资源 一.编译时注解和注解处理器 上一篇博客 [Android A ...

  6. Android中自定义注解处理器

    注解和注解生成器 如果没有处理注解的工具,那么注解也不会有太大的作用.对于不同的注解有不同的注解处理器.虽然注解处理器的编写千变万化,但是也有处理标准. 参考文献:https://blog.csdn. ...

  7. 浅谈Java/Android下的注解

    什么是注解 java.lang.annotation,接口 Annotation,在JDK5.0及以后版本引入. 注解是代码里的特殊标记,这些标记可以在编译.类加载.运行时被读取,并执行相应的处理.通 ...

  8. @column注解_Java 注解及其在 Android 中的应用

    Linux编程点击右侧关注,免费入门到精通! 作者丨WngShhnghttps://juejin.im/post/5b824b8751882542f105447d 一般的,注解在 Android 中有 ...

  9. java编译时注解_Java注解处理器--编译时处理的注解

    1. 一些基本概念 在开始之前,我们需要声明一件重要的事情是:我们不是在讨论在运行时通过反射机制运行处理的注解,而是在讨论在编译时处理的注解. 注解处理器是 javac 自带的一个工具,用来在编译时期 ...

最新文章

  1. 谷歌发布地图「时光机」:100年前,你家街道长啥样?
  2. Fabio 安装和简单使用
  3. 关于程序变式中动态选择的一点说明
  4. 深入浅出oracle锁原理篇
  5. C#开发笔记之16-如何用C#获取枚举的中文特性信息?
  6. Python学习入门1:Python 新手入门引导
  7. 分享一下淘宝iData技术嘉年华的几点感触
  8. modbus发送接收_自己编写MODBUS协议代码所踩过的坑
  9. 演练一下500台主机的内网中IP地址的划分
  10. catia设计树_在CATIA目录树上**零件号原来这么简单!
  11. ssm基于微信平台的牙科就诊信息管理系统的设计与实现 毕业设计源码211157
  12. 为了让你在“口袋奇兵”聊遍全球,手撕ArrayList底层,透彻分析源码
  13. 哈希传递PTH、密钥传递PTT、票据传递PTK的实现和比较
  14. SQL 拼接多个字段的值一个字段多条记录的拼接
  15. 数据库候选关键词怎么求_数据库中候选码求法.(比较全的哦)
  16. Cesium渐变色3dtiles白模(视频)
  17. 项目预算包括管理储备__成本基准只包括应急储备
  18. Linux 解压缩文件到指定目录
  19. 计算机游戏32,腾讯宣布将32款游戏退市 2019中国十大科技成就公布
  20. 无所不在的JavaScript与物联网设备

热门文章

  1. android M权限问题
  2. SI(crosstalk)对common path的影响(CPPR)
  3. 计算机毕业设计之java+ssm企业员工考勤系统
  4. 微信小程序+SpringBoot实现校园快递代收平台
  5. android view.isshown,关于android:View getVisibility()isShown()返回不正确的可见性
  6. 如何批量修改图片名称?
  7. 普歌+阿里云视频点播错误修改
  8. 开店经验|如何开一家精品咖啡馆
  9. C++引用计数原理和实现
  10. 文字折叠特效 html+css