The importance of compile-time annotations in development

From the early days of the amazing ButterKnife to various routing frameworks led by ARouter and now Google’s aggressive Jetpack component, compile-time annotations are being used by a growing number of third-party frameworks. Compile-time annotations are a basic technique that you need to master whether you want to delve into the mechanics of these third-party frameworks or become a senior Android developer.

This article starts with the basic use of run-time annotations and progresses to compile-time annotations, giving you a real idea of where, how, and why compile-time annotations should be used.

Write run-time annotations

In the same way as the following, when there are too many lines of findViewById, it is difficult to write them by hand. Let’s first try to solve this problem with runtime annotations and see if we can automate the findViewById operations.

The first is the engineering structure, which must define a Lib Module.

Next, define our annotation class:

With this annotation class, we can use our MainAcitivity first, even though the annotation is not working yet.

Think about it a little bit. What we need to do at this point is annotate R.I.D.X to the corresponding field, which is the view object that you define (such as TV in the red box). For our lib project, MainActivity depends on the lib. Naturally, your lib can no longer rely on the app project that Main belongs to, for two reasons:

  • If A is dependent on B and B is dependent on A, an error will be reported.

  • If you’re going to make a lib you can’t depend on the host of the user otherwise how can you call it a lib?

So the problem becomes that the lib project can only get the Acitivty, but not the host MainActivity. If I can’t get the host MainActivity, how do I know how many fields this activity has? This is where the reflection comes in.

public class BindingView {
 
    public static void init(Activity activity) {
        Field[] fields = activity.getClass().getDeclaredFields();
        for (Field field : fields) {
            // Get annotated
            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();
                }
            }
 
        }
 
    }
}
Copy the code

Finally, we call this method in the host’s MainActivity:

At this point, some people are going to ask, this runtime annotation doesn’t look so hard, why don’t many people use it? The problem is that the reflected methods have a known performance cost to the Android runtime, and the code here is a loop, which means that the code gets slower and slower as the interface complexity of your Lib Activity increases. This is a process that deteriorates as your interface gets more complex. Single reflection is almost no longer a performance drain on today’s phones, but the use of reflection in this for loop should be minimized.

Write compile-time comments

To solve this problem, compile time annotations are used. Now let’s try to solve this problem with compile-time annotations. As mentioned earlier, runtime annotations can use reflection to fetch host fields to fulfill requirements. To solve the performance problem of reflection, we actually want code that looks like this:

We can create a MainActivityViewBinding class in our app module:

Then call this method in our BindingView(notice that our BindingView is in the Lib Module) to solve the reflection problem.

The problem with this is that since you’re a lib you can’t rely on the host, so you can’t actually get the MainActivityViewBinding class in the Lib Module, you still have to use reflection.

Take a look at the code commented out above, why not just write the string dead? Because you’re a lib library and of course you have to be dynamic, otherwise how can anyone else use it? Just take the class name of the host and add a fixed suffix ViewBinding. At this point we have this Binding class, right, and all we have to do is call the constructor.

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(); }}}Copy the code

Look at the code structure at this point:

Somebody here is going to say, you’re still using reflection here, right! I’m using reflection, but my reflection method is only called once. No matter how many fields your activity has, my reflection method is only executed once. So the performance must be many times faster than the previous scheme. Now, the code is working fine, but there’s still a problem. I can call the constructor of my app’s host class in lib, but it’s still handwritten. So your lib library still doesn’t do anything to make us write less code.

This is where apt comes in, the heart of compile-time annotations. Let’s create a Java Library, note that the Java Lib is not the Android Lib, and introduce it into the App Module.

Notice that it’s not imp, it’s annotation Processor;

Let’s change lib_processor by creating an annotation handler class:

To create a file resources/meta-inf/services/javax.mail annotation. Processing. The Processor, to note here folder to create don’t write wrong.

Then this Processor specifies our annotation Processor:

We need to tell the annotation handler to process only our BindView annotation. Otherwise, the annotation handler is too slow to process all annotations by default. However, our BindView annotation class is still in the lib repository.

