Loading...
墨滴

掀乱书页的风

2021/10/16  阅读:49  主题:兰青

让注解处理器支持增量编译

背景


[kapt] Incremental annotation processing 
requested, but support is disabled because the
 following processors are not incremental:  
com.wuba.wbrouter.compiler.processor.  
AutoWiredProcessor (NON_INCREMENTAL), 
com.wuba.wbrouter.compiler.processor.Intercept
orProcessor (NON_INCREMENTAL), 
com.wuba.wbrouter.compiler.processor.RouteProc
essor (NON_INCREMENTAL).

在开发编译的时候,报了这么一段黄色警告,什么意思呢,注解处理器需要增量编译,但是当前是不支持的

什么是增量编译

所谓增量编译,是指当源程序的局部发生变更后进重新编译的工作只限于修改的部分及与之相关部分的内容,而不需要对全部代码进行编译。与之对应的是全量编译,无论修改了什么,全量编译都将进行一次全新的完整的编译。

增量编译的目标:尽可能少得更改输入类,让build的速度更快

android方案

Instant Run和Apply Changes,以及我们组内开发的Wafers

Instant Run(过时)

Instant Run是Android Studio 2.0版本推出的一个增量编译功能,使用Instant Run功能时,需要在build.gradle 文件中将 minSdkVersion 设置为 15 或以上时,并且为另外获得最佳性能,可以将 minSdkVersion 设置为 21 或更高。

Android Studio在3.5版本废弃了Instant Run,这里就不再详细介绍了

Apply Changes

在Android Studio 3.5及其以上版本,官方提供了Apply Changes,使用Apply Changes时,需要满足以下两个条件:

apk必须是debug包; Android 8.0及以上的手机上运行 当我们使用Android Studio运行项目后,会在菜单栏看见3个按钮,分别用来控制应用重启,如下图所示。

如上图所示,从左到右的按钮分别表示【Run】、【Apply Changes 】和【Apply Code Changes】。

Run:将部署所有的变化并重启应用 。 Apply Changes: 将尝试应用变化的资源和代码,并仅重启Activity而不需要重启整个应用。 Apply Code Changes :将尝试在不重启操作的情况下应用变化的代码,如果只有代码修改,可以使用此按钮来使代码生效。 不过,由于Apply Changes仅支持在Android 8.0 或者更高版本的手机上运行,并且实际操作时在工程中带来的提速效果也不明显。

类的结构性重定义是一个运行时功能,它扩展了 Android 8 中引入的重定义类方法,Apply Changes 可以通过它来改变类的自身结构,并可以在类中增加变量或者方法

wafers

基于AAB构建出一个 base APK 和多个 dynamic feature APK,在业务线开发时,只构建它自身的 featureAPK,并且可以利用并行构建的特性,最后使用 adb install-multiple 命令安装到手机上

Compile avoidance(编译避免)

Compile avoidance不同于增量编译,那么如何理解compile avoidance呢 假设你的app项目依赖core项目,而core项目本身又依赖utils项目 在app项目中有以下代码

public class Main {
   public static void main(String... args) {
        WordCount wc = new WordCount();
        wc.collect(new File(args[0]);
        System.out.println("Word count: " + wc.wordCount());
   }
}

在core项目中有以下代码

public class WordCount {  // WordCount lives in project `core`
   // ...
   void collect(File source) {
       IOUtils.eachLine(source, WordCount::collectLine);
   }
}

在utils项目中有以下代码

public class IOUtils { // IOUtils lives in project `utils`
    void eachLine(File file, Callable<String> action) {
        try {
            try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
                // ...
            }
        } catch (IOException e) {
            // ...
        }
    }
}

现在,要修改IOUtils的实现。比如,修改eachLine方法,使用utf8编码而不是缺省编码

public class IOUtils { // IOUtils lives in project `utils`
    void eachLine(File file, Callable<String> action) {
        try {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), "utf-8") )) {
                // ...
            }
        } catch (IOException e) {
            // ...
        }
    }
}

现在重新构建app项目,会发生什么情况?utils需要重新编译,而由于依赖关系它又会触发core和app重新编译。乍一看,很合理。但真的合理吗?

IOUtils的修改只是内部细节的改变。eachLine的实现变了,但它的公开API并没有变化。之前已经编译好的依赖IOUtils的class文件其实仍然有效。这个例子看似简单,其实是一种很常见的模式:core项目通常被很多子项目共享,而每个子项目又依赖于其他子项目。core项目的改动会导致所有项目都要重新编译。Gradle可以判断是否ABI(Application Binary Interface)不兼容的修改,只有ABI不兼容的修改才会引起所有项目重新编译。

这就是我们所谓的compilation avoidance。

API和ABI

