How to write Android projects based on compile-time annotations

How to write Android projects based on compile-time annotations

1. Overview

In Android application development, we often choose to use some annotation-based frameworks to improve development efficiency. However, due to the loss of operating efficiency caused by reflection, we prefer compile-time annotation frameworks, such as:

  • butterknife saves us from writing code for View initialization and event injection.
  • EventBus3 facilitates communication between components.
  • fragmentargs easily adds parameter information to fragments and provides creation methods.
  • ParcelableGenerator can automatically convert any object into a Parcelable type, making it easier to transfer objects.

There are many similar libraries. Most of these libraries are designed to automatically help us complete the parts that need to be rewritten in daily coding (for example: each View in an Activity needs to be initialized, and each object that implements the Parcelable interface requires a lot of fixed-style code).

[[192267]]

This does not mean that the above frameworks do not use reflection. In fact, some of the above frameworks still have some internal implementations that rely on reflection, but they are very few and generally cached, so relatively speaking, the efficiency impact is very small.

However, when using such projects, errors are sometimes difficult to debug. The main reason is that many users do not understand the internal principles of such frameworks, so when problems arise, they spend a lot of time troubleshooting.

So, it makes sense that at a time when the compile-time annotation framework is so popular, we have reason to learn: how to write a project that uses compile-time annotations

First, it is to understand its principles, so that when we encounter problems using similar frameworks, we can find the correct way to troubleshoot the problems; secondly, if we have good ideas and find that some codes need to be created repeatedly, we can also write a framework ourselves to facilitate our daily coding and improve coding efficiency; it can also be regarded as an improvement of our own technology.

Note: The following IDE is Android Studio.

This article will take writing a View injection framework as a clue and introduce the steps of writing such a framework in detail.

2. Preparation before writing

When writing such a framework, you generally need to create multiple modules, such as the example that this article will implement:

  • ioc-annotation is used to store annotations, etc., Java modules
  • ioc-compiler is used to write annotation processors, Java modules
  • ioc-api is used to provide users with an API. In this case, it is an Android module.
  • ioc-sample example, this example is an Android module

In addition to the example, you generally need to create three modules. You can decide the name of the module yourself. The above is a simple reference. Of course, if conditions permit, some developers like to merge the annotation storage and API modules into one module.

For dependencies between modules, since writing annotation processors requires dependencies on related annotations, so:

  • ioc-compiler depends on ioc-annotation

In the process of use, we will use annotations and related APIs

  • So ioc-sample depends on ioc-api; ioc-api depends on ioc-annotation

3. Implementation of Annotation Module

The annotation module is mainly used to store some annotation classes. In this example, the template Butterknife implements View injection, so this example only needs one annotation class:

  1. @Retention(RetentionPolicy.CLASS)
  2. @Target(ElementType.FIELD)
  3. public @interface BindView
  4. {
  5. int value();
  6. }

The retention strategy we set is Class, and the annotation is used on Field. Here we need to pass in an id when using it, and set it directly in the form of value.

When you are writing, analyze how many annotation classes you need and set @Target and @Retention correctly.

4. Implementation of Annotation Processor

After defining the annotation, you can write the annotation processor. This is a bit complicated, but there are rules to follow.

For this module, we generally rely on the annotation module and can use an auto-service library

The dependencies of build.gradle are as follows:

  1. dependencies {
  2. compile 'com.google.auto.service:auto-service:1.0-rc2'  
  3. compile project ( ':ioc-annotation' )
  4. }

The auto-service library can help us generate META-INF and other information.

(1) Basic code