Let’s create a new Javalib, just put BindView in it, and let our lib_processor and app rely on this lib_interface. Modify the code a little bit, so we’re doing compile-time processing, so the Policy doesn’t have to be Runtime anymore.

@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.FIELD)
public @interface BindView {
    int value(a);
}
Copy the code
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;
    }
 
    // Which annotations to support
    @Override
    public Set<String> getSupportedAnnotationTypes(a) {
        returnCollections.singleton(BindView.class.getCanonicalName()); }}Copy the code

That’s when most of our work is done. Take a look at the code structure (the code structure must understand why it is designed this way, otherwise you won’t learn compile-time annotations).

We can now use the lib SDK to call the method MainActivityViewBinding, but it’s still in the app store. It’s not smart enough to use it. We need to dynamically generate this class in the annotation handler, as long as we can do this step, then our SDK is basically complete.

It should be mentioned here that many people can’t learn annotations and are stuck here, because too many articles or tutorials are based on the Javapoet code, and they can’t learn it at all, or they can only copy and paste other people’s stuff, and they can’t change it a little bit. The best way to learn is to use the StringBuffer concatenation to spell out the code you want, use the string concatenation process to understand the API and how to generate the Java code, and then use JavaPoet to optimize the code.

We can start by thinking about what we would accomplish if we did this class generation by concatenating strings.

  • The first step is to get which classes use our BindView annotation;

  • Get the BindView annotated fields in these classes and their corresponding values;

  • Get the class names of these classes so that we can generate class names like MainActivityViewBinding;

  • Get the package names of these classes, because the generated class needs to belong to the same package as the annotated class to avoid field access issues;

  • Once all of these conditions are met, we can use string concatenation to concatenate the Java code we want.

This is the code directly, the important part directly look at the comments, with the steps above analysis and then look at the code comments should not be difficult to understand.

public class BindingProcessor extends AbstractProcessor {
 
    Messager messager;
    Filer filer;
    Elements elementUtils;
 
    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        // The main output is some important log use
        messager = processingEnvironment.getMessager();
        // Think of it as the important output parameters that we will eventually use to write Java files
        filer = processingEnvironment.getFiler();
        // Some handy utils methods
        elementUtils = processingEnvironment.getElementUtils();
        Diagnostic.kind. ERROR is an important parameter that can be used to fail the compilation. You can use this to tell the user what did you write wrong
        messager.printMessage(Diagnostic.Kind.NOTE, " BindingProcessor init");
        super.init(processingEnvironment);
    }
 
    private void generateCodeByStringBuffer(String className, List<Element> elements) throws IOException {
 
        // The class you want to generate must belong to the same package as the annotated class, so take the name of the package
        String packageName = elementUtils.getPackageOf(elements.get(0)).getQualifiedName().toString();
        StringBuffer sb = new StringBuffer();
        // Each Java class begins with 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);
 
        // Define the beginning of the constructor
        String constructorName = "public " + className + "ViewBinding(" + className + " activity){ \n";
        sb.append(constructorName);
 
        // Iterate over all the elements to generate statements such as activity.tv=activity.findViewById(R.i.D.xx)
        for (Element e : elements) {
            sb.append("activity." + e.getSimpleName() + "=activity.findViewById(" + e.getAnnotation(BindView.class).value() + "); \n");
        }
 
        sb.append("\n}");
        sb.append("\n }");
 
        // Generate the file directly after the content is determined
        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 is the name of the class that uses the annotation. Element is the element that uses the annotation itself. A class can have multiple fields that use the annotation
        Map<String, List<Element>> fieldMap = new HashMap<>();
        // Get all elements that use the BindView annotation
        for (Element element : roundEnvironment.getElementsAnnotatedWith(BindView.class)) {
            // Get the Name of the class to which the annotation belongs
            String className = element.getEnclosingElement().getSimpleName().toString();
            // Add an element to the value. // Add an element to the value
            if(fieldMap.get(className) ! =null) {
                List<Element> elementList = fieldMap.get(className);
                elementList.add(element);
            } else {
                List<Element> elements = newArrayList<>(); elements.add(element); fieldMap.put(className, elements); }}// Start generating helper classes by iterating over the map
        for (Map.Entry<String, List<Element>> entry : fieldMap.entrySet()) {
            try {
                generateCodeByStringBuffer(entry.getKey(), entry.getValue());
            } catch(IOException e) { e.printStackTrace(); }}return false;
    }
 
    // Which annotations to support
    @Override
    public Set<String> getSupportedAnnotationTypes(a) {
        returnCollections.singleton(BindView.class.getCanonicalName()); }}Copy the code

