前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >使用编译时注解简单实现类似 ButterKnife 的效果

使用编译时注解简单实现类似 ButterKnife 的效果

作者头像
张拭心 shixinzhang
发布2018-01-05 16:43:13
1.5K0
发布2018-01-05 16:43:13
举报

这篇文章是学习鸿洋前辈的 Android 如何编写基于编译时注解的项目 的笔记,用于记录我的学习收获。

什么是编译时注解

上篇文章 什么是注解以及运行时注解的使用 中我们介绍了注解的几种使用场景,这里回顾一下:

  1. 编译前提示信息:注解可以被编译器用来发现错误,或者清除不必要的警告;
  2. 编译时生成代码:一些处理器可以在编译时根据注解信息生成代码,比如 Java 代码,xml 代码等;
  3. 运行时处理:我们可以在运行时根据注解,通过反射获取具体信息,然后做一些操作。

编译时注解就是只在编译时存在的注解,可以被注解处理器识别,用于生成一些代码。

APT

处理编译时注解需要使用 APT。

APT 即 Annotation Processing Tool,注解处理工具,它可以在编译时检测源代码文件,找到符合条件的注解修饰的内容,然后进行相应的处理。

我们在使用 ButterKnife 和 Dagger2 时,gradle 依赖中的 apt 就是指定在编译时调用它们的注解处理器:

代码语言:javascript
复制
compile "com.jakewharton:butterknife:$rootProject.butterknifeVersion"
apt "com.jakewharton:butterknife-compiler:$rootProject.butterknifeVersion"
compile "com.google.dagger:dagger:$rootProject.daggerVersion"
apt "com.google.dagger:dagger-compiler:$rootProject.daggerVersion"

编译时注解如何使用与编写

编译时注解的使用一般分为三步:

  1. 用注解修饰变量
  2. 编译时使用注解处理器生成代码
  3. 运行时调用生成的代码

那编写编译时注解项目的步骤就是这样:

  1. 先创建注解
  2. 创建注解处理器,在其中拿到注解修饰的变量信息,生成需要的代码
  3. 创建运行时,调用生成代码的调度器

举个例子

这里我们写一个类似 ButterKnife 使用注解实现 findViewById 的 demo。

思路

这个 demo 的目的减少编写 findViewById 的代码,使用一个注解就达到 View 对象的绑定效果。

羊毛出在猪身上,使用方便的背后一定有默默无闻的付出者,我们要做的就是根据注解实现对应 View 的绑定。

所以大概思路就是这样子:

  1. 先写一个注解,这个注解修饰一个成员变量,同时指定这个变量对应的 id
  2. 然后写个注解处理器,读取当前类的所有被注解修饰的成员对象和 id,生成对应的 findViewById 代码
  3. 最后写个运行时绑定的类,初始化当前类的成员

注意: 注解处理器所在的 module 必须是 Java Library,因为要用到特有的 javax; 注解处理器需要依赖 注解 module,所以注解所在的 module 也要是 Java Library; 运行时绑定的类要操作 Activity 或者 View,所以需要为 Android Library。

因此需要创建三个 module:

这里写图片描述
这里写图片描述

接下来将分别介绍每个 module 的内容。

1.创建注解

New 一个 Module,选择为 Java library,我们起名为 ioc-annotation。

这里写图片描述
这里写图片描述

在其中创建一个注解,这里叫 BindView:

代码语言:javascript
复制
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView {
    int value();
}

编译时注解的 RetentionRetentionPolicy.CLASS,即只在编译时保留。 修饰目标为 ElementType.FIELD,即成员变量。

这个注解有一个 value 属性,类型为 int,用于指明将来 findViewById 的 id。

现在我们可以使用这个注解来修饰 Activity 中的成员,指定它对应的 id:

代码语言:javascript
复制
@BindView(R.id.tv_content)
public TextView mTextView;
@BindView(R.id.tv_bottom_content)
public TextView mBottomTextView;

看起来和 ButterKnife 很相似吧,不过现在它只是有个样子,还得写点额外代码它才能起作用。

2.创建运行时绑定的类

类似 ButterKnife,我们需要在 Activity 中调用一个绑定的方法,便于运行时初始化当前类中使用注解修饰的字段。就像这样:

代码语言:javascript
复制
@Override
protected void onCreate(@Nullable final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_annotation);
    ViewBinder.bind(this);

}

