乐趣区

关于android:手把手教你实现Android编译期注解

一、编译期注解在开发中的重要性

从晚期令人惊艳的 ButterKnife,到起初的以 ARouter 为首的各种路由框架,再到当初谷歌鼎力推广的 Jetpack 组件,越来越多的第三方框架都在应用编译期注解这门技术,能够说不论你是想要深入研究这些第三方框架的原理 还是要成为一个 Android 高级开发工程师,编译期注解都是你不得不好好把握的一门根底技术。

本文从根底的运行期注解用法开始,逐渐演进到编译期注解的用法,让你真正明确编译期注解到底应该在什么场景下应用,怎么用,用了有哪些益处。

二、手写运行期注解

相似上面这种写法,当 View 一多得不停的 findViewById 写很多行,手写起来很麻烦,咱们首先尝试用运行期注解来解决这个问题,看看能不能主动解决这些 findViewById 的操作。

首先是工程构造,必定要定义一个 lib module。

其次定义咱们的注解类:

有了这个注解的类,咱们就能够在咱们的 MainAcitivity 先用起来,尽管此时这个注解还并未起到什么作用。

到这里要略微想一下,此时咱们要做的是 通过注解来将 R.id.xx 赋值给对应的 field,也就是你定义的那些 view 对象(例如红框中的 tv),对于咱们的 lib 工程来说,因为是 MainActivity 要依赖 lib,天然你 lib 不能够依赖 Main 所属的 app 工程了,这里有 2 个起因:

  • A 依赖 B,B 依赖 A 的循环依赖是必定会报错的;
  • 既然你要做一个 lib 那你必定不能依赖使用者的宿主 否则怎么能叫 lib 呢?

所以这个问题就变成了,lib 工程 只能拿到 Acitivty,拿不到宿主的 MainActivity , 既然拿不到宿主的 MainActivity,那我怎么晓得这个 activity 有多少个 field?这里就要用到反射了。

public class BindingView {public static void init(Activity activity) {Field[] fields = activity.getClass().getDeclaredFields();
        for (Field field : fields) {
            // 获取 被注解
            BindView annotation = field.getAnnotation(BindView.class);
            if (annotation != null) {int viewId = annotation.value();
                field.setAccessible(true);
                try {field.set(activity, activity.findViewById(viewId));
                } catch (IllegalAccessException e) {e.printStackTrace();
                }
            }
 
        }
 
    }
}

最初咱们在宿主的 MainActivity 中调用一下这个办法 即可:

到这里其实有人就要问了,这个运行时注解看起来也不难啊,为啥如同用的人不是很多?问题就出在方才反射的那堆办法里,反射大家都晓得 会对 Android 运行时带来一些性能损耗,而这里的代码是一段循环,也就是说这里的代码会随着你应用 lib 的 Activity 的界面复杂程度的进步 而变得越来越慢,这是一个会 随着你界面复杂度进步而逐渐劣化的过程,单次反射对于明天的手机来说简直曾经不存在什么性能耗费了,然而这种 for 循环中应用反射还是尽量少用。

三、手写编译期注解

为了解决这个问题,就要应用编译期注解。当初咱们来尝试用编译期注解来解决上述的问题。后面咱们说过,运行期注解能够用反射来拿到宿主的 field 从而实现需要,为了解决反射的性能问题,咱们其实想要的代码是这样的:

咱们能够在 app 的 module 中新建一个 MainActivityViewBinding 的类:

而后在咱们的 BindingView(留神咱们的 BindingView 是在 lib module 下的)中来调用这个办法不就解决这个反射的问题了吗?

然而这里会有个问题 就是你既然是一个 lib 你不能依赖宿主,所以在 lib Module 中你其实拿不到 MainActivityViewBinding 这个类的,还是得利用反射。

能够看一下下面正文掉的代码,为啥不间接字符串写死?因为你是 lib 库你当然得是动静的,不然怎么给他人用?其实就是获取宿主的 class 名称而后加上一个固定的后缀 ViewBinding 即可。这个时候 咱们就拿到这个 Binding 的 class 了,对吧,剩下就是调用构造方法即可。

