preface

  • Last article: A quick start to Gradle learning

In the previous Gralde primer, we explained how to customize Gralde plugins. This article defines a simple Gradle plugin using Asm and Transfrom. This Gradle plugin can count the time of a method and when the time of a method exceeds a threshold, The Log is printed on the console, and then we can locate the position of the time consuming method through the Log to help us find out the time consuming method. A very simple function, the principle is also very simple, which requires the use of Asm knowledge and Transfrom knowledge, so this article will first introduce the Asm and Transfrom knowledge. Finally, I will show you how to implement Gradle plugin using Asm and Transform. If you are familiar with Asm and Transfrom, you can skip these two sections.

The source location is at the end of the text

Running effect

Since this is a local plugin, apply directly to app/build.gradle and (optionally) configure it with the time extension:

apply plugin: com.example.plugin.TimeCostPlugin
// The function time threshold is 200ms, and only the functions in the application are inserted (excluding third-party libraries)
time{
    threshold = 200
    appPackage = 'com.example.plugindemo'
}
Copy the code

Then define several time consuming functions specifically:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState)  {
        super.onCreate(savedInstanceState);

        try {
            method1();
            method2();
            method3();
        } catch(InterruptedException e) { e.printStackTrace(); }}private static void method1(a) throws InterruptedException {
        Thread.sleep(500);
    }

    public void method2(a) throws InterruptedException {
        Thread.sleep(300);
    }

    void method3(a) throws InterruptedException {
        Thread.sleep(1000); }}Copy the code

Finally, compile and run it, and it will print the time function information in the console:

Clicking on the method line number takes you directly to the time function.

Asm

Official address: ASM

Official tutorials: ASM4- Guide (English edition), ASM4- Guide (Chinese edition)

Asm is a generic Java bytecode manipulation and analysis framework, it provides some simple and easy to use bytecode manipulation method, can be directly modify an existing class or dynamically generated in the form of binary class, simply speaking, Asm is a bytecode manipulation framework, through the Asm, we could generate a class, or modify an existing class, Asm has the advantages of small size, good performance, and high efficiency compared with other Bytecode operation frameworks such as Javasist, AspectJ, etc. However, it has the disadvantage of high learning cost. However, IntelliJ plug-in Asm Bytecode Outline can automatically generate Asm code for us. So for those of you who want to get started with Asm, it’s fairly straightforward. We just need to briefly learn what the Asm API means, and before that, hopefully you have a good understanding of the BASICS of the JVM: type descriptors, method descriptors, and Class file structures.

There are two types of APIS in Asm, one is the Tree API based on the tree model, and the other is the visitor API based on the visitor pattern. Visitor API is the core and basic API of Asm, so for beginners, we need to know the use of visitor API. There are three main classes in the Visitor API for reading, accessing, and generating class bytecode:

  • ClassVisitor: It’s used to access calSS bytecode, and it has a lot of visitXX methods in it, and every visitXX Method that you call means that you’re accessing some structure of the class file, such as Method, Field, Annotation, etc. We usually extend that ClassVisitor, using the proxy pattern, Delegating each visitXX method call of the extended ClassVisitor to another ClassVisitor allows us to add our own logic before and after the delegation to transform and modify the class bytecode of that class.

  • ClassReader: It reads the class bytecode given as a byte array. It has an Accept method that receives a ClassVisitor instance. Inside the Accept method, the visitXX method of the ClassVisitor is called to access the class file that has been read;

  • ClassWriter: It inherits from ClassVisitor and generates class bytecode in binary form. It has a toByteArray method that converts the generated binary form of class bytecode back into byte array form.

    ClassVisitor, ClassReader, and ClassWriter are usually used in combination. Here are some practical examples to quickly understand. First, we need to introduce Asm in build.gradle, as follows:

    dependencies {
        // The core API provides the Visitor API
        implementation 'org. Ow2. Asm: asm: 7.0'
        // Optional, some predefined class converters are provided based on the core API
        implementation 'org. Ow2. Asm: asm - Commons: 7.0'
        // Optional, provides some utility classes based on the core API
        implementation 'org. Ow2. Asm: asm - util: 7.0'
    }
    Copy the code

1, read, access a class

Before reading the class, let’s first introduce the visitXX method in ClassVisitor. The main structure of ClassVisitor is as follows:

public abstract class ClassVisitor {

    // The ASM version is defined in the Opcodes interface. The lowest version is ASM4 and the latest is ASM7
    protected final int api;
  
    // The delegated ClassVisitor can be passed empty
    protected ClassVisitor cv;

    public ClassVisitor(final int api) {
        this(api, null);
    }
  
    public ClassVisitor(final int api, final ClassVisitor cv) {
        / /...
        this.api = api;
        this.cv = cv;
    }

    // to start accessing the class
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        if(cv ! =null) { cv.visit(version, access, name, signature, superName, interfaces); }}// to access the source file name of the class (if any)
    public void visitSource(String source, String debug) {
        if(cv ! =null) { cv.visitSource(source, debug); }}// Represents access to the class's external class (if any)
    public void visitOuterClass(String owner, String name, String desc) {
        if(cv ! =null) { cv.visitOuterClass(owner, name, desc); }}// An annotation that indicates access to the class (if any)
    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
        if(cv ! =null) {
            return cv.visitAnnotation(desc, visible);
        }
        return null;
    }

    // represents access to the inner class of this class (if any)
    public void visitInnerClass(String name, String outerName,
            String innerName, int access) {
        if(cv ! =null) { cv.visitInnerClass(name, outerName, innerName, access); }}// indicates access to the fields of this class (if any)
    public FieldVisitor visitField(int access, String name, String desc,
            String signature, Object value) {
        if(cv ! =null) {
            return cv.visitField(access, name, desc, signature, value);
        }
        return null;
    }

    // represents the method (if any) to access the class
    public MethodVisitor visitMethod(int access, String name, String desc,
            String signature, String[] exceptions) {
        if(cv ! =null) {
            return cv.visitMethod(access, name, desc, signature, exceptions);
        }
        return null;
    }

    // End access to this class
    public void visitEnd(a) {
        if(cv ! =null) { cv.visitEnd(); }}/ /... Some other visitXX methods are omitted
}
Copy the code