New 一个 Module,选择为 Android library,我们起名为 ioc。

这里写图片描述
这里写图片描述

创建 ViewBinder,它的作用是调用生成类,完成 bind() 方法传入对象的字段初始化。

代码语言:javascript
复制
**
 * Description:
 * <br> 从生成类中为当前 Activity/View 中的 View findViewById
 * <p>
 * <br> Created by shixinzhang on 17/6/22.
 * <p>
 * <br> Email: shixinzhang2016@gmail.com
 * <p>
 * <br> https://about.me/shixinzhang
 */

public class ViewBinder {
    private static final String SUFFIX = "$$ViewInjector";

    //Activity 中调用的方法
    public static void bind(Activity activity) {
        bind(activity, activity);
    }

    /**
     * 1.寻找对应的代理类
     * 2.调用接口提供的绑定方法
     *
     * @param host
     * @param root
     */
    @SuppressWarnings("unchecked")
    private static void bind(final Object host, final Object root) {
        if (host == null || root == null) {
            return;
        }

        Class<?> aClass = host.getClass();
        String proxyClassFullName = aClass.getName() + SUFFIX;    //拼接生成类的名称

        try {
            Class<?> proxyClass = Class.forName(proxyClassFullName);
            ViewInjector viewInjector = (ViewInjector) proxyClass.newInstance();
            if (viewInjector != null) {
                viewInjector.inject(host, root);
            }
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

    }
}

ViewBinder.bind(this) 的作用就是根据当前类名和约定好的类名,找到生成类,然后反射调用它的方法。为了调用指定的方法,让这个生成类实现一个接口。

所以我们还需要创建一个接口 ViewInjector,这个接口的作用是便于反射调用。

代码语言:javascript
复制
public interface ViewInjector<T> {
    void inject(T t, Object source);
}

其实也可以反射遍历调用对象的方法,但是效率不如直接实现一个接口来的好。

3.创建注解处理器

注解处理器的作用是读取注解、生成代码,先看下将来想要生成的代码:

这里写图片描述
这里写图片描述

我们要生成的类,名称是使用注解修饰的字段所在类名 拼接上 $$ViewInjector,实现 ViewInjector 接口,在 inject() 方法中实现类中字段的 findViewById 过程。

这样直接传入对应的 activity,其中的 mTextView 或者 mBottomTextView 等使用 BindView 修饰的变量就可以初始化了。

OK,知道要生成啥样的类以后,就可以编写注解处理代码了。

New 一个 Module,选择为 Java library,我们起名为 ioc-processor。

这里写图片描述
这里写图片描述

①首先修改 build.gradle 文件,添加下面两行:

代码语言:javascript
复制
dependencies {
    compile 'com.google.auto.service:auto-service:1.0-rc3'
    compile project(path: ':ioc-annotation')
}

第一个依赖会帮我们生成 META-INF元信息,指明注解处理器的完整路径。

如果不想使用这个依赖,要自己创建的话,也可以:

  • 在 main 文件夹下创建一个 resources.META-INF.services 文件夹
  • 在其中创建 javax.annotation.processing.Processor 文件
这里写图片描述
这里写图片描述
  • 文件的内容是注解处理器的完整包名加类名 top.shixinzhang.BindViewProcessor

②我这里直接使用注解了,创建注解处理器,继承 AbstractProcessor

代码语言:javascript
复制
@AutoService(Processor.class)    //帮我们生成 META-INF 信息
@SupportedAnnotationTypes("top.shixinzhang.BindView")    要处理的注解类型
@SupportedSourceVersion(SourceVersion.RELEASE_7)    //支持的源码版本
public class BindViewProcessor extends AbstractProcessor {
    //...
  }

三个注解的作用如注释所示。

如果不使用后面两个注解,就需要重写 getSupportedAnnotationTypes()getSupportedSourceVersion 方法:

代码语言:javascript
复制
//    有注解就不用重写这两个方法了
//    @Override
//    public Set<String> getSupportedAnnotationTypes() {
//        Set<String> annotationTypes = new LinkedHashSet<>();
//        annotationTypes.add(BindView.class.getCanonicalName());
//        return annotationTypes;
//    }
//
//
//    /**
//     * 支持的源码版本
//     * @return
//     */
//    @Override
//    public SourceVersion getSupportedSourceVersion() {
//        return SourceVersion.latestSupported();
//    }

③然后重写 init() 方法:

代码语言:javascript
复制
@AutoService(Processor.class)
@SupportedAnnotationTypes("top.shixinzhang.BindView")
@SupportedSourceVersion(SourceVersion.RELEASE_7)    //支持的源码版本
public class BindViewProcessor extends AbstractProcessor {
    private Elements mElementUtils; //基于元素进行操作的工具方法
    private Filer mFileCreator;     //代码创建者
    private Messager mMessager;     //日志,提示者,提示错误、警告