public class BindingView {public static void init(Activity activity) {
        try {Class bindingClass = Class.forName(activity.getClass().getCanonicalName() + "ViewBinding");
            Constructor constructor = bindingClass.getDeclaredConstructor(activity.getClass());
            constructor.newInstance(activity);
        } catch (ClassNotFoundException | NoSuchMethodException e) {e.printStackTrace();
        } catch (IllegalAccessException e) {e.printStackTrace();
        } catch (InstantiationException e) {e.printStackTrace();
        } catch (InvocationTargetException e) {e.printStackTrace();
        }
    }
}

看下此时的代码构造:

有人这里要问,这里你不还是用了反射么,对! 这里尽管用了反射,然而我这里的反射只会调用一次,不论你的 activity 有都少 field,在我这里反射办法都只会执行一次。所以性能必定是比之前的计划要快很多倍的。接着看,尽管此刻代码能够失常运行,然而还有一个问题,尽管我能够在 lib 中调用到咱们 app 宿主的类的构造方法,然而,宿主的这个类仍旧是咱们手写的 啊?那你这个 lib 库 还是没有起到任何能够让咱们少写代码的作用。

这个时候就须要咱们的 apt 出场了,也就是编译期注解的外围局部了。咱们创立一个 Java Library,留神是 Java lib 不是 android lib,而后在 app module 中引入他。

留神 引入的形式 不是 imp 了,是 annotation processor;

而后咱们来批改一下 lib_processor, 首先创立一个 注解解决类:

再创立文件 resources/META-INF/services/javax.annotation.processing.Processor , 这里要留神 文件夹创立不要写错了。

而后再这个 Processor 指定 一下咱们的注解处理器即可:

到这里还没完,咱们得通知这个注解处理器 只解决咱们的 BindView 注解 即可,否则这个注解处理器默认解决全副注解 速度就太慢了,然而此时 咱们的 BindView 这个注解类还在 lib 仓外面,显然咱们要调整一下咱们的工程构造:

咱们再 新建一个 Javalib,只放 BindView 即可,而后让咱们的 lib\_processor 和 app 都依赖这个 lib\_interface 即可。再略微批改一下代码,此时咱们是编译期解决,所 Policy 不必是 runtime 了。

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.FIELD)
public @interface BindView {int value();
}
public class BindingProcessor extends AbstractProcessor {
 
    Messager messager;
 
    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {messager = processingEnvironment.getMessager();
        messager.printMessage(Diagnostic.Kind.NOTE, "BindingProcessor init");
        super.init(processingEnvironment);
    }
 
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {return false;}
 
    // 要反对哪些注解
    @Override
    public Set<String> getSupportedAnnotationTypes() {return Collections.singleton(BindView.class.getCanonicalName());
    }
}

到此咱们的大部分工作就处理完毕了。再看一下代码构造(这里的代码构造肯定要了解分明为什么这样设计,否则你是学不会编译期注解的)。

咱们当初曾经可能做到 通过 lib 这个 sdk 调用到 MainActivityViewBinding 这个外面的办法,然而他 还在 app 仓是咱们手写的,不太智能,还没方法用。咱们须要在注解处理器外面,动静的生成这个类,只有能实现这个步骤,那咱们的 SDK 也就根本实现了。

这里要提一下,很多人注解始终学不会就是卡在这里,因为太多的文章或者教程上来就是 Javapoet 那一套代码,压根学不会,或者只能复制粘贴他人的货色,略微变动一下就不会了,其实这里最佳的学习形式是先用 StringBuffer 字符串拼接的形式 拼出咱们想要的代码就能够了,通过这个字符串拼接的过程 来了解对应的 api 以及生成 java 代码的思路,而后最初再用 JavaPoet 来优化代码即可。

咱们能够先思考一下,如果用字符串拼接的形式来做这个生成类的操作要实现哪些步骤。

  • 首先要获取哪些类应用了咱们的 BindView 注解;
  • 获取这些类中应用了 BindView 注解的 field 以及他们对应的值;
  • 拿到这些类的类名称以便咱们生成诸如 MainActivityViewBinding 这样的类名;
  • 拿到这些类的包名,因为咱们生成的类要和注解所属的类属于同一个 package 才不会呈现 field 拜访权限的问题;
  • 上述条件都具备当前 就用字符串拼接的形式 拼接出咱们想要的 java 代码 即可。