As you can see, all the visitXX methods of the ClassVisitor delegate logic to the visitorXX methods of the other ClassVisitor. We know that when a class is loaded into the JVM, its class structure is roughly as follows:

So comparing the class file structure with the methods in the ClassVisitor, we can see that the visitXX methods in the ClassVisitor, except for the visitEnd method, correspond to some structure in the class file, such as fields, methods, properties, etc. The parameters of each visitXX method represent information about fields, methods, properties, and so on. For example: Access is a modifier, signature is a generic, desc is a descriptor, and name is a name or fully-qualified name. We also notice that some visitXX methods return an instance of the XXVisitor class, which in turn has a similar visitXX method. This means that the external can continue to call the visitXX method of the returned XXVisitor instance, thus continuing to access the substructures in the corresponding structure, as explained later.

After we know what the methods in ClassVisitor do, we define a class to read and print information from that class using ClassReader and ClassVisitor. First, we define a class named OuterClass, as follows:

@Deprecated
public class OuterClass{

    private int mData = 1;

    public OuterClass(int data){
        this.mData = data;
    }

    public int getData(a){
        return mData;
    }

    class InnerClass{}}Copy the code

The OuterClass class has annotations, fields, methods, inner classes, and then a custom class named PrintClassVisitor that extends from ClassVisitor, as follows:

public class PrintClassVisitor extends ClassVisitor implements Opcodes {

    public ClassPrinter(a) {
        super(ASM7);
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        System.out.println(name + " extends " + superName + "{");
    }

    @Override
    public void visitSource(String source, String debug) {
        System.out.println(" source name = " + source);
    }

    @Override
    public void visitOuterClass(String owner, String name, String descriptor) {
        System.out.println(" outer class = " + name);
    }

    @Override
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        System.out.println(" annotation = " + descriptor);
        return null;
    }

    @Override
    public void visitInnerClass(String name, String outerName, String innerName, int access) {
        System.out.println(" inner class = " + name);
    }

    @Override
    public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
        System.out.println(" field = "  + name);
        return null;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        System.out.println(" method = " + name);
        return null;
    }

    @Override
    public void visitEnd(a) {
        System.out.println("}"); }}Copy the code

There are many constants defined in the Opcodes interface. ASM7 is from the Opcodes. Each visitXX method prints out the class information, and then uses ClassReader to read the OuterClass bytecode. Pass in the ClassVisitor instance in the Accept method to complete access to the OuterClass as follows:

public static void main(String[] args) throws IOException {
  // Create ClassVisitor instance
  ClassPrinter printClassVisitor = new ClassPrinter();
  ClassReader reads the OuterClass bytecode as an array of bytes
  ClassReader classReader = new ClassReader(OuterClass.class.getName());
  // Pass the ClassVisitor instance in the Accept of the ClassReader to enable access. The second argument indicates the mode of access
  classReader.accept(printClassVisitor, 0); } Run output:  com/example/plugindemo/OuterClass extends java/lang/Object{ source name = OuterClass.java annotation = Ljava/lang/Deprecated; innerclass = com/example/plugindemo/OuterClass$InnerClass
 field = mData
 method = <init>
 method = getData
}
Copy the code

Classreaders are constructed to accept not only the fully qualified name of a class, but also the input stream of a class file, ultimately reading the class bytecode into memory as an array of bytes. The Accept method of ClassReader uses the memory offset to parse the byte array of the class bytecode read in the construct, parsing the structure information of the class bytecode from the byte array. The visitorXX method of the incoming ClassVisitor instance is then called to access the parsed structural information, and as you can see from the run output, there is a sequence of calls to the visitorXX method of the ClassVisitor in the Accept method, beginning with the visit method, End with the visitEnd method, intercalated with calls to the other visitXX methods, in the following general order:

visit 
[visitSource] 
[visitOuterClass] 
[visitAnnotation]
[visitInnerClass | visitField | visitMethod]
visitEnd

// Where [] is optional and | is level
Copy the code

2, generate a class

Now that we know that ClassReader can be used to read a class, ClassVisitor can be used to access a class, and ClassWirter can be used to generate a class out of thin air, let’s generate an interface named Person that has the following structure:

public interface Person {
    String NAME = "rain9155";
    int getAge(a);
}
Copy the code

The code for generating the Person interface using ClassWriter is as follows:

import static org.objectweb.asm.Opcodes.*;

public class Main {