    private Map<String, ProxyInfo> mProxyMap = new HashMap<>();

    @Override
    public synchronized void init(final ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        mElementUtils = processingEnv.getElementUtils();
        mFileCreator = processingEnv.getFiler();
        mMessager = processingEnv.getMessager();
    }
    //...
}

在这个方法中做初始化操作,参数 processingEnv 是注解处理环境,通过它可以获取很多功能类:

代码语言:javascript
复制
public interface ProcessingEnvironment {
    //返回注解处理工具的一些配置选项
    Map<String,String> getOptions();

    //返回信息传递者,用来报告错误、警告灯信息
    Messager getMessager();

    //返回用于创建 Java 文件、class 文件或者其他辅助文件的文件创建者
    Filer getFiler();

    //返回用于基于元素进行操作的工具类
    Elements getElementUtils();

    //返回用于基于类型进行操作的工具类
    Types getTypeUtils();

    //返回生成文件的版本
    SourceVersion getSourceVersion();

    //返回当前区域,用于提示本地化的消息
    Locale getLocale();
}

这么多功能,我们这里只使用 getElementUtils(), getFiler()getMessager(),用于后续创建文件、获取元素信息,以及在编译时提示信息。

④重写 process() 方法

做好准备工作后,接下来在 process() 中做两件事:

  1. 收集信息
  2. 生成代码

首先收集信息,我们需要拿到的信息有如下几点:

  1. 注解修饰变量所在的类名,便于和后缀拼接生成代理类
  2. 类的完整包名
  3. 类中被注解修饰的字段,以及对应的布局 id

那我们编译时可以拿到什么呢?

代码语言:javascript
复制
@Override
public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
    //...
}

第一个参数暂且不表,第二个参数 RoundEnvironment 的作用是提供一个注解处理器,在编译时可以查询类的信息。其中有一个关键的方法 getElementsAnnotatedWith()

代码语言:javascript
复制
Set<? extends Element> getElementsAnnotatedWith(Class<? extends Annotation> a);

这个方法可以拿到指定注解修饰的元素集合,返回的是 Element 及其子类的对象集合。

Element 是一个接口,代表着一个包、类、方法或者元素,它的子接口有很多,比如:

  • VariableElement:成员变量
  • TypeElement :类或者接口
  • PackageElement:包信息
  • ExecutableElement:方法

OK,了解了目的和条件,就可以编写代码了。

代码语言:javascript
复制
@Override
public boolean process(final Set<? extends TypeElement> annotations, final RoundEnvironment roundEnv) {
    mMessager.printMessage(Diagnostic.Kind.NOTE, "process...");
    //避免生成重复的代理类
    mProxyMap.clear();

    //拿到被 @BindView 注解修饰的元素,应该是 VariableElement
    Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
    //1.收集信息
    for (Element element : elements) {
        if (!checkAnnotationValid(element, BindView.class)) {    //去除不合格的元素
            continue;
        }

        //类中的成员变量
        VariableElement variableElement = (VariableElement) element;
        //类或者接口
        TypeElement typeElement = (TypeElement) variableElement.getEnclosingElement();
        //完整的名称
        String qualifiedName = typeElement.getQualifiedName().toString();

        ProxyInfo proxyInfo = mProxyMap.get(qualifiedName);
        if (proxyInfo == null) {
            //将该类中被注解修饰的变量加入到 ProxyInfo 中
            proxyInfo = new ProxyInfo(mElementUtils, typeElement);
            mProxyMap.put(qualifiedName, proxyInfo);
        }

        BindView annotation = variableElement.getAnnotation(BindView.class);
        if (annotation != null) {
            int id = annotation.value();
            proxyInfo.mInjectElements.put(id, variableElement);
        }
    }
    //...
}