API是Application Programming Interface的缩写,即应用程序接口。 一个API是不同代码片段的连接纽带。它定义了一个函数的参数,函数的返回值,以及一些属性比如继承是否被允许。 因此API是用来约束编译器的:一个API是给编译器的一些指令,它规定了源代码可以做以及不可以做哪些事。

ABI是Application Binary Interface的缩写,应用程序二进制接口。 一个ABI是不同二进制片段的连接纽带。 它定义了函数被调用的规则:参数在调用者和被调用者之间如何传递,返回值怎么提供给调用者,库函数怎么被应用,以及程序怎么被加载到内存。 因此ABI是用来约束链接器的:一个ABI是无关的代码如何在一起工作的规则

注解(annotation)的分类

注解的本质就是一个继承了 Annotation 接口的接口 不同的划分方式将会导致不同的种类

按照来源划分:

a. 元注解( Target, Retention, Documented, Inherited, Repeatable(1.8))

b. 自定义注解(JDK内置的注解,第三方库的注解,我们自己定义的注解)

按照注解的时机(作用域)划分:

i. Source Code 注解

ii. 编译时注解

iii.运行时注解

一个注解准确意义上来说,只不过是一种特殊的注释而已,如果没有解析它的代码,它可能连注释都不如。

注解处理器(annotation processor)

annotation processors是一种强大的机制,可以通过注解的方式生成代码。典型的使用场景包括依赖注入(比如Dagger),或者是减少样板代码(比如Butterknife)。但是使用annotation processor对构建性能有很多负面影响,因为构建的时候会插入自定义的一些操作。

annotation processor原理

annotation processor基本上可以理解成一个Java编译器插件。在Java编译器识别到一个可被processor处理的注解时就会触发这个processor。从构建工具角度而言,它是一个墨盒:我们并不知道annotation processor到底做了什么操作,尤其是annotation processor生成了什么文件,这些文件放在哪里。

我们一般在处理注解时,都需要来继AbstractProcessor

An annotation that is present in the binary form may or may not be available at run time via the reflection libraries of the Java SE Platform. (二进制文件中的注解是通过Java SE平台的反射库来决定是否起作用的)

另外,在RetentionPolicy的源码中也能得到反射的线索

public enum RetentionPolicy {
    /**
     * Annotations are to be discarded by the compiler.
     * 注解被编译器丢弃,只保留在源码中 比如平时对代码的逻辑说明
     */
    SOURCE,

    /**
     * Annotations are to be recorded in the class file by the compiler
     * but need not be retained by the VM at run time.  This is the default
     * behavior.
     * 注解将会被编译器记录在Class文件中,但是运行时不必被虚拟机保留。默认行为 比如 @Override @Suppvisewarnings 等,他们只在编译时起作用,按照注解的指定来编译
     */
    CLASS,

    /**
     * Annotations are to be recorded in the class file by the compiler and
     * retained by the VM at run time, so they may be read reflectively.
     * 注解将会被编译器记录在Class文件中,同时在运行时被虚拟机保留。所以通过反射读取。比如 Retrofit,Eventbus
     *
     * @see java.lang.reflect.AnnotatedElement
     */
    RUNTIME

通过上面的信息,我们明确地知道处理注解的武器 -———— 反射

下面我们可以来看一下AbstractProcessor的部分源码:

第一个方法process() 是个抽象方法,它的逻辑是让我们开发者来续写。

/**
     * {@inheritDoc}
     */
    public abstract boolean process(Set<? extends TypeElement> annotations,
                                    RoundEnvironment roundEnv);

第二个方法getSupportedAnnotationTypes()

public Set<String> getSupportedAnnotationTypes() {
            // 从这里我们可以看出,根据反射来处理SupportedAnnotationTypes注解
            SupportedAnnotationTypes sat = this.getClass().getAnnotation(SupportedAnnotationTypes.class);
            boolean initialized = isInitialized();
            if  (sat == null) {
                if (initialized)
                    processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING,
                                                             "No SupportedAnnotationTypes annotation " +
                                                             "found on " + this.getClass().getName() +
                                                             ", returning an empty set.");
                return Set.of();
            } else {
                // 这里有个版本1.8的限制,这是因为在JDK1.9才开始支持module概念
                boolean stripModulePrefixes =
                        initialized &&
                        processingEnv.getSourceVersion().compareTo(SourceVersion.RELEASE_8) <= 0;
                // 这里sat.value其实就是SupportedAnnotationTypes的注解类型值,往下看
                return arrayToSet(sat.value(), stripModulePrefixes,
                                  "annotation type""@SupportedAnnotationTypes");
            }
        }


@Documented
@Target(TYPE)
@Retention(RUNTIME)
public @interface SupportedAnnotationTypes {
    /**
     * Returns the names of the supported annotation types.
     * @return the names of the supported annotation types
     */
    String [] value(); // 上面的sat.value是数组类型。
}