这里就间接上代码了,重要局部 间接看正文即可,有了下面的步骤剖析再看代码正文应该不难理解。

public class BindingProcessor extends AbstractProcessor {
 
    Messager messager;
    Filer filer;
    Elements elementUtils;
 
    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        // 次要是输入一些重要的日志应用
        messager = processingEnvironment.getMessager();
        // 你就了解成最终咱们写 java 文件 要用到的重要 输入参数即可
        filer = processingEnvironment.getFiler();
        // 一些不便的 utils 办法
        elementUtils = processingEnvironment.getElementUtils();
        // 这里要留神的是 Diagnostic.Kind.ERROR 是能够让编译失败的 一些重要的参数校验能够用这个来提醒用户你哪里写的不对
        messager.printMessage(Diagnostic.Kind.NOTE, "BindingProcessor init");
        super.init(processingEnvironment);
    }
 
    private void generateCodeByStringBuffer(String className, List<Element> elements) throws IOException {
 
        // 你要生成的类 要和 注解的类 同属一个 package 所以还要取 package 的名称
        String packageName = elementUtils.getPackageOf(elements.get(0)).getQualifiedName().toString();
        StringBuffer sb = new StringBuffer();
        // 每个 java 类 的结尾都是 package sth...
        sb.append("package");
        sb.append(packageName);
        sb.append(";\n");
 
        // public class XXXActivityViewBinding {
        final String classDefine = "public class" + className + "ViewBinding { \n";
        sb.append(classDefine);
 
        // 定义构造函数的结尾
        String constructorName = "public" + className + "ViewBinding(" + className + "activity){ \n";
        sb.append(constructorName);
 
        // 遍历所有 element 生成诸如 activity.tv=activity.findViewById(R.id.xxx) 之类的语句
        for (Element e : elements) {sb.append("activity." + e.getSimpleName() + "=activity.findViewById(" + e.getAnnotation(BindView.class).value() + ");\n");
        }
 
        sb.append("\n}");
        sb.append("\n}");
 
        // 文件内容确定当前 间接生成即可
        JavaFileObject sourceFile = filer.createSourceFile(className + "ViewBinding");
        Writer writer = sourceFile.openWriter();
        writer.write(sb.toString());
        writer.close();}
 
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
 
        // key 就是应用注解的 class 的类名 element 就是应用注解自身的元素 一个 class 能够有多个应用注解的 field
        Map<String, List<Element>> fieldMap = new HashMap<>();
        // 这里 获取到 所有应用了 BindView 注解的 element
        for (Element element : roundEnvironment.getElementsAnnotatedWith(BindView.class)) {
            // 取到 这个注解所属的 class 的 Name
            String className = element.getEnclosingElement().getSimpleName().toString();
            // 取到值当前 判断 map 中 有没有 如果没有就间接 put 有的话 就间接在这个 value 中减少一个 element
            if (fieldMap.get(className) != null) {List<Element> elementList = fieldMap.get(className);
                elementList.add(element);
            } else {List<Element> elements = new ArrayList<>();
                elements.add(element);
                fieldMap.put(className, elements);
            }
        }
 
        // 遍历 map,开始生成辅助类
        for (Map.Entry<String, List<Element>> entry : fieldMap.entrySet()) {
            try {generateCodeByStringBuffer(entry.getKey(), entry.getValue());
            } catch (IOException e) {e.printStackTrace();
            }
        }
        return false;
    }
 
    // 要反对哪些注解
    @Override
    public Set<String> getSupportedAnnotationTypes() {return Collections.singleton(BindView.class.getCanonicalName());
    }
}

最初看下成果:

尽管生成的代码格局不太好看,然而运行起来是 ok 的。这里要留神一下 Element 这个接口,实际上应用编译期注解的时候 如果可能了解了 Element,那后续的工作就简略不少。