我们先输出了一个提示信息 “process…”,一会儿 build 项目的时候可以看到这个提示。

上面的代码主要做了这几件事:

  • 先调用 roundEnv.getElementsAnnotatedWith(BindView.class) 拿到被 @BindView 注解修饰的元素集合,在前面的例子中,我们拿到的就是 TextView mTextViewTextView mBottomTextView
  • 然后遍历这些元素,由于我们注解修饰的是变量,可以直接转换成 VariableElement 类型。
  • 调用 variableElement.getEnclosingElement() 方法拿到变量所在类的对象信息,调用它的 getQualifiedName().toString() 方法获得类的完整名称。

我们使用一个 map 保存类的信息:

代码语言:javascript
复制
private Map<String, ProxyInfo> mProxyMap = new HashMap<>();

你可以先创建一个空的 ProxyInfo 类,构造函数为:

代码语言:javascript
复制
public class ProxyInfo {
    private static final String SUFFIX = "ViewInjector";
    public Map<Integer, VariableElement> mInjectElements = new HashMap<>();    //被注解修饰的变量和 id 映射表

    public ProxyInfo(final Elements elementUtils, final TypeElement typeElement) {
    //...
    }
}
}

它的具体内容后面介绍。

将该类中被注解修饰的变量加入到 mProxyMap 后,接下来就可以遍历这些信息,生成对应的代码了。

生成代码:

代码语言:javascript
复制
    //...
    //2.生成代理类
    for (String key : mProxyMap.keySet()) {
        ProxyInfo proxyInfo = mProxyMap.get(key);
        try {
            //创建文件对象
            JavaFileObject sourceFile = mFileCreator.createSourceFile(
                    proxyInfo.getProxyClassFullName(), proxyInfo.getTypeElement());
            Writer writer = sourceFile.openWriter();
            writer.write(proxyInfo.generateJavaCode());     //写入文件
            writer.flush();
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
            error(proxyInfo.getTypeElement(), "Unable to write injector for type %s: %s", proxyInfo.getTypeElement(), e.getMessage());
        }
    }

    return true;
}

遍历 mProxyMap,里面的 ProxyInfo 列表创建文件。

创建文件对象只需要调用 mFileCreator.createSourceFile 拿到 JavaFileObject 对象,然后拿到 Writer,写入信息即可。

生成代码最终调用的是 proxyInfo.generateJavaCode(),这时我们可以了解前面介绍的 ProxyInfo 了。

最终的代码生成类。

ProxyInfo 的作用就是拿代码拼出这个类:

这里写图片描述
这里写图片描述

所以它需要保存类的信息、包名、完整类名以及其中的变量列表,在构造函数中初始化:

代码语言:javascript
复制
public class ProxyInfo {
    private static final String SUFFIX = "ViewInjector";
    public Map<Integer, VariableElement> mInjectElements = new HashMap<>();    //变量列表
    private TypeElement mTypeElement;    //类信息
    private String mPackageName;    //包名
    private String mProxyClassName;    //代理类名

    public ProxyInfo(final Elements elementUtils, final TypeElement typeElement) {
        mTypeElement = typeElement;
        PackageElement packageElement = elementUtils.getPackageOf(typeElement);
        mPackageName = packageElement.getQualifiedName().toString();
        String className = getClassName(typeElement, mPackageName);
        mProxyClassName = className + "$$" + SUFFIX;
        System.out.println("****** " + mProxyClassName + " \n" + mPackageName);
    }

    private String getClassName(final TypeElement typeElement, final String packageName) {
        int packageLength = packageName.length() + 1;   //

        return typeElement.getQualifiedName().toString().substring(packageLength).replace('.', '$');
    }
    //...
}

然后就可以根据这些动态信息生成不同的类了。

