7、KAPT实战:AbstractProcessor的实现、代码生成与Filer API

好,咱们今天聊点硬核的。前面几章我们把KAPT的原理、注解处理流程都捋了一遍,但说实话,光看不练等于白搭。这一章,我带你亲手写一个AbstractProcessor,把代码生成出来。

你可能要问:为什么要自己写代码生成?用运行时反射不香吗?嗯,我刚开始也是这么想的。直到我在一个大型项目里,发现运行时反射拖慢了启动速度,而且ProGuard一混淆,反射就炸了。从那以后,我彻底转向了编译期处理。

7.1 AbstractProcessor 的核心结构

写一个注解处理器,说白了就是继承 AbstractProcessor,然后重写几个关键方法。我习惯先搭个骨架,再往里填逻辑。

@AutoService(Processor.class)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedOptions("kapt.kotlin.generated")
public class MyProcessor extends AbstractProcessor {

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        // 这里可以拿到 Filer、Messager、Elements 等工具
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        return Collections.singleton(MyAnnotation.class.getCanonicalName());
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // 核心逻辑写在这里
        return true;
    }
}

这里有个细节:@AutoService 注解。我早期手写过 META-INF/services 文件,后来发现用 Google 的 auto-service 库能自动生成,省心多了。你想想看,每次加个处理器都要手动配文件,多容易漏啊。

7.2 核心方法详解

init() 方法

这个方法在处理器初始化时调用一次。我一般在这里做三件事:

  • 保存 processingEnv 引用
  • 获取 Filer 对象(用于生成文件)
  • 获取 Messager 对象(用于打印日志)
private Filer filer;
private Messager messager;

@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    filer = processingEnv.getFiler();
    messager = processingEnv.getMessager();
    messager.printMessage(Diagnostic.Kind.NOTE, "处理器初始化完成");
}

我个人习惯在 init 里打印一条日志,这样能确认处理器是否被正确加载。曾经有一次我配了半天发现处理器没跑,最后发现是 @AutoService 依赖没加对……嗯,这种坑踩过一次就记住了。

getSupportedAnnotationTypes()

这个方法返回处理器要处理的注解类型。注意,这里返回的是全限定名,不是注解类本身。

@Override
public Set<String> getSupportedAnnotationTypes() {
    Set<String> annotations = new LinkedHashSet<>();
    annotations.add("com.example.BindView");
    annotations.add("com.example.OnClick");
    return annotations;
}

你也可以用 @SupportedAnnotationTypes 注解来声明,效果一样。我个人更倾向于用代码方式,因为可以动态判断。

process() 方法

这是核心中的核心。每次注解处理轮次都会调用这个方法。我一般这样组织代码:

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    if (roundEnv.processingOver()) {
        return false;
    }

    for (Element annotatedElement : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {
        if (annotatedElement.getKind() != ElementKind.CLASS) {
            messager.printMessage(Diagnostic.Kind.ERROR, "只能用在类上", annotatedElement);
            continue;
        }
        TypeElement typeElement = (TypeElement) annotatedElement;
        generateCode(typeElement);
    }
    return true;
}

为什么要检查 processingOver()?因为最后一轮处理完成后,系统会再调一次 process,这时候不需要做任何事。我之前没加这个判断,结果在最后一轮生成了空文件,编译报错,排查了半天。

7.3 代码生成与 Filer API

好,终于到了最激动人心的部分——生成代码。Filer API 提供了三种创建文件的方式:

方法 用途 示例
createSourceFile 生成 Java 源文件 生成 Activity_Binding.java
createClassFile 生成编译后的 class 文件 直接输出字节码
createResource 生成资源文件 生成 META-INF 配置文件

我项目中用得最多的是 createSourceFile。说白了就是写一个 Java 文件,只不过是用代码来写代码。

private void generateCode(TypeElement typeElement) {
    String className = typeElement.getSimpleName() + "_Binding";
    String packageName = processingEnv.getElementUtils().getPackageOf(typeElement).toString();

    try {
        JavaFileObject sourceFile = filer.createSourceFile(
                packageName + "." + className, typeElement);
        Writer writer = sourceFile.openWriter();
        writer.write(buildClassCode(packageName, className, typeElement));
        writer.close();
    } catch (IOException e) {
        messager.printMessage(Diagnostic.Kind.ERROR, "生成代码失败: " + e.getMessage());
    }
}