让注解处理器支持增量编译(Making an annotation processor incremental)

Gradle支持对两种常见的注解处理器进行增量编译:隔离(Isolating) 和 聚合(Aggregating)。注解处理器的增量也是基于Java的增量编译

当编译Java类文件时,通过compileJavaWithJavac这个task实现,具体处理类是AndroidJavaCompile和JavaCompile首先做一些预处理操作,如校验注解类型,判断编译配置是否允许增量编译等。如果判断结果为全量编译,则直接走接下来的编译流程;如果判断结果为增量编译,还会进一步确定修改的影响范围,并把所有受到影响的类都作为编译的输入,再走接下来的编译流程。

增量处理的配置方式

简而言之,就是在我们../META-INF/gradle/目录下的注解处理器加上这些字段(isolating,dynamic,aggregating)即可

  1. 首先是配置: 隔离 or 动态
processor/src/main/resources/META-INF/gradle/incremental.annotation.processors

EntityProcessor,isolating  // 隔离模式
EntityProcessor,aggregating  // 聚合模式
ServiceRegistryProcessor,dynamic // 动态选项,这里的模式是需要覆写getSupportedOptions()来设置的。

如果处理器只能在运行时决定它是否是增量的,则可以将其声明为动态(Dynamic)

  1. 具体到代码中
src/main/java/com/bert/processor/DynamicProcessor.java

@Override
public Set<String> getSupportedOptions() {
    return Collections.singleton("org.gradle.annotation.processing.aggregating");//聚合模式
}

我们比较关心的是这两种增量编译如何使用呢,官方并没有给出很详细的教程,这个时候我们不妨参考一下业内一些优秀的框架

EventBus VS ButterKnife

这两者内部都是借助于注解来实现其功能的。那么这两个优秀的开源框架在处理注解方面有哪些需要我们借鉴or改善的地方呢? 我们知道处理注解离不开注解处理器(AbstractProceesor),我们先来看一下两者在解释器上的区别

聚合注解处理器(Aggregating annotation processors)

顾名思义,这种模式就是将多个源文件聚合为一个或者多个输出文件或者验证信息。 EventBusAnnotationProcessor

@SupportedAnnotationTypes("org.greenrobot.eventbus.Subscribe")
@SupportedOptions(value = {"eventBusIndex""verbose"})
@IncrementalAnnotationProcessor(AGGREGATING)
public class EventBusAnnotationProcessor extends AbstractProcessor {

/** Found subscriber methods for a class (without superclasses). */
 private final ListMap<TypeElement, ExecutableElement> methodsByClass = new ListMap<>();

@Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {

        ...
            /*将注解方法(@Subscribe)都缓存起来*/
            collectSubscribers(annotations, env, messager);
            checkForSubscribersToSkip(messager, indexPackage);
            /*如果有注解方法(@Subscribe),则创建文件*/
            if (!methodsByClass.isEmpty()) {
                createInfoIndexFile(index);
            } else {
                messager.printMessage(Diagnostic.Kind.WARNING, "No @Subscribe annotations found");
            }
        ...
    }

}


 private void createInfoIndexFile(String index) {
        BufferedWriter writer = null;
        try {  
            // 跟官方示例一样,创建一个文件
            JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(index);
            int period = index.lastIndexOf('.');
            String myPackage = period > 0 ? index.substring(0, period) : null;
            String clazz = index.substring(period + 1);
            writer = new BufferedWriter(sourceFile.openWriter());
            if (myPackage != null) {
                writer.write("package " + myPackage + ";\n\n");
            }
            ...
            // 关键方法
            writeIndexLines(writer, myPackage);
           
            ...
        } catch (IOException e) {
            throw new RuntimeException("Could not write source for " + index, e);
        } finally {
            if (writer != null) {
                try {
                    writer.close();
                } catch (IOException e) {
                    //Silent
                }
            }
        }
    }


 private void writeIndexLines(BufferedWriter writer, String myPackage) throws IOException {
        // 遍历缓存(methodsByClass),然后继续写入到上面生成的文件。
        for (TypeElement subscriberTypeElement : methodsByClass.keySet()) {
            if (classesToSkip.contains(subscriberTypeElement)) {
                continue;
            }

            String subscriberClass = getClassString(subscriberTypeElement, myPackage);
            if (isVisible(myPackage, subscriberTypeElement)) {
                writeLine(writer, 2,
                        "putIndex(new SimpleSubscriberInfo(" + subscriberClass + ".class,",
                        "true,""new SubscriberMethodInfo[] {");
                List<ExecutableElement> methods = methodsByClass.get(subscriberTypeElement);
                writeCreateSubscriberMethods(writer, methods, "new SubscriberMethodInfo", myPackage);
                writer.write("        }));\n\n");
            } else {
                writer.write("        // Subscriber not visible to index: " + subscriberClass + "\n");
            }
        }
    }