代码语言:javascript
复制
{

    public String generateJavaCode() {
        StringBuilder stringBuilder = new StringBuilder();
        //stringBuilder 中不要再使用 + 拼接字符串
        stringBuilder.append("// Generate code. Do not modify it !\n")
                .append("package ").append(mPackageName).append(";\n\n")
                .append("import top.shixinzhang.ioc.*;\n\n")
                .append("public class ").append(mProxyClassName).append(" implements ").append(SUFFIX).append("<").append(mTypeElement.getQualifiedName()).append(">").append("{\n");
        generateMethod(stringBuilder);
        stringBuilder.append("\n}\n");
        return stringBuilder.toString();
    }

    private void generateMethod(final StringBuilder stringBuilder) {
        if (stringBuilder == null) {
            return;
        }
        stringBuilder.append("@Override\n")
                .append("public void inject(").append(mTypeElement.getQualifiedName()).append(" host, Object object )").append("{\n");

        for (Integer id : mInjectElements.keySet()) {
            VariableElement variableElement = mInjectElements.get(id);
            String name = variableElement.getSimpleName().toString();
            String type = variableElement.asType().toString();
            stringBuilder.append("if(object instanceof android.app.Activity)").append("{\n")
                    .append("host.").append(name).append(" = ")
                    .append("(").append(type).append(")((android.app.Activity)object).findViewById(").append(id).append(");")
                    .append("\n}\n")
                    .append("else").append("{\n")
                    .append("host.").append(name).append(" = ")
                    .append("(").append(type).append(")((android.view.View)object).findViewById(").append(id).append(");")
                    .append("\n}\n");
        }
        stringBuilder.append("\n}\n");
    }


    public String getProxyClassFullName() {
        return mPackageName + "." + mProxyClassName;
    }

    public TypeElement getTypeElement() {
        return mTypeElement;
    }

}

拼的很简单粗暴,参考目标代码即可。

完成编写,使用一下

完成这三个 module 后,就可以直接使用了!

在 app module 的 gradle 文件中添加三个 module 的依赖:

代码语言:javascript
复制
compile project(':ioc')
compile project(':ioc-annotation')
apt project(':ioc-processor')

apt 指定注解处理器。

然后在类中使用注解修饰变量,同时调用 ViewBinder.bind(this) 绑定当前 Activity。

代码语言:javascript
复制
public class AnnotationTestActivity extends BaseActivity {

    @BindView(R.id.tv_content)
    public TextView mTextView;
    @BindView(R.id.tv_bottom_content)
    public TextView mBottomTextView;


    @Override
    protected void onCreate(@Nullable final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_annotation);
        ViewBinder.bind(this);

    }
    //...
}

点击 Build -> Rebuild Project,可以在 Messages -> Gradle Console 控制台中看到输出信息:

这里写图片描述
这里写图片描述

然后在 app -> build -> generated -> source -> apt -> flavor -> 使用注解的包名下,看到生成类:

这里写图片描述
这里写图片描述

有这个类表示生成代码成功了。

然后运行一下,运行时就会完成对应变量的初始化。

遇到的坑

1.无法引入javax包下的类库

javax 包属于java,Android核心库中没有。所以不能直接在app Module和Android Library中使用,必须要创建一个Java Library。然后由Java Library导出jar包使用。

2.不生成文件

检查你有没有使用注解。。。

2.几个 module 没有划分

注解没有单独在一个 module 中

3.感谢这个开源项目负责人认真的解答,让我也发现了问题所在!

https://github.com/realm/realm-java/issues/2695

代码地址

完整代码地址

总结

这篇文章介绍了如何编写编译时注解,光看一边很难理解,希望各位可以亲手敲一遍,加深理解。

编译时注解的作用就是生成代码,对比在运行时反射进行类似的操作,性能影响可以忽略不计,它其实和直接运行手写代码没有任何区别,方便在帮我们省去编写一些重复的代码。

EventBus,ButterKnife,Dagger2 都使用了编译时注解,技术基础有了后,具体如何创造,就看你的想象力了!

Thanks

http://blog.csdn.net/lmj623565791/article/details/51931859 https://lizhaoxuan.github.io/2016/08/26/apt_usage_scenario/ http://blog.stablekernel.com/the-10-step-guide-to-annotation-processing-in-android-studio https://medium.com/@aitorvs/annotation-processing-in-android-studio-7042ccb83024 https://bitbucket.org/hvisser/android-apt https://joyrun.github.io/2016/07/19/AptHelloWorld/

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2017-07-05 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是编译时注解
  • APT
  • 编译时注解如何使用与编写
  • 举个例子
    • 思路
      • 1.创建注解
        • 2.创建运行时绑定的类
          • 3.创建注解处理器
          • 完成编写,使用一下
          • 遇到的坑
          • 代码地址
          • 总结
            • Thanks
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档