private String buildClassCode(String packageName, String className, TypeElement typeElement) {
    StringBuilder sb = new StringBuilder();
    sb.append("package ").append(packageName).append(";\n\n");
    sb.append("public class ").append(className).append(" {\n");
    sb.append("    public static void bind(").append(typeElement.getQualifiedName()).append(" target) {\n");
    sb.append("        // 生成的绑定代码\n");
    sb.append("    }\n");
    sb.append("}\n");
    return sb.toString();
}

这里有个坑:createSourceFile 的第一个参数是类的全限定名,第二个参数是触发生成的元素。如果你传了第二个参数,生成的代码会和原文件关联起来,这样增量编译时能正确清理旧文件。我之前没传第二个参数,结果改了注解后旧文件还在,编译报重复类错误。

注意:生成的代码不要放在源文件目录下,KAPT 会自动放到 build/generated/source/kapt 目录。你不需要手动指定路径。

7.4 实战:生成一个简单的 ViewBinding 代码

光说不练假把式。咱们写一个完整的例子:根据 @BindView 注解生成 ViewBinding 代码。

先定义注解:

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

然后处理器核心逻辑:

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    for (Element element : roundEnv.getElementsAnnotatedWith(BindView.class)) {
        if (element.getKind() != ElementKind.FIELD) {
            continue;
        }
        VariableElement field = (VariableElement) element;
        TypeElement enclosingClass = (TypeElement) field.getEnclosingElement();
        int viewId = field.getAnnotation(BindView.class).value();
        String fieldName = field.getSimpleName().toString();
        String fieldType = field.asType().toString();

        // 生成代码
        generateBinding(enclosingClass, fieldName, fieldType, viewId);
    }
    return true;
}

生成代码的方法:

private void generateBinding(TypeElement clazz, String fieldName, String fieldType, int viewId) {
    String bindingClassName = clazz.getSimpleName() + "_ViewBinding";
    String packageName = processingEnv.getElementUtils().getPackageOf(clazz).toString();

    try {
        JavaFileObject file = filer.createSourceFile(packageName + "." + bindingClassName, clazz);
        try (PrintWriter out = new PrintWriter(file.openWriter())) {
            out.println("package " + packageName + ";");
            out.println();
            out.println("public class " + bindingClassName + " {");
            out.println("    public static void bind(" + clazz.getQualifiedName() + " target) {");
            out.println("        target." + fieldName + " = (" + fieldType + ")");
            out.println("            target.findViewById(" + viewId + ");");
            out.println("    }");
            out.println("}");
        }
    } catch (IOException e) {
        messager.printMessage(Diagnostic.Kind.ERROR, "生成失败: " + e.getMessage());
    }
}
小技巧:用 try-with-resources 自动关闭 Writer,省得忘了 flush 或 close。我早期写代码经常忘关流,导致生成的文件内容不完整。

7.5 知识体系总览

下面这张图是我自己总结的,把整个流程串起来看会更清晰:

KAPT 注解处理器工作流程 1. init() 初始化 获取 Filer、Messager 2. 收集注解元素 getSupportedAnnotationTypes 3. process() 处理 遍历 RoundEnvironment 4. 代码生成 Filer.createSourceFile 5. 写入文件 Writer.write() 6. 参与编译 生成 .java 文件 多轮处理直到没有新注解 关键点:Filer API 负责文件创建,Messager 负责错误报告 生成的代码位于 build/generated/source/kapt 目录

7.6 避坑指南

最后,我把自己踩过的坑总结一下,希望能帮你少走弯路:

  • 文件重复生成:每次 process 调用都会生成文件,记得用 roundEnv.processingOver() 判断是否最后一轮
  • 包名错误:生成代码的包名要和原类一致,否则 import 会找不到
  • 增量编译问题:createSourceFile 的第二个参数一定要传触发元素,否则增量编译不会清理旧文件
  • 编码问题:生成文件默认用 UTF-8,如果你项目用了其他编码,记得在 Writer 里指定

核心要点:

  • AbstractProcessor 的 init 里获取 Filer 和 Messager
  • process 方法里遍历注解元素,生成对应的 Java 文件
  • Filer.createSourceFile 是生成代码的主要方式
  • 生成的代码会自动参与编译,不需要额外配置

好了,这一章的内容就到这里。代码生成这块,说白了就是「用代码写代码」,掌握了 Filer API 的用法,剩下的就是字符串拼接的功夫了。我建议你动手写一个简单的处理器,哪怕只是生成一个 HelloWorld 类,也比光看强十倍。


公众号:蓝海资料掘金营,微信deep3321