总结:EventBus采用的是聚合方式,将所有被(@Subscribe)注解方法都写入到同一个文件中

聚合的局限性

聚合模式下,注解解释器只能处理CLASS or RUNTIME类型的注解
另外只在用户传递-parameters编译器参数时读取参数名。

Gradle将始终重新处理(但不是重新编译)处理器注册的所有带注释的文件并总是重新编译处理器生成的所有文件。

隔离注解解释器(Isolating annotation processors)

这种模式非常快,单独查看每个独立的注解元素并为其创建文件或者验证信息。

这里我们就拿ButterKnife举例

@AutoService(Processor.class)
@IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.DYNAMIC)
@SuppressWarnings("NullAway") // TODO fix all these...
public final class ButterKnifeProcessor extends AbstractProcessor {

/* 设置动态选项必定要重写该方法,并在此方法中选择模式:此处可以看到是"隔离"模式*/
 @Override public Set<String> getSupportedOptions() {
    ImmutableSet.Builder<String> builder = ImmutableSet.builder();
    builder.add(OPTION_SDK_INT, OPTION_DEBUGGABLE);
    if (trees != null) {
      builder.add(IncrementalAnnotationProcessorType.ISOLATING.getProcessorOption());
    }
    return builder.build();
  }
    /*需要注意的是ButterKnife使用的是JavaPoet库来生成源文件的*/
  @Override public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) {
    Map<TypeElement, BindingSet> bindingMap = findAndParseTargets(env);
    // 遍历注解并对应生成独立的文件
    for (Map.Entry<TypeElement, BindingSet> entry : bindingMap.entrySet()) {
      TypeElement typeElement = entry.getKey();
      BindingSet binding = entry.getValue();

      JavaFile javaFile = binding.brewJava(sdk, debuggable);
      try {
        javaFile.writeTo(filer);
      } catch (IOException e) {
        error(typeElement, "Unable to write binding for type %s: %s", typeElement, e.getMessage());
      }

    }

    return false;
  }

}

ButterKnife用的是『隔离模式』的动态选项,并使用JavaPoet(基于JavaFiler API)来生成源文件。

隔离模式的局限性

这种模式必须根据AST(Abstract Syntax Tree抽象语法)所能访问到的信息为注解类型做出决策(如代码生成,验证消息等...),这意味着我们可以分析类型的父类,方法返回类型,注释等,甚至可以进行传递性分析。但是我们不能基于RoundEnvironment中不相关的元素做出决策,这样做很少文件会被重新编译而导致失败。(PS:如果您的处理器需要基于其他无关元素的组合来做出决策,请将其标记为“聚合”。)

它们必须为使用Filer API生成的每个文件提供一个源元素。如果提供零个或多个源元素,Gradle将重新编译所有源文件。(简而言之,就是One to One)

另外,如果有一个源文件被重新编译,Gradle将会重新编译其生成的所有文件. 当一个源文件被删除,Gradle将会删除它所产生的所有文件。

动态选项

If your processor can only decide at runtime whether it is incremental or not, you can declare it as "dynamic" in the META-INF descriptor and return its true type at runtime using the Processor#getSupportedOptions() method.

简而言之,就是如果我们的注解解释器只在Runtime时决定是否增量,就加上『dynamic』。很显然,ButterKnife已经应用上啦。

问题解决

WBRouter中实现方式建议

AutoWiredProcessor: javapoet实现,生成多个单独文件,可以采用隔离模式(Isolating) RouteProcessor:javapoet实现,生成多个单独文件,可以采用隔离模式(Isolating) InterceptorProcessor:虽然也是javapoet实现,看实现过程,类似多个源文件合成一个的方式,可以采用聚合模式(Aggregating)

总结

  1. 查了很多资料,并没有一个定论,说必须使用哪种方式,我们可以参考EventBus、ButterKnife的实现方式,向隔离(Isolating) 或 聚合(Aggregating)的增量编译方向靠拢。
  2. 注解处理器已经支持增量编译了,我们在开发自己的插件时,如果用到注解处理器,尽量支持增量编译,否则的话,gradle在编译时会报黄色警告
 [INFO] Incremental KAPT support is disabled. Processors that are not incremental

虽然暂时不受影响,但是这个也是我们需要优化的一个地方

参考文章

https://blog.gradle.org/incremental-compiler-avoidance https://www.sunmoonblog.com/2018/09/13/gradle-incremental-compilation/

掀乱书页的风

2021/10/16  阅读:49  主题:兰青

作者介绍

掀乱书页的风