  public static void main(String[] args){
    // Create a ClassWriter and construct the behavior pattern of the class that is passed to modify
    ClassWriter classWriter = new ClassWriter(0);
    // Generate the class header
    classWriter.visit(V1_7, ACC_PUBLIC + ACC_ABSTRACT + ACC_INTERFACE, "com/example/plugindemo/Person".null."java/lang/Object".null);
    // Generate the file name
    classWriter.visitSource("Person.java".null);
    // Generate field NAME with value RAIN9155
    FieldVisitor fileVisitor = classWriter.visitField(ACC_PUBLIC + ACC_FINAL + ACC_STATIC, "NAME"."Ljava/lang/String;".null."rain9155");
    fileVisitor.visitEnd();
		// Generate a method named getAge that returns int
    MethodVisitor methodVisitor = classWriter.visitMethod(ACC_PUBLIC + ACC_ABSTRACT, "getAge"."()I".null.null);
    methodVisitor.visitEnd();
		// Class generation is complete
    classWriter.visitEnd();
    // The generated class can be returned as a byte array via the toByteArray method
    byte[] bytes = classWriter.toByteArray();
  }
Copy the code

ClassWirter inherits from ClassVisitor and extends the visitorXX method of ClassVisitor to give it the ability to generate class bytecode, Finally, the array of bytes returned by the toByteArray method can be dynamically loaded as a Class object by the ClassLoader. Since I’m generating an interface here, the getAge method has no method body. So the visitMethod method returns a MethodVisitor that simply calls visitEnd to generate the getAge method header. If you need to generate the internal logic of the getAge method, for example:

int getAge(a){
  return 1;
}
Copy the code

So before calling the visitEnd method of the MethodVisitor, you need to call the other visitXX method of the MethodVisitor to generate the internal logic of the method. The visitXX method of the MethodVisitor is the bytecode instruction in the simulated JVM. For example, the push, the exit, the FieldVisitor returned by the visitField method and the AnnotationVisitor returned by the visitAnnotation method have the same meaning as the MethodVisitor.

You can see how tedious it is to generate a simple interface using ClassWirter. If it were a class and the methods in the class had method bodies, the code would be even more complicated. Fortunately, we can do this with the ASM Bytecode Outline plug-in. First install the plugin in your AS or IntelliJ IDE, then right-click on the class you want to view -> Show Bytecode Outline – it will Show the Bytecode and ASMified code in the side window. Click on the ASMified TAB to display the Asm code for this class. For example, here is the Asm code generated by the Person interface plugin:

As you can see, the Person interface is generated using ClassWriter.

3. Transform a class

ClassReader can be used to read a class, ClassVisitor can be used to access a class, and ClassWirter can be used to generate a class, so when we put these three together, we can read class bytecode through ClassReader, To transform a class, convert the class bytecode read by an extended ClassVisitor. After the transformation, regenerate the class using a ClassWirter. Let’s remove the OuterClass annotation. First, customize a ClassVisitor as follows:

public class RemoveAnnotationClassVisitor extends ClassVisitor implements Opcodes {

    public RemoveAnnotationClassVisitor(ClassVisitor classVisitor) {
        super(ASM7, classVisitor);
    }

    @Override
    public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
      / / returns null
      return null; }}Copy the code

Here I’m just overriding the visitAnnotation method of the ClassVisitor, which returns null in the visitAnnotation method, so that the caller can’t use the returned AnnotationVisitor to generate annotations for the class, Then use the RemoveAnnotationClassVisitor, as follows:

public static void main(String[] args) throws IOException {
  	// Read the bytecode from OuterClass to ClassReader
		ClassReader classReader = new ClassReader(OuterClass.class.getName());
  	// Define the ClassWriter used to generate the class
  	ClassWriter classWriter = new ClassWriter(0);
    / / hand into the ClassWriter RemoveAnnotationClassVisitor structure
  	RemoveAnnotationClassVisitor removeAnnotationClassVisitor = new RemoveAnnotationClassVisitor(classWriter);
    / / in the accept method of ClassReader incoming RemoveAnnotationClassVisitor instance, open access
 	  classReader.accept(removeAnnotationClassVisitor, 0);
    // Finally, use ClassWriter's toByteArray method to return an array of bytes for the transformed OuterClass class
  	byte[] bytes = classWriter.toByteArray();
}
Copy the code

This code simply combines the previous knowledge of reading, accessing, and generating a class. The construction of a ClassVisitor can be passed into a ClassVisitor, thus acting as a proxy for the incoming ClassVisitor, and the ClassWriter is inherited from the ClassVisitor. So the ClassWriter RemoveAnnotationClassVisitor agent, RemoveAnnotationClassVisitor handed OuterClass conversion and then to the ClassWriter, Finally we can use ClassWriter’s toByteArray method to return an array of bytes for the transformed OuterClass class.

The above code has a simple one ClassVisitor that does the transformation. If we extend it, we can also define RemoveMethodClassVisitor, AddFieldClassVisitor, and many other classVisitors with different functions. Then string all classVisitors into a chain of transformations, thinking of classReaders as the head, Classwriters as the tail, and a series of Classvisitors in the middle, The class bytecode read by the ClassReader passes through a series of ClassVisitor transformations to the ClassWriter, which eventually generates a new class by the ClassWriter. The process is shown below:

If you want to learn more about Asm, please refer to the official tutorial given at the beginning. Let’s learn about Transform.

Transform

Liverpoolfc.tv: Transform

Transform is part of the Android Gradle API. It can get all the.class files in the Android project before it is compiled into the.dex file. Then we can process all the.class files in the Transform. So Transform provides the ability for us to get the bytecode of an Android project. The red mark in the figure is the point of use for Transform:

The image above is part of the Android packaging process, and the Android packaging process is given to the Android Gradle Plugin, so if we want to customize the Transform, we have to inject it into the Android Gradle Plugin to make it work. The execution unit of the plugin is Task, but the Transform is not a Task. How is the Transform executed? The Android Gradle Plugin creates a TransformTask for each Transform, which then executes the corresponding Transform.

To introduce The Transform, first we need to introduce the Transform in build.gradle as follows:

dependencies {
   	// Reference the Android Gradle API, which includes the Transform API
    implementation 'com. Android. Tools. Build: gradle: 4.0.0'
}
Copy the code

Since the Transform API is part of the Android Gradle API, we can simply introduce the Android Gradle API and customize a transform named MyTransform as follows:

public class MyTransform extends Transform {

    @Override
    public String getName(a) {
        // The name used to generate the TransformTask
        return "MyTransform";
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        // Input type
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        // The scope of the input
        return TransformManager.SCOPE_FULL_PROJECT;
    }
  
   @Override
    public boolean isIncremental(a) {
        // Whether to enable incremental compilation
        return false;
    }
  