次要关注 Element 的这 5 个子类即可,举个例子:

package com.smart.annotationlib_2;//PackageElement | 示意一个包程序元素
//  TypeElement 示意一个类或接口程序元素。public class VivoTest {
    //VariableElement | 示意一个字段、enum 常量、办法或构造方法参数、局部变量或异样参数。int a;
 
    //VivoTest 这个办法 :ExecutableElement| 示意某个类或接口的办法、构造方法或初始化程序(动态或实例),包含正文类型元素。//int b 这个函数参数: TypeParameterElement | 示意个别类、接口、办法或构造方法元素的模式类型参数。public VivoTest(int b) {this.a = b;}
}

四、Javapoet 生成代码

有了下面的根底 再用 Javapoet 写一遍字符串拼接来生成 java 代码的过程,就不会难以了解了。

private void generateCodeByJavapoet(String className, List<Element> elements) throws IOException {
 
    // 申明构造方法
    MethodSpec.Builder constructMethodBuilder =
            MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC).addParameter(ClassName.bestGuess(className), "activity");
    // 构造方法外面 减少语句
    for (Element e : elements) {constructMethodBuilder.addStatement("activity." + e.getSimpleName() + "=activity.findViewById(" + e.getAnnotation(BindView.class).value() + ");");
    }
 
    // 申明类
    TypeSpec viewBindingClass =
            TypeSpec.classBuilder(className + "ViewBinding").addModifiers(Modifier.PUBLIC).addMethod(constructMethodBuilder.build()).build();
    String packageName = elementUtils.getPackageOf(elements.get(0)).getQualifiedName().toString();
     
    JavaFile build = JavaFile.builder(packageName, viewBindingClass).build();
    build.writeTo(filer);
}

这里要提一下,当初越来越多的人应用 Kotlin 语言开发 app,你甚至能够应用 https://github.com/square/kotlinpoet 来间接生成 Kotlin 代码。有趣味的能够尝试一下。

五、编译期注解的总结

首先是大家关注的性能方面,对于运行时注解来说,会产生大量的反射代码,而且反射调用的次数会随着我的项目复杂度的进步而变的越来越多,是一个逐渐劣化的过程,而对于编译期注解来说,反射的调用次数是固定的,他并不会随着我的项目复杂度的进步而变的性能越来越差,实际上对于大多数运行时注解的我的项目都能够通过编译期注解来大幅提高框架的性能,比方驰名的 Dagger、EventBus 等等,他们的首个版本都是运行时注解,后续版本都对立替换成了编译期注解。

其次回顾一下后面咱们编译期注解的开发流程当前,能够得出以下几点论断:

  • 编译期注解只能生成代码,然而不能批改代码;
  • 注解生成的代码 必须要手动被调用,他本人是不会被调用的;
  • 对于 SDK 的编写者来说, 即便是编译期注解,往往也免不了至多要走一次反射,而反射的作用次要就是调用你注解处理器生成的代码。

这里可能会有小伙伴问,既然编译期注解只能生成代码不能批改代码,那作用很无限啊,为啥不间接用相似于 ASM、Javassist 等字节码工具呢,这些工具岂但能够生成代码而且还能够批改代码,性能更强劲。因为这些字节码工具生成的间接是 class,且写法简单容易出错,也不易于调试,小规模写一下相似于避免疾速点击之类的货色还能够,大规模开发第三方框架其实也挺不不便的,远远不如编译期注解来的效率高。

此外,再认真想想,咱们前文中提到的编译期注解的写法做成第三方库给他人应用当前,还是须要使用者手动的在适合的机会调用一下“init”办法的,然而有些杰出的第三方库能够做到连 init 办法都不须要使用者手动调用了,应用起来十分不便,这又是怎么做到的?其实也不难,少数状况都是这些第三方库用编译期注解生成了代码当前,再配合 ASM 等字节码工具间接帮你调用了 init 办法,从而让你免去手动调用的过程。外围仍旧是编译期注解,只不过是用字节码工具省略了一步而已。

作者:vivo 互联网客户端团队 -Wu Yue

退出移动版