Annotation processors generally inherit from AbstractProcessor. We just said that there are rules to follow because the writing methods of some codes are basically fixed, as follows:

  1. @AutoService(Processor.class)
  2. public class IocProcessor extends AbstractProcessor{
  3. private Filer mFileUtils;
  4. private Elements mElementUtils;
  5. private Messager mMessager;
  6. @Override
  7. public synchronized void init(ProcessingEnvironment processingEnv){
  8. super.init(processingEnv);
  9. mFileUtils = processingEnv.getFiler();
  10. mElementUtils = processingEnv.getElementUtils();
  11. mMessager = processingEnv.getMessager();
  12. }
  13. @Override
  14. public   Set <String> getSupportedAnnotationTypes(){
  15. Set <String> annotationTypes = new LinkedHashSet<String>();
  16. annotationTypes.add ( BindView.class.getCanonicalName ());
  17. return annotationTypes;
  18. }
  19. @Override
  20. public SourceVersion getSupportedSourceVersion(){
  21. return SourceVersion.latestSupported();
  22. }
  23. @Override
  24. public boolean process( Set <? extends TypeElement> annotations, RoundEnvironment roundEnv){
  25. }

After implementing AbstractProcessor, the process() method must be implemented, which is also the core part of our code writing, which will be introduced later.

We usually implement two methods, getSupportedAnnotationTypes() and getSupportedSourceVersion(). One of these two methods returns the supported annotation types, and the other returns the supported source code version. Refer to the code above, the writing method is basically fixed.

In addition, we will choose to override the init() method, which passes in a parameter processingEnv, which can help us initialize some parent classes:

  • Filer mFileUtils; Auxiliary class related to files, generates JavaSourceCode.
  • Elements mElementUtils; Auxiliary class related to elements, helping us to obtain some element-related information.
  • Messager mMessager; auxiliary class related to logging.

Let's briefly mention Elemnet here. Let's take a look at its several subclasses. According to the comments below, you should have a simple understanding of it.

  1. Element
  2. - VariableElement //Generally represents a member variable
  3. - ExecutableElement //Generally represents a method in a class
  4. - TypeElement //Generally represents the class
  5. - PackageElement //Generally represents Package

(2) Implementation of process

The implementation in the process is a bit more complicated. Generally, you can think of it as two major steps:

  • Collecting Information
  • Generate proxy class (this article calls the class generated during compilation the proxy class)

What does it mean to collect information? It means getting the corresponding Element according to your annotation declaration, and then getting the information we need. This information is definitely prepared for the subsequent generation of JavaFileObject.

For example, in this example, we will generate a proxy class for each class, for example, for MainActivity we will generate a MainActivity$$ViewInjector. If annotations are declared in multiple classes, it corresponds to multiple classes, so here we need:

  • A class object, representing all the information generated by the proxy class of a specific class, in this case ProxyInfo
  • A collection that stores the above class objects (which will be traversed to generate proxy classes in the future). In this case, it is a Map, and the key is the full path of the class.

It doesn't matter if the description here is a bit vague, it will be easier to understand with the code later.

a. Information Collection

  1. private Map<String, ProxyInfo> mProxyMap = new HashMap<String, ProxyInfo>();
  2. @Override
  3. public boolean process( Set <? extends TypeElement> annotations, RoundEnvironment roundEnv){
  4. mProxyMap.clear();
  5. Set <? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
  6. //1. Collect information
  7. for (Element element : elements){
  8. // Check element type
  9. if (!checkAnnotationUseValid(element)){
  10. return   false ;
  11. }
  12. //field type
  13. VariableElement variableElement = (VariableElement) element;
  14. //class type
  15. TypeElement typeElement = (TypeElement) variableElement.getEnclosingElement();//TypeElement
  16. String qualifiedName = typeElement.getQualifiedName().toString();
  17.  
  18. ProxyInfo proxyInfo = mProxyMap.get(qualifiedName);
  19. if (proxyInfo == null ){
  20. proxyInfo = new ProxyInfo(mElementUtils, typeElement);
  21. mProxyMap.put(qualifiedName, proxyInfo);
  22. }
  23. BindView annotation = variableElement.getAnnotation(BindView.class);
  24. int id = annotation.value();
  25. proxyInfo.mInjectElements.put(id, variableElement);
  26. }
  27. return   true ;
  28. }

First we call mProxyMap.clear();, because the process may be called multiple times to avoid generating duplicate proxy classes and to avoid exceptions where the class name of the generated class already exists.

Then, we get the elements annotated with @BindView through roundEnv.getElementsAnnotatedWith. The return value here should be a VariableElement collection as we expected, because we use it for member variables.

Next we for loop our elements, first checking if the type is VariableElement.

Then get the corresponding class information TypeElement, and then generate a ProxyInfo object. Here, it is checked through an mProxyMap. The key is qualifiedName, which is the full path of the class. If it is not generated, a new one will be generated. ProxyInfo corresponds to the class one by one.

Next, the VariableElement corresponding to the class and declared by @BindView will be added to ProxyInfo. The key is the id filled in when we declare it, that is, the id of the View.

This completes the collection of information. After collecting the information, you should be able to generate the proxy class.

b. Generate proxy class

  1. @Override
  2. public boolean process( Set <? extends TypeElement> annotations, RoundEnvironment roundEnv){
  3. //...Omit the code for collecting information and try, catch related
  4. for (String key : mProxyMap.keySet()){
  5. ProxyInfo proxyInfo = mProxyMap.get( key );
  6. JavaFileObject sourceFile = mFileUtils.createSourceFile(
  7. proxyInfo.getProxyClassFullName(), proxyInfo.getTypeElement());
  8. Writer writer = sourceFile.openWriter();
  9. writer.write(proxyInfo.generateJavaCode());
  10. writer.flush();
  11. writer.close () ;
  12. }
  13. return   true ;
  14. }

You can see that the code for generating the proxy class is very short. It mainly traverses our mProxyMap and then obtains each ProxyInfo. Finally, it creates a file object through mFileUtils.createSourceFile. The class name is proxyInfo.getProxyClassFullName(), and the content written is proxyInfo.generateJavaCode().

It seems that the methods for generating Java code are all in ProxyInfo.

c. Generate Java code

Here we mainly focus on how it generates Java code.

The following mainly looks at the method of generating Java code:

  1. #ProxyInfo
  2. // key is id, value is the corresponding member variable
  3. public Map< Integer , VariableElement> mInjectElements = new HashMap< Integer , VariableElement>();
  4.  
  5. public String generateJavaCode(){
  6. StringBuilder builder = new StringBuilder();
  7. builder.append( "package " + mPackageName).append( ";\n\n" );
  8. builder.append( "import com.zhy.ioc.*;\n" );
  9. builder.append( "public class " ).append(mProxyClassName).append( " implements " + SUFFIX + "<" + mTypeElement.getQualifiedName() + ">" );
  10. builder.append( "\n{\n" );
  11. generateMethod(builder);
  12. builder.append( "\n}\n" );
  13. return builder.toString();
  14. }
  15. private void generateMethod(StringBuilder builder){
  16. builder.append( "public void inject(" +mTypeElement.getQualifiedName()+ " host , Object object )" );
  17. builder.append( "\n{\n" );
  18. for ( int id : mInjectElements.keySet()){
  19. VariableElement variableElement = mInjectElements.get(id);
  20. String name = variableElement.getSimpleName().toString();
  21. String type = variableElement.asType().toString();
  22.  
  23. builder.append( " if(object instanceof android.app.Activity)" );
  24. builder.append( "\n{\n" );
  25. builder.append( "host." + name ).append( " = " );
  26. builder.append( "(" +type+ ")(((android.app.Activity)object).findViewById(" +id+ "));" );
  27. builder.append( "\n}\n" ).append( "else" ).append( "\n{\n" );
  28. builder.append( "host." + name ).append( " = " );
  29. builder.append( "(" +type+ ")(((android.view.View)object).findViewById(" +id+ "));" );
  30. builder.append( "\n}\n" );
  31. }
  32. builder.append( "\n}\n" );
  33. }

Here we mainly rely on the collected information to splice the completed proxy class object. It may seem a headache, but I will give you a generated code, which will be a lot easier to compare.

  1. package com.zhy.ioc_sample;
  2. import com.zhy.ioc.*;
  3. public class MainActivity$$ViewInjector implements ViewInjector<com.zhy.ioc_sample.MainActivity>{
  4. @Override
  5. public void inject(com.zhy.sample.MainActivity host , Object object ){
  6. if(object instanceof android.app.Activity){
  7. host.mTv = (android.widget.TextView)(((android.app.Activity)object).findViewById(2131492945));
  8. }
  9. else {
  10. host.mTv = (android.widget.TextView)(((android. view . View )object).findViewById(2131492945));
  11. }
  12. }
  13. }

It will be much better to look at the above code in this way. In fact, it is based on the collected member variables (declared by @BindView), and then generates Java code according to the specific requirements we want to achieve.

Please note that the generated code implements an interface ViewInjector, which is used to unify the types of all proxy class objects. When the time comes, we need to force the proxy class object to be of this interface type and call its method. The interface is generic, and the main purpose is to pass in the actual class object, such as MainActivity, because the code we are generating in the proxy class is actually the actual class. Member variables are accessed in the form of member variables, so member variables annotated at compile time are generally not allowed to be modified with the private modifier (some are allowed, but getter and setter access methods need to be provided).

Here we use a completely spliced ​​approach to write Java code. You can also use some open source libraries to generate code through the Java API, such as javapoet.

  1. A Java API for generating .java source files.

At this point we have completed the generation of the proxy class. The writing method of any annotation processor here basically follows the steps of collecting information and generating proxy classes.

5. Implementation of API module

After we have the proxy class, we usually provide an API for users to access. For example, the access entry in this example is

  1. //Activity
  2. Ioc.inject(Activity);
  3. //In Fragment, get the ViewHolder
  4. Ioc.inject(this, view);

Imitating butterknife, the first parameter is the host object, and the second parameter is the object that actually calls findViewById; of course, in Actiivty, the two parameters are the same.

How is an API generally written?

In fact, it is very simple. As long as you understand its principle, this API does two things:

  • Find the proxy class we generated according to the passed in host: for example MainActivity->MainActity$$ViewInjector.
  • Force conversion to a unified interface and call the methods provided by the interface.

These two things should not be complicated. The first thing is to concatenate the proxy class name, then generate the object by reflection, and the second thing is to force the call.

  1. public class Ioc{
  2. public   static void inject(Activity activity){
  3. inject(activity, activity);
  4. }
  5. public   static void inject(Object host, Object root){
  6. Class<?> clazz = host.getClass();
  7. String proxyClassFullName = clazz.getName()+ "$$ViewInjector" ;
  8. //Omit try, catch related code
  9. Class<?> proxyClazz = Class.forName(proxyClassFullName);
  10. ViewInjector viewInjector = (com.zhy.ioc.ViewInjector) proxyClazz.newInstance();
  11. viewInjector.inject(host,root);
  12. }
  13. }
  14. public interface ViewInjector<T>{
  15. void inject(T t , Object object);
  16. }

The code is very simple. It concatenates the full path of the proxy class, generates an instance through newInstance, and then forcibly calls the inject method of the proxy class.

Here, the generated proxy class is generally cached, for example, it is stored in a Map and not regenerated, so we will not do it here.

In this way, we have completed the writing of a compile-time annotation framework.

VI. Conclusion

This article describes how to write a project based on compile-time annotations through specific examples. The main steps are: project structure division, annotation module implementation, annotation processor writing, and API module writing. Through the study of this text, you should be able to understand the operating principles of such frameworks based on compile-time annotations, and how to write such a framework yourself.

<<:  App Development Architecture Guide (Google official document translation)

>>:  Android screenshots and WebView long pictures sharing experience summary

Recommend

How to build a brand at zero cost?

For those of us who work in marketing, the shocks...

How to continuously obtain seed users?

There are many techniques for acquiring seed user...

How to balance eating forbidden fruit and publishing papers while on vacation?

Logically speaking If this happens to an educated...

A strange "dandelion" in the universe? The mystery of the supernova remnant Pa30

Author: Duan Yuechu and Huang Xianghong Deep in t...

This generation of young people rely on "sound massage" to help them sleep

Difficulty falling asleep, shallow sleep, dreamin...

Appearance first, powerful performance, Mechanic F117 gaming laptop review

In July this year, during the 2nd anniversary cel...

Can Apple’s wake-up call for Qualcomm’s industry monopoly continue?

Apple is claiming $1 billion in patent licensing ...

360 Points Promotion Tutorial [Detailed Lecture]

Learn to master: Learn the relevant knowledge of ...

Does the cold wind make your face crooked? These things about "facial paralysis"

The beginning of summer has passed, and the heat ...