    @Override
    public void transform(TransformInvocation transformInvocation){
        // This is where the class file is processed}}Copy the code

Transform is an abstract class, so it forces us to implement several methods and override the Transform method. Here’s what these methods mean:

1, getName method

Front said android gradle plugin will for each Transform to create a corresponding TransformTask, and create TransformTask the name of the general format for transformXX1WithXX2ForXX3, The value of XX2 is the return value of getName and the value of XX3 is the Build Variants of the current Build environment, such as Debug, Release etc. Build Variants for Debug, inputType for Class files, then the Transform the corresponding Task called transformClassesWithMyTransformForDebug.

2, the getInputTypes and getScopes methods

Both the getInputTypes and getScopes methods return a Set of elements of the ContentType interface and Scope enumeration, respectively. In the case of Transform, ContentType represents the type of the Transform input. The Scope represents the Scope of the Transform input. The Transform filters the Transform input from the ContentType and Scope dimensions. An input will only be consumed by Transform if it satisfies both the ContentType set returned by the getInputTypes method and the Scope set returned by the getScopes method.

In the Transform, there are two main types of input, CLASSES and RESOURCES, to implement an enumeration of the ContentType interface DefaultContentType. The meanings of each enumeration are as follows:

DefaultContentType meaning
CLASSES Represents a.class file in a JAR or folder
RESOURCES Represents a standard Java source file

Similarly, in the Transform, the input Scope is also represented by the enumeration Scope, including PROJECT, SUB_PROJECTS, EXTERNAL_LIBRARIES, TESTED_CODE, and PROVIDED_ONLY. The meanings of each enumeration are as follows:

Scope meaning
PROJECT Only the current project is processed
SUB_PROJECTS Only subprojects of the current project are processed
EXTERNAL_LIBRARIES Only external dependencies for the current project are dealt with
TESTED_CODE Only the test code for the current project build environment is handled
PROVIDED_ONLY Only handles the provided-only dependent libraries for the current project

ContentType and Scope can be combined separately, and the Set form is returned. The TransformManager class defines some common combinations that we can use directly. For example, MyTransform’s ContentType is CONTENT_CLASS. Scope is SCOPE_FULL_PROJECT, defined as follows:

public class TransformManager extends FilterableStreamCollection {
  
      public static final Set<ContentType> CONTENT_CLASS = ImmutableSet.of(CLASSES);
  		
      public static final Set<ScopeType> SCOPE_FULL_PROJECT = ImmutableSet.of(Scope.PROJECT, Scope.SUB_PROJECTS, Scope.EXTERNAL_LIBRARIES);
  
  	 / /... There are many other combinations
}
Copy the code

You can see CONTENT_CLASS consists of CLASSES, SCOPE_FULL_PROJECT consists of PROJECT, SUB_PROJECTS, and EXTERNAL_LIBRARIES. So MyTransform will only handle.class file input from the current project (including subprojects) and external dependent libraries.

3. IsIncremental

The isIncremental method returns true to indicate whether the Transform supports incremental compilation. In fact, only tasks have incremental compilation. The Transform is eventually executed by the TransformTask. So Transform relies on Task to compile incrementally. Gradle Task incrementally compiles by detecting its inputs and outputs: When the changed file is detected, Gradle determines that the compile is incremental. Task internally increments output based on the changed file. If Gradle detects that the input has not changed since the last input, Gradle determines that the current up-to-data compilation can be skipped. If the output is deleted, Gradle determines that the compile is full and triggers the full output of the Task, i.e. output for all input files.

When the Transform is determined to be incrementally compiled, the Transform method can process each input file according to its Status, which is also an enumeration. The meanings of each enumeration are as follows:

Status meaning
NOTCHANGED This file has not changed since the last build
ADDED This file is a new file
CHANGED This file has changed (has been modified) since the last build
REMOVED The file has been deleted

Enabling incremental compilation can greatly speed up Gradle builds.

Note: If your isIncremental method returns true, then the Transform method of your custom Transform must provide support for incremental compilation by processing the input file based on Status, otherwise incremental compilation will not take effect. You will see how support for incremental compilation is provided in a later plug-in implementation.

4, transform method

The transform method is where the input is processed in the transform, and the TransformTask executes the transform method that executes the transform. The transform method takes TransfromInvocation, It contains the input and output information for the current Transform. You can use the getInputs method of TransfromInvocation to get the input for the Transform, Use the getOutputProvider method of TransformInvocation to generate the output of the Transform, You can also determine whether the transform was incrementally compiled by using the return value of the isIncremental method of TransfromInvocation.

The getInputs method of TransfromInvocation returns a collection of elements of type TransformInput, where TransformInput can take two types of input, as follows:

public interface TransformInput {
  	// The getJarInputs method returns the JarInput collection
    Collection<JarInput> getJarInputs(a);
  
  	// The getDirectoryInputs method returns a collection of DirectoryInputs
    Collection<DirectoryInput> getDirectoryInputs(a);
}
Copy the code

The two types of input are abstracted as JarInput and DirectoryInput. JarInput represents input as. JarInput has a getStatus method to get the Status of the Jar file. And DirectoryInputgetChangedFiles method to get a Map < File, Status > set, so can traverse the Map collections, and then according to the corresponding Status to increment the File to the File.

TransfromInvocation’s getOutputProvider method returns a TransformOutputProvider that can be used to create the output position of the Transform, as follows:

public interface TransformOutputProvider {
    // Delete all output
    void deleteAll(a) throws IOException;

    // Create an output position based on the name, ContentType, Scope, and Format given by the parameters
    File getContentLocation(
            @NonNull String name,
            @NonNull Set<QualifiedContent.ContentType> types,
            @NonNull Set<? super QualifiedContent.Scope> scopes,
            @NonNull Format format);
}

Copy the code

Calling the getContentLocation method creates an output location and returns the File instance represented by that location, or if it exists, Through getContentLocation method to create the output of the general is located at/app/build/intermediates/transforms/build variants/transform name/directory, Where build variants are the current build environment such as Debug, release, etc., transform name is the return value of getName, For example in the debug build MyTransform output location is under the/app/build/intermediates/transforms/debug/MyTransform/directory, the directory is to Transform the output jar files or folders, The name is 0, 1, 2… Incrementing the name form, and calling the deleteAll method removes all files from the output location created by the getContentLocation method.

So if incremental compilation is not supported, the transform method would normally say:

 public void transform(TransformInvocation transformInvocation) throws IOException {
        // All inputs are fetched through the getInputs method of TransformInvocation, which is a collection. TransformInput represents one input
        Collection<TransformInput> transformInputs = transformInvocation.getInputs();

        GetOutputProvider (TransformInvocation) getOutputProvider (TransformOutputProvider); TransformOutputProvider (TransformOutputProvider) creates the Transform output
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

        // Iterates through all the inputs, each containing a set of files for the two input types: JAR and directory
        for(TransformInput transformInput : transformInputs){
            Collection<JarInput> jarInputs = transformInput.getJarInputs();
            // Iterate to process the JAR file
            for(JarInput jarInput : jarInputs){
                File dest = outputProvider.getContentLocation(
                        jarInput.getName(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR
                );
                // Here we simply copy the JAR file to the output location
                FileUtils.copyFile(jarInput.getFile(), dest);
            }

            Collection<DirectoryInput> directoryInputs = transformInput.getDirectoryInputs();
            // Traversal, processing folders
            for(DirectoryInput directoryInput : directoryInputs){
                File dest = outputProvider.getContentLocation(
                        directoryInput.getName(),
                        directoryInput.getContentTypes(),
                        directoryInput.getScopes(),
                        Format.DIRECTORY
                );
                // Here we simply recursively copy all the files in the folder to the output locationFileUtils.copyDirectory(directoryInput.getFile(), dest); }}}Copy the code

Take the input, walk through all the JarInput and DirectoryInput in the input, and then simply redirect the corresponding input to the output location. In the process, we can also get the class files in jar files and folders, modify the class files and redirect to the output. This is the goal of modifying the bytecode during compilation, which is at the heart of later plug-in implementations.

The output of each Transform is used as the input to the next Transform, which is executed sequentially, as follows:

Now that you have a general understanding of both Asm and Transform, you can start implementing the function time detection plug-in.

A plugin

Checking the time of a function is as simple as adding time detection logic at the beginning and end of each method. For example:

protected void onCreate(Bundle savedInstanceState) {
  long startTime = System.currentTimeMillis();//start
  
  super.onCreate(savedInstanceState);
  
  long endTime = System.currentTimeMillis();//end
  long costTime = endTime - startTime;
  if(costTime > 100){
    StackTraceElement thisMethodStack = (new Exception()).getStackTrace()[0];// Get the StackTraceElement for the current method
    Log.e("TimeCost", String.format(
      "===> %s.%s(%s:%s) method takes %d ms",
      thisMethodStack.getClassName(), // Fully qualified name of the class
      thisMethodStack.getMethodName(),/ / the method name
      thisMethodStack.getFileName(),  // Class file name
      thisMethodStack.getLineNumber(),/ / line number
      costTime                        // The method is time consuming)); }}Copy the code

It is impossible to manually add this code to the beginning and end of every method in the application. There are too many methods in the application, so we need the Gradle plugin to repeat the process for us. During the compilation of the project, we will use the Transform to retrieve the bytecode of every class in the project. If you don’t know how to customize a Gradle plugin, go back to the previous article. I put the Gradle plugin implementation code in the buildSrc directory. The directory structure of the whole project is as follows:

The code for the Plugin and Transform implementations is placed under com.example.plugin, and the code for the Asm implementation is placed under com.example.asm.

1. Customize Plugin

The corresponding code of custom Plugin is as follows:

public class TimeCostPlugin implements Plugin<Project> {

    // When the running time of the function is greater than the threshold, it is considered as a time consuming function, unit: ms
    public static long sThreshold = 100L;
    // Print only the time functions in the package when the package has a value
    public static String sPackage = "";

    @Override
    public void apply(Project project) {
        try {
            // Register an extension named time with the project instance
            Time time = project.getExtensions().create("time", Time.class);
            // Get the assignment in the time extension after the project is built
            project.afterEvaluate(project1 -> {
                if(time.getThreshold() >= 0){
                    sThreshold = time.getThreshold();
                }
                if(time.getAppPackage().length() > 0){ sPackage = time.getAppPackage(); }});// Get the android Gradle plugin extension instance named Android from the project instance
            AppExtension appExtension = (AppExtension) project.getExtensions().getByName("android");
            // Register our custom Transform in the Android Gradle Plugin by calling the appExtension instance registerTransform
            appExtension.registerTransform(new TimeCostTransform());
        }catch(UnknownDomainObjectException e){ e.printStackTrace(); }}/** * extend the corresponding bean class */
    static class Time{

        private long mThreshold = -1;
        private String mPackage = "";

        public Time(a){}

        public long getThreshold(a) {
            return mThreshold;
        }

        public void setThreshold(long threshold) {
            this.mThreshold = threshold;
        }

        public String getAppPackage(a) {
            return mPackage;
        }

        public void setAppPackage(String p) {
            this.mPackage = p; }}}Copy the code

The TimeCostPlugin does two things:

1. Define an extension named time, the extension corresponding to the bean class is time, with this extension we can configure our plug-in in build.gradle, here I define the function time threshold and print functions through package filtering. Then we can use this in app/build.gradle:

apply plugin: com.example.plugin.TimeCostPlugin
// The function time threshold is 200ms, and only the functions in the application are inserted (excluding third-party libraries)
time{
    threshold = 200
    filter = 'com.example.plugindemo'
}
Copy the code

The assignment to the extended attribute is not available until after the project is built, so register the afterEvaluate callback in the project to get the assignment to the time extended attribute.

2. Inject our custom Transform into the Android Gradle Plugin. The Android Gradle Plugin is named android Gradle extension and the corresponding bean class is AppExtension class. AppExtension has a List of elements of type Transform. We call the registerTransform method to put TimeCostTransform into this collection. This Transform set will be used in the Android Gradle Plugin, which also registers the afterEvaluate callback of the project, In the callback it generates TransformTask for each Transform.

2, custom Transform

The corresponding code of the custom Transform is as follows:

public class TimeCostTransform extends Transform {

    private static final String TAG = TimeCostTransform.class.getSimpleName();/ / the name of the class

    @Override
    public String getName(a) {
        return TAG;
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental(a) {
        return true;
    }

    @Override
    public void transform(TransformInvocation transformInvocation) throws IOException {
        System.out.println("transform(), ---------------------------start------------------------------");

        Collection<TransformInput> transformInputs = transformInvocation.getInputs();
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
      
        // check whether the Transform task isIncremental by using the isIncremental method of TransformInvocation. If the isIncremental method of the Transform returns false, The isIncremental method of TransformInvocation always returns false
        boolean isIncremental = transformInvocation.isIncremental();

        System.out.println("transform(), isIncremental = " + isIncremental);

        // If not, delete all previously generated output and start again
        if(! isIncremental){ outputProvider.deleteAll(); }// Iterates through all the inputs, each containing a set of files for the two input types: JAR and directory
        for(TransformInput transformInput : transformInputs){
            Collection<JarInput> jarInputs = transformInput.getJarInputs();
            // Iterate over all jar file inputs
            for(JarInput jarInput : jarInputs){
                // Determine whether the Transform task is incremental
                if(isIncremental){
                    // Incrementally process the Jar file
                    handleJarIncremental(jarInput, outputProvider);
                }else {
                    // Handle Jar files non-incrementally
                    handleJar(jarInput, outputProvider);
                }
            }

            Collection<DirectoryInput> directoryInputs = transformInput.getDirectoryInputs();
            // Iterate through all the input directory files
            for(DirectoryInput directoryInput : directoryInputs){
                // Determine whether the Transform task is incremental
                if(isIncremental){
                    // Incrementally process directory files
                    handleDirectoryIncremental(directoryInput, outputProvider);
                }else {
                    // Non-incrementally process directory files
                    handleDirectory(directoryInput, outputProvider);
                }
            }
        }

        System.out.println("transform(), ---------------------------end------------------------------");
    }

  / /...
}
Copy the code

According to the previous explanation of Transform, the meaning of each method in TimeCostTransform should be better understood. The most important one is the Transform method. Since I returned true in the isIncremental method indicating that TimeCostTransform supports incremental compilation, I need to do both full processing and incremental processing in the transform method depending on whether it isIncremental compilation or not. As the processing of JAR files and directory files are the same, the following is the processing of JAR files as an example, for the processing of directory files can be viewed at the end of the source link:

1. HandleJar method, which processes the jar file input in full, producing a new output:

private void handleJar(JarInput jarInput, TransformOutputProvider outputProvider) throws IOException {
  // Get the input JAR file
  File srcJar = jarInput.getFile();
  // Construct the output location from the input using the getContentLocation method of the TransformOutputProvider
  File destJar = outputProvider.getContentLocation(
    jarInput.getName(),
    jarInput.getContentTypes(),
    jarInput.getScopes(),
    Format.JAR
  );
  // Iterate through the contents of the srcJar and copy the contents of the srcJar to the destJar one by one
  // If it is found that the content entry is a class file, it is modified by ASM and copied to the destJar
  foreachJarWithTransform(srcJar, destJar);
}
Copy the code

The handleJar method determines the input and output and then calls the foreachJarWithTransform method as follows:

private void foreachJarWithTransform(File srcJar, File destJar) throws IOException {
  try(
    JarFile srcJarFile = new JarFile(srcJar);
    JarOutputStream destJarFileOs = new JarOutputStream(new FileOutputStream(destJar))
  ){
    Enumeration<JarEntry> enumeration = srcJarFile.entries();
    // Iterate through each item in the srcJar
    while (enumeration.hasMoreElements()){
      JarEntry entry = enumeration.nextElement();
      try(
        // Get the input stream for each entry
        InputStream entryIs = srcJarFile.getInputStream(entry)
      ){
        destJarFileOs.putNextEntry(new JarEntry(entry.getName()));
        if(entry.getName().endsWith(".class")) {// If it is a class file
          // Use ASM to modify the source class file
          ClassReader classReader = new ClassReader(entryIs);
          ClassWriter classWriter = new ClassWriter(0);
          TimeCostClassVisitor timeCostClassVisitor = new TimeCostClassVisitor(classWriter);
          classReader.accept(timeCostClassVisitor, ClassReader.EXPAND_FRAMES);
          // Then copy the modified class file to the destJar
          destJarFileOs.write(classWriter.toByteArray());
        }else {// If it is not a class file
          // Copy it intact to the destJardestJarFileOs.write(IOUtils.toByteArray(entryIs)); } destJarFileOs.closeEntry(); }}}}Copy the code

Since the input is a JAR file, and a JAR file is essentially a ZIP file, the foreachJarWithTransform is like decompressing the JAR file, and then iterating through all the files in the decompressed JAR file to determine whether the file is a.class file. If the file is a.class file, it will be printed by ASM. If it is not, it will be copied to the output as it is. The logic is very simple.

2. The handleJarIncremental method increments the JAR file input and may produce new output:

private void handleJarIncremental(JarInput jarInput, TransformOutputProvider outputProvider) throws IOException {
  // Get the status of the input file
  Status status = jarInput.getStatus();
  // Perform different operations based on the Status of the file
  switch (status){
    case ADDED:
    case CHANGED:
      handleJar(jarInput, outputProvider);
      break;
    case REMOVED:
      // Delete all output
      outputProvider.deleteAll();
      break;
    case NOTCHANGED:
      //do nothing
      break;
    default:}}Copy the code

Incremental processing in the handleJarIncremental method is done by using the ADDED and CHANGED jar files based on their Status. REMOVED means that the input has been REMOVED, so the corresponding output is REMOVED. NOTCHANGED means that the input has NOTCHANGED, so it is skipped.

3. Asm processes class files

Transform (” transform “, “file”, “file”, “file”, “file”, “file”, “file”);

if(entry.getName().endsWith(".class")) {// If it is a class file
  // Use ASM to modify the source class file
  ClassReader classReader = new ClassReader(entryIs);
  ClassWriter classWriter = new ClassWriter(0);
  TimeCostClassVisitor timeCostClassVisitor = new TimeCostClassVisitor(classWriter);
  classReader.accept(timeCostClassVisitor, ClassReader.EXPAND_FRAMES);
  // Then copy the modified class file to the destJar
  destJarFileOs.write(classWriter.toByteArray());
}
Copy the code

As described earlier in ASM, this is the step to use ASM to transform a class. First, use ClassReader to read the class file. Then call ClassReader’s Accept method to open access to the class visitor using TimeCostClassVisitor. The converted class byte stream is eventually obtained through the toByteArray method of ClassWriter, so the logic for modifying the class file is in the TimeCostClassVisitor, as follows:

public class TimeCostClassVisitor extends ClassVisitor implements Opcodes {

    private String mPackage;/ / package name
    private String mCurClassName;// The fully qualified name of the class being accessed
    private boolean isExcludeOtherPackage;// Whether to exclude classes that are not part of package

    public TimeCostClassVisitor(ClassVisitor classVisitor) {
        super(ASM7, classVisitor);
        mPackage = TimeCostPlugin.sPackage;
        if(mPackage.length() > 0){
            mPackage = mPackage.replace("."."/");
        }
        isExcludeOtherPackage = mPackage.length() > 0;
    }

    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces);
        mCurClassName = name;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
        if(isExcludeOtherPackage){
           // If the class corresponding to the method is in the package
            if(mCurClassName.startsWith(mPackage) && !"<init>".equals(name)){
                return newTimeCostMethodVisitor(methodVisitor, access, descriptor); }}else {
            if(!"<init>".equals(name)){
                return newTimeCostMethodVisitor(methodVisitor, access, descriptor); }}returnmethodVisitor; }}Copy the code

TimeCostClassVisitor inherits from ClassVisitor. Since we only need to modify the methods in the class file, we only override the visit and visitMethod methods of the ClassVisitor. The visit method obtains the fully qualified name of the currently accessed class, which is combined with the visitMethod and the package name obtained by the TimeCostPlugin extension to determine whether the methods of this class need to be filtered out. If this class is not a class in the package, So instead of changing the methods in the class file of this class, skip, if this class is a class in the package, return TimeCostMethodVisitor, and change the methods in the class file in TimeCostMethodVisitor, So the logic for modifying the methods in the class file is in the TimeCostMethodVisitor, as follows:

class TimeCostMethodVisitor extends LocalVariablesSorter implements Opcodes {

  // Local variables
  int startTime, endTime, costTime, thisMethodStack;

  public TimeCostMethodVisitor(MethodVisitor methodVisitor, int access, String desc) {
    super(ASM7, access, desc, methodVisitor);
  }

  @Override
  public void visitCode(a) {
    super.visitCode();
    / /... Methods the beginning
    //long startTime = System.currentTimeMillis();
  }

  @Override
  public void visitInsn(int opcode) {
    if(opcode == RETURN){
      / /... Methods the ending
      //long endTime = System.currentTimeMillis();
      //long costTime = endTime - startTime;
      //if(costTime > 100){
      // StackTraceElement thisMethodStack = (new Exception()).getStackTrace()[0]; // Get the StackTraceElement for the current method
     // Log.e("TimeCost", String.format(
     // "===> %s.%s(%s:%s) ",
     / / thisMethodStack getClassName (), / / the fully qualified name of the class
     / / thisMethodStack getMethodName (), / / the method name
     / / thisMethodStack getFileName (), / / the class file name
     / / thisMethodStack getLineNumber (), / / line number
     // costTime // The method is time-consuming
     / /)
     / /);
     / /}
    }
    super.visitInsn(opcode); }}Copy the code

What we need to do is insert the code for the function time detection logic before and after the method, and the visitCode method is called when the bytecode of the method is first generated, which is called when the method begins, and the visitInsn method is called when the RETURN directive is called, which is called when the method ends normally, Asm will automatically convert the ASM code into bytecode for us, so that the generated method bytecode will contain the bytecode for our function time detection logic. TimeCostMethodVisitor inherits from LocalVariablesSorter, and LocalVariablesSorter inherits from MethodVisitor, which extends MethodVisitor, This makes it convenient to use local variables such as startTime, endTime, costTime, and thisMethodStack in the visitXX method of the MethodVisitor through the ASM code.

So we can use the ASM plugin described above to generate the ASM code for function time detection, as follows:

Because the generated ASM code is too long and the screenshots are incomplete, the asm code of the onCreate method header and end and the super.onCreate(savedInstanceState) code are removed, and the rest of the ASM code belongs to the function time detection logic. I have made some simplification. Remove some of the unnecessary visitLabel and visitLineNumber and copy it into the TimeCostMethodVisitor as follows:

class TimeCostMethodVisitor extends LocalVariablesSorter implements Opcodes {

    // Local variables
    int startTime, endTime, costTime, thisMethodStack;

    public TimeCostMethodVisitor(MethodVisitor methodVisitor, int access, String desc) {
        super(ASM7, access, desc, methodVisitor);
    }

    @Override
    public void visitCode(a) {
        super.visitCode();
        //long startTime = System.currentTimeMillis();
        mv.visitMethodInsn(INVOKESTATIC, "java/lang/System"."currentTimeMillis"."()J".false);
        startTime = newLocal(Type.LONG_TYPE);
        mv.visitVarInsn(LSTORE, startTime);
    }

    @Override
    public void visitInsn(int opcode) {
        if(opcode == RETURN){
            //long endTime = System.currentTimeMillis();
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System"."currentTimeMillis"."()J".false);
            endTime = newLocal(Type.LONG_TYPE);
            mv.visitVarInsn(LSTORE, endTime);

            //long costTime = endTime - startTime;
            mv.visitVarInsn(LLOAD, endTime);
            mv.visitVarInsn(LLOAD, startTime);
            mv.visitInsn(LSUB);
            costTime = newLocal(Type.LONG_TYPE);
            mv.visitVarInsn(LSTORE, costTime);

            // Check whether costTime is greater than sThreshold
            mv.visitVarInsn(LLOAD, costTime);
            mv.visitLdcInsn(new Long(TimeCostPlugin.sThreshold));// The threshold is controlled by the TimeCostPlugin extension attribute threshold
            mv.visitInsn(LCMP);

            //if costTime <= sThreshold, jump to the end marker, otherwise continue to execute
            Label end = new Label();
            mv.visitJumpInsn(IFLE, end);

            //StackTraceElement thisMethodStack = (new Exception()).getStackTrace()[0]
            mv.visitTypeInsn(NEW, "java/lang/Exception");
            mv.visitInsn(DUP);
            mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Exception"."<init>"."()V".false);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Exception"."getStackTrace"."()[Ljava/lang/StackTraceElement;".false);
            mv.visitInsn(ICONST_0);
            mv.visitInsn(AALOAD);
            thisMethodStack = newLocal(Type.getType(StackTraceElement.class));
            mv.visitVarInsn(ASTORE, thisMethodStack);

            / / the e (" rain ", the String. Format (" = = = > % s. % s (% s: % s) method takes % d ms ", thisMethodStack. GetClassName (), thisMethodStack.getMethodName(),thisMethodStack.getFileName(),thisMethodStack.getLineNumber(),costTime));
            mv.visitLdcInsn("TimeCost");
            mv.visitLdcInsn("===> %s.%s(%s:%s)\u65b9\u6cd5\u8017\u65f6 %d ms");
            mv.visitInsn(ICONST_5);
            mv.visitTypeInsn(ANEWARRAY, "java/lang/Object");
            mv.visitInsn(DUP);
            mv.visitInsn(ICONST_0);
            mv.visitVarInsn(ALOAD, thisMethodStack);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StackTraceElement"."getClassName"."()Ljava/lang/String;".false);
            mv.visitInsn(AASTORE);
            mv.visitInsn(DUP);
            mv.visitInsn(ICONST_1);
            mv.visitVarInsn(ALOAD, thisMethodStack);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StackTraceElement"."getMethodName"."()Ljava/lang/String;".false);
            mv.visitInsn(AASTORE);
            mv.visitInsn(DUP);
            mv.visitInsn(ICONST_2);
            mv.visitVarInsn(ALOAD, thisMethodStack);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StackTraceElement"."getFileName"."()Ljava/lang/String;".false);
            mv.visitInsn(AASTORE);
            mv.visitInsn(DUP);
            mv.visitInsn(ICONST_3);
            mv.visitVarInsn(ALOAD, thisMethodStack);
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StackTraceElement"."getLineNumber"."()I".false);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Integer"."valueOf"."(I)Ljava/lang/Integer;".false);
            mv.visitInsn(AASTORE);
            mv.visitInsn(DUP);
            mv.visitInsn(ICONST_4);
            mv.visitVarInsn(LLOAD, costTime);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/Long"."valueOf"."(J)Ljava/lang/Long;".false);
            mv.visitInsn(AASTORE);
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/String"."format"."(Ljava/lang/String; [Ljava/lang/Object;)Ljava/lang/String;".false);
            mv.visitMethodInsn(INVOKESTATIC, "android/util/Log"."e"."(Ljava/lang/String; Ljava/lang/String;) I".false);
            mv.visitInsn(POP);

            // End at the end of the method
            mv.visitLabel(end);
        }
        super.visitInsn(opcode); }}Copy the code

Each of these comments represents the asm code below. For local variables, the newLocal method of LocalVariablesSorter is used. If you look closely at the generated ASM code, it is quite regular. When the method bytecodes are generated using the visitXX method of the MethodVisitor, they are called in the following order (ignore the annotation annotations) :

[visitCode]
[visitLabel | visitLineNumber | visitFrame | visitXXInsn | visitLocalVariable | visitTryCatchBlock]
[visitMax]
visitEnd

// Where [] is optional and | is level
Copy the code

Similar to ClassVisitor, but begins with visitCode to generate the method body bytecode, calls visitLabel, visitXXInsn and so on to generate the method body bytecode, and then ends with a visitMax, and always ends with a visitEnd, If the method does not have a method body, then just call a visitEnd.

At this point the function time detection plugin is complete, using the same method as the gradle plugin.

conclusion

The gradle plugin is still rudimentary and can be extended, such as ns time threshold support, printing out the call stack when a time consuming function is found, but the purpose of this article is to learn how to customize gradle plugins, as well as asm and Transform. Since android Gradle API 3.6, most of the built-in Transforms used in ApK packaging have been implemented using tasks directly. For example, DesugarTransform -> DesugarTask, MergeClassesTransform -> MergeClassesTask, etc., may be to improve the efficiency of the build, which also shows that the transform is essentially dependent on task to complete, It is not a new thing, it is just android Gradle API for external, convenient external operation of bytecode tools, while Android Gradle API also has a lot of apK build with plug-ins, such as AppPlugin, LibrayPlugin, etc. We can also select one as a reference when writing gradle plug-ins.

That’s all for this article!

Source address of this article

References:

Android Gradle Plugin packs the Transform API of Apk

Play with bytecode in your Android project

Read AOP

Android Gradle Plugin main process analysis