Here’s the final effect:

The generated code is not a pretty format, but it works fine. One thing to note here is that the Element interface is actually easier to understand when using compile-time annotations.

Focus on the five subclasses of Element. Here’s an example:

package com.smart.annotationlib_2;/ / PackageElement | said a package program elements
// TypeElement represents a class or interface element.
public class VivoTest {
    / / VariableElement | said a field, enum constants, the method or constructor parameters, local variables, or abnormal.
    int a;
 
    / / VivoTest this method: ExecutableElement | said the method, the construction method of a class or interface or initialization program (static or instance), including the annotation type elements.
    / / int the function parameter b: TypeParameterElement | said general class, interface, method or constructor element in the form of a type parameter.
    public VivoTest(int b ) {
        this.a = b; }}Copy the code

Javapoet generates code

With the basics above, the process of writing string concatenations to generate Java code in Javapoet should not be too hard to understand.

private void generateCodeByJavapoet(String className, List<Element> elements) throws IOException {
 
    // Declare the constructor
    MethodSpec.Builder constructMethodBuilder =
            MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC).addParameter(ClassName.bestGuess(className), "activity");
    // Add a statement to the constructor
    for (Element e : elements) {
        constructMethodBuilder.addStatement("activity." + e.getSimpleName() + "=activity.findViewById(" + e.getAnnotation(BindView.class).value() + ");");
    }
 
    / / class declaration
    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);
}
Copy the code

It should be mentioned that more and more people are using Kotlin to develop apps. You can even use github.com/square/kotl… To generate Kotlin code directly. Those who are interested can try it.

Summary of compile-time annotations

First is the performance of attention, for runtime notes, will produce a lot of reflection code, and the number of reflection calls will be as the project becomes more and more, with the increase of complexity is a progressive deterioration process, and for compile-time annotation, the reflection of the call number is fixed, he will not be with the improvement of project complexity and performance worse and worse, In fact, compile-time annotations can greatly improve the performance of the framework for most runtime annotated projects, such as the Dagger, EventBus, etc. The first version of these annotations was runtime annotations, and later versions were replaced with compile-time annotations.

Secondly, after reviewing our previous compile-time annotation development process, we can draw the following conclusions:

  • Compile-time annotations can only generate code, but cannot modify it;

  • The code generated by the annotation must be called manually, it will not be called itself;

  • For SDK writers, even compile-time annotations often require at least one reflection, and the main purpose of reflection is to call the code generated by your annotation processor.

Some people may ask that compile-time annotations can only generate code, but not modify it. So why not just use bytecode tools like ASM and Javassist, which can not only generate code but also modify it, which is much more powerful? Because these bytecode tools generate classes directly and are complex and error-prone, they are not easy to debug. Writing things like preventing quick clicks on a small scale is fine, but developing third-party frameworks on a large scale is not as efficient as compiling annotations.

In addition, think again, before we compile time mentioned in the article written notes to make third-party libraries to others after use, still need the user manual at the right time to call it “init” method, but some excellent third-party libraries can do don’t even need the user manual call the init method, use rise very convenient, How does that work? In most cases, these third-party libraries generate code with compile-time annotations, and then work with a bytecode tool like ASM to call the init method directly for you, so you don’t have to call it manually. The core is still compile-time annotations, but with bytecode tools that omit a step.

Author: Vivo Internet Client team -Wu Yue