In this article

Bytecode piling is implemented using GradlePlugin, Transform, and ASM. The code of GradlePlugin is written by Kotlin, so you can read it easily if you are not familiar with Groovy.

What is bytecode staking

To understand bytecode piling, we should first understand the idea of AOP(Aspect Oriented Programming). In contrast, OOP(object-oriented Programming) mainly encapsulates the entities, attributes and behaviors of business processes, in order to have a clearer and efficient division of logical units. AOP(aspect oriented programming) is to extract the aspects of the business process, which is faced with a step or stage in the process, in order to achieve the isolation effect of low coupling between the parts of the logical process.

Insert position of bytecode

Android developers should be familiar with this image (from Android Developer) :

In Compilers, they are:

Bytecode staking is to modify the.class file to modify the code before the.class file is converted to the.dex.

The business scenario

Bytecode staking can be used for logging, performance statistics, exception handling, modifying tripartite library code, and so on.

Knowledge to master

When using bytecode staking in Android projects, you need to master it

  • Knowledge of GradlePlugin
  • Understand the Transform API
  • Understand the word ASM, Javassist or other related peg framework

Create your GradlePlugin

GradlePlugin can also be found in the official documentation

Three ways to create

  • Build. Gradle. Write plugin code directly into the build.gradle of the corresponding model (directly, but only for a single model).

  • Write plugin code in buildSrc (scope is the entire project)

  • Create plugin module, write resource file xxx.properties file to determine plugin name and file corresponding address (compared to the first two implementation methods are more complex, but can be uploaded to a public warehouse for multi-project reuse)

Mark the location of the module file in the Properties file:

The properties file

Introduced the module name: the properties file filename (example for the com. Shawn. Addlogformethodplugin)

build.gradle

Maven libraries are introduced to facilitate local debugging.

Using the plugin

  • In the first case plugin is the default package name and can be referenced directly

  • In the second method, the import is based on the package name defined by the user

  • The third way is to first import the repository in the project root directory and then reference it

Build. Gradle in the root directory

Inside the model build. Gradle

Understand the Transform

What is the Transform

Starting with 1.5.0-beta1, the Gradle plug-in includes a Transform API that allows third-party plug-ins to operate on compiled class files before converting them to dex files. (This API already existed in 1.4.0-beta2, but has been radically improved in 1.5.0-beta1)

How Transform works

An interceptor similar to OkHttp, in chain of responsibility mode. Each Transform is a Task, and the compiled intermediate is passed across multiple Transform chains. The user defined Transform is executed first in the header. Since the source of each Transform is provided by the previous Transform, if the user defined Transform does not move the corresponding file to the specified output location, The next Transform will not work.

Transform correlation method

getName()

    /** * Returns the unique name of the transform. * * 

This is associated with the type of work that the transform does. It does not have to be * unique per variant. */

@NonNull public abstract String getName(a); Copy the code

According to the current name of the Transform, the name will be used to create the directory, it will appear in the app/build/intermediates/transforms directory.

getInputTypes()

    /**
     * Returns the type(s) of data that is consumed by the Transform. This may be more than
     * one type.
     *
     * <strong>This must be of type {@link QualifiedContent.DefaultContentType}</strong>
     */
    @NonNull
    public abstract Set<ContentType> getInputTypes(a);
Copy the code

The data types to be processed to determine which types of results we need to modify, such as classes, resource files, etc.

getScopes()

    /** * Returns the scope(s) of the Transform. This indicates which scopes the transform consumes. */
    @NonNull
    public abstract Set<? super Scope> getScopes();
Copy the code

Represents the scope of content that the Transform will operate on

These scopes can be combined freely (e.g. SCOPE_FULL_PROJECT includes scope. PROJECT, scope. SUB_PROJECTS, scope. EXTERNAL_LIBRARIES).

isIncremental()

    /** * Returns whether the Transform can perform incremental work. * * 

If it does, then the TransformInput may contain a list of changed/removed/added files, unless * something else triggers a non incremental run. */

public abstract boolean isIncremental(a); Copy the code

Whether incremental updates are supported: If true, TransformInput will contain a list of modified files, if false, a full compilation will remove the last output.

Description of related status:

  • NOTCHANGED: No processing is required.

  • REMOVED: Files need to be REMOVED.

  • ADDED, CHANGED: The file needs to be modified normally.

transform()

    /**
     * Executes the Transform.
     *
     * <p>The inputs are packaged as an instance of {@link TransformInvocation}
     * <ul>
     *     <li>The <var>inputs</var> collection of {@link TransformInput}. These are the inputs
     *     that are consumed by this Transform. A transformed version of these inputs must
     *     be written into the output. What is received is controlled through
     *     {@link #getInputTypes()}, and {@link #getScopes()}.</li>
     *     <li>The <var>referencedInputs</var> collection of {@link TransformInput}. This is
     *     for reference only and should be not be transformed. What is received is controlled
     *     through {@link #getReferencedScopes()}.</li>
     * </ul>
     *
     * A transform that does not want to consume anything but instead just wants to see the content
     * of some inputs should return an empty set in {@link #getScopes()}, and what it wants to
     * see in {@link #getReferencedScopes()}.
     *
     * <p>Even though a transform's {@linkTransform#isIncremental()} returns true, this method may * be receive <code>false</code> in <var>isIncremental</var>. This can be due to * <ul> * <li>a change in  secondary files ({@link #getSecondaryFiles()},
     *     {@link #getSecondaryFileOutputs()}, {@link #getSecondaryDirectoryOutputs()})</li>
     *     <li>a change to a non file input ({@link#getParameterInputs()})</li> * <li>an unexpected change to the output files/directories. This should not happen unless *  tasks are improperly configured and clobber each other's output.</li> * <li>a file deletion that the transform mechanism could not match to a previous input. * This should not happen in most case, except in some cases where dependencies have * changed.</li> * </ul> * In such an event, when <var>isIncremental</var> is false, the inputs will not have any * incremental change information: * <ul> * <li>{@link JarInput#getStatus()} will return {@link Status#NOTCHANGED} even though
     *     the file may be added/changed.</li>
     *     <li>{@link DirectoryInput#getChangedFiles()} will return an empty map even though
     *     some files may be added/changed.</li>
     * </ul>
     *
     * @param transformInvocation the invocation object containing the transform inputs.
     * @throws IOException if an IO error occurs.
     * @throws InterruptedException
     * @throws TransformException Generic exception encapsulating the cause.
     */
    public void transform(@NonNull TransformInvocation transformInvocation)
            throws TransformException, InterruptedException, IOException {
        // Just delegate to old method, for code that uses the old API.
        //noinspection deprecation
        transform(transformInvocation.getContext(), transformInvocation.getInputs(),
                transformInvocation.getReferencedInputs(),
                transformInvocation.getOutputProvider(),
                transformInvocation.isIncremental());
    }

Copy the code

GetInputs () is available here. If getInputs() is consumed, the transform must be output to the next level, otherwise the next level cannot read the compiled middleware. IsIncremental is used for incremental compilation.

    override fun transform(transformInvocation: TransformInvocation?). {
        valinputs = transformInvocation? .inputsval out= transformInvocation? .outputProvider inputs? .forEach { transformInput ->// Project directory
            transformInput.directoryInputs.forEach { directoryInput ->
                if (directoryInput.file.isDirectory) {
                    FileUtils.getAllFiles(directoryInput.file).forEach {
                        val file = it
                        val name = file.name
                        if (name.endsWith(".class") && name ! ="R.class" && !name.startsWith("R\$") && name ! ="BuildConfig.class") {
                            val classPath = file.absolutePath
                            val cr = ClassReader(file.readBytes())
                            val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
                            val visitor = AddLogClassVisitor(cw)
                            cr.accept(visitor, ClassReader.EXPAND_FRAMES)

                            val byte = cw.toByteArray();
                            val fos = FileOutputStream(classPath)
                            fos.write(byte)
                            fos.close()
                        }
                    }
                }

                val dest = out? .getContentLocation( directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY ) FileUtils.copyDirectoryToDirectory(directoryInput.file, dest) }/ / the jar package
            transformInput.jarInputs.forEach {
                val dest = out? .getContentLocation( it.name, it.contentTypes, it.scopes, Format.JAR ) FileUtils.copyFile(it.file, dest) } } }Copy the code

ASM Practice Scheme

Bytecode related knowledge please check for yourself, this article will not explain too much.

ASM core API

The ASM Core API can mimic the SAX approach to parsing XML files by streaming bytecode files without reading in the entire structure of the class. The advantage is that it is very memory efficient, but difficult to program. However, for performance reasons, programming typically uses the Core API. There are several key classes in the Core API:

  • ClassReader: Used to read compiled. Class files.
  • ClassWriter: A bytecode file used to rebuild a compiled class to generate a new class.
  • A variety of Visitor classes: The CoreAPI works from top to bottom based on the bytecode, with different visitors for different areas of the bytecode file, such as MethodVisitor for accessing methods, FieldVisitor for accessing class variables, AnnotationVisitor for accessing annotations, and so on. To implement AOP, the focus is on using the MethodVisitor.

Situational goal

To add a custom method call to all methods in mainActivity.kt:

  • First read the class file you want to modify in the transform() method. Create a ClassVisitor class and pass in a ClassWriter for a bytecode location modification operation, pass in a ClassVisitor for a ClassReader to rebuild a new bytecode file.
    val classPath = file.absolutePath
    val cr = ClassReader(file.readBytes())
    val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
    val visitor = AddLogClassVisitor(cw)
    cr.accept(visitor, ClassReader.EXPAND_FRAMES)

    val byte = cw.toByteArray()
    val fos = FileOutputStream(classPath)
    fos.write(byte)
    fos.close()
Copy the code
  • Write a custom Visitor class that inherits from ClassVisitor to implement method positioning.
  1. The visit() method gets information about the class:
 public void visit(
      final int version,
      final int access,
      final String name,
      final String signature,
      final String superName,
      final String[] interfaces) {
    if(cv ! =null) { cv.visit(version, access, name, signature, superName, interfaces); }}Copy the code
  1. The visitMethod() method traverses all method information to locate methods that need to be modified.
  public MethodVisitor visitMethod(
      final int access,
      final String name,
      final String descriptor,
      final String signature,
      final String[] exceptions) {
    if(cv ! =null) {
      return cv.visitMethod(access, name, descriptor, signature, exceptions);
    }
    return null;
  }
Copy the code

Find the method of locating a target by name here:

AddLogClassVisitor.kt

class AddLogClassVisitor(classVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM7, classVisitor) {
    private var className: String? = null
    override fun visit(
        version: Int,
        access: Int,
        name: String? , signature:String? , superName:String? , interfaces:Array<out String>? {
        super.visit(version, access, name, signature, superName, interfaces)
        className = name
        println("className = $className")}override fun visitMethod(
        access: Int,
        name: String? , descriptor:String? , signature:String? , exceptions:Array<out String>?: MethodVisitor {
        val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
        println("method = $name")

        if (className.equals("com/shawn/krouter/MainActivity") && name ! ="<init>") {
            return if (name == "onCreate") {
                AddLogInitMethodVisitor(methodVisitor)
            } else {
                AddLogMethodVisitor(methodVisitor)
            }
        }

        return methodVisitor
    }
}
Copy the code
  • Write a custom Visitor class that inherits from MethodVisitor to make bytecode modifications.
  1. The bytecode in the visitCode() method is inserted before the method is executed.
  /** Starts the visit of the method's code, if any (i.e. non abstract method). */
  public void visitCode(a) {
    if(mv ! =null) { mv.visitCode(); }}Copy the code
  1. In visitInsn (opcode: Int) method when the opcode = = ARETURN | | opcode = = RETURN, behind the byte code will be inserted into the method of the insert.
  /**
   * Visits a zero operand instruction.
   *
   * @param opcode the opcode of the instruction to be visited. This opcode is either NOP,
   *     ACONST_NULL, ICONST_M1, ICONST_0, ICONST_1, ICONST_2, ICONST_3, ICONST_4, ICONST_5,
   *     LCONST_0, LCONST_1, FCONST_0, FCONST_1, FCONST_2, DCONST_0, DCONST_1, IALOAD, LALOAD,
   *     FALOAD, DALOAD, AALOAD, BALOAD, CALOAD, SALOAD, IASTORE, LASTORE, FASTORE, DASTORE,
   *     AASTORE, BASTORE, CASTORE, SASTORE, POP, POP2, DUP, DUP_X1, DUP_X2, DUP2, DUP2_X1, DUP2_X2,
   *     SWAP, IADD, LADD, FADD, DADD, ISUB, LSUB, FSUB, DSUB, IMUL, LMUL, FMUL, DMUL, IDIV, LDIV,
   *     FDIV, DDIV, IREM, LREM, FREM, DREM, INEG, LNEG, FNEG, DNEG, ISHL, LSHL, ISHR, LSHR, IUSHR,
   *     LUSHR, IAND, LAND, IOR, LOR, IXOR, LXOR, I2L, I2F, I2D, L2I, L2F, L2D, F2I, F2L, F2D, D2I,
   *     D2L, D2F, I2B, I2C, I2S, LCMP, FCMPL, FCMPG, DCMPL, DCMPG, IRETURN, LRETURN, FRETURN,
   *     DRETURN, ARETURN, RETURN, ARRAYLENGTH, ATHROW, MONITORENTER, or MONITOREXIT.
   */
  public void visitInsn(final int opcode) {
    if(mv ! =null) { mv.visitInsn(opcode); }}Copy the code

Here we insert the required bytecode:

AddLogMethodVisitor.kt

class AddLogMethodVisitor(methodVisitor: MethodVisitor) :
    MethodVisitor(Opcodes.ASM7, methodVisitor) {
    override fun visitCode(a) {
        super.visitCode()
        // method is inserted before execution
        mv.visitLdcInsn("AddLogMethodPlugin")
        mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder")
        mv.visitInsn(Opcodes.DUP)
        mv.visitMethodInsn(
            Opcodes.INVOKESPECIAL,
            "java/lang/StringBuilder"."<init>"."()V".false
        )
        mv.visitLdcInsn("into ")
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/lang/StringBuilder"."append"."(Ljava/lang/String;) Ljava/lang/StringBuilder;".false
        )
        mv.visitTypeInsn(Opcodes.NEW, "java/lang/Exception")
        mv.visitInsn(Opcodes.DUP)
        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Exception"."<init>"."()V".false)
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/lang/Exception"."getStackTrace"."()[Ljava/lang/StackTraceElement;".false
        )
        mv.visitInsn(Opcodes.ICONST_0)
        mv.visitInsn(Opcodes.AALOAD)
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/lang/StackTraceElement"."getMethodName"."()Ljava/lang/String;".false
        )
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/lang/StringBuilder"."append"."(Ljava/lang/String;) Ljava/lang/StringBuilder;".false
        )
        mv.visitMethodInsn(
            Opcodes.INVOKEVIRTUAL,
            "java/lang/StringBuilder"."toString"."()Ljava/lang/String;".false
        )
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            "com/shawn/krouter/uitl/UtilsKt"."MyLogD"."(Ljava/lang/String; Ljava/lang/String;) V".false)}override fun visitInsn(opcode: Int) {
        // insert after method

        super.visitInsn(opcode)
    }
}
Copy the code
  • The requirements can be implemented by compiling the plugin and incorporating it into the corresponding model.

Viewing the class file, you can see that the bytecode was inserted successfully

The log output

Easily generate insert code

The ASM Bytecode Outline plugin is recommended in many web articles to view bytecodes, but it is currently only installed in Intellij IDEA, not Android Studio. The ASM Bytecode Viewer allows you to insert Bytecode more easily.

  • First write the test. Java class, compile it as.class, and then use the ASM Bytecode Viewer plug-in to view the associated ASM code.

Test.java

package com.shawn.krouter;

import android.util.Log;

import com.shawn.krouter.uitl.UtilsKt;

public class Test {
    String name = "testFun";
    public void TestFun(a){
        Log.d("suihw"."lalala"); }}Copy the code

View ASM code through the plug-in:

package asm.com.shawn.krouter;

import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.Attribute;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.ConstantDynamic;
import org.objectweb.asm.FieldVisitor;
import org.objectweb.asm.Handle;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.TypePath;

public class TestDump implements Opcodes {

    public static byte[] dump() throws Exception {

        ClassWriter classWriter = new ClassWriter(0);
        FieldVisitor fieldVisitor;
        MethodVisitor methodVisitor;
        AnnotationVisitor annotationVisitor0;

        classWriter.visit(V1_8, ACC_PUBLIC | ACC_SUPER, "com/shawn/krouter/Test".null."java/lang/Object".null);

        classWriter.visitSource("Test.java".null);

        {
            methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>"."()V".null.null);
            methodVisitor.visitCode();
            Label label0 = new Label();
            methodVisitor.visitLabel(label0);
            methodVisitor.visitLineNumber(5, label0);
            methodVisitor.visitVarInsn(ALOAD, 0);
            methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object"."<init>"."()V".false);
            methodVisitor.visitInsn(RETURN);
            Label label1 = new Label();
            methodVisitor.visitLabel(label1);
            methodVisitor.visitLocalVariable("this"."Lcom/shawn/krouter/Test;".null, label0, label1, 0);
            methodVisitor.visitMaxs(1.1);
            methodVisitor.visitEnd();
        }
        {
            methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "TestFun"."()V".null.null);
            methodVisitor.visitCode();
            Label label0 = new Label();
            methodVisitor.visitLabel(label0);
            methodVisitor.visitLineNumber(7, label0);
            methodVisitor.visitLdcInsn("suihw");
            methodVisitor.visitLdcInsn("lalala");
            methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log"."d"."(Ljava/lang/String; Ljava/lang/String;) I".false);
            methodVisitor.visitInsn(POP);
            Label label1 = new Label();
            methodVisitor.visitLabel(label1);
            methodVisitor.visitLineNumber(8, label1);
            methodVisitor.visitInsn(RETURN);
            Label label2 = new Label();
            methodVisitor.visitLabel(label2);
            methodVisitor.visitLocalVariable("this"."Lcom/shawn/krouter/Test;".null, label0, label2, 0);
            methodVisitor.visitMaxs(2.1);
            methodVisitor.visitEnd();
        }
        classWriter.visitEnd();

        returnclassWriter.toByteArray(); }}Copy the code
  • Modify the test. Java class, write the relevant Java code you want to insert, and then compile it into a.class file:

Modified test.java

package com.shawn.krouter;

import android.util.Log;

import com.shawn.krouter.uitl.UtilsKt;

public class Test {
    String name = "testFun";
    public void TestFun(a){
        UtilsKt.MyLogD("suihw"."into " + new Exception().getStackTrace()[0].getMethodName());
        Log.d("suihw"."lalala"); }}Copy the code

View ASM code through the plug-in:

package asm.com.shawn.krouter;

import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.Attribute;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.ConstantDynamic;
import org.objectweb.asm.FieldVisitor;
import org.objectweb.asm.Handle;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.TypePath;

public class TestDump implements Opcodes {

    public static byte[] dump() throws Exception {

        ClassWriter classWriter = new ClassWriter(0);
        FieldVisitor fieldVisitor;
        MethodVisitor methodVisitor;
        AnnotationVisitor annotationVisitor0;

        classWriter.visit(V1_8, ACC_PUBLIC | ACC_SUPER, "com/shawn/krouter/Test".null."java/lang/Object".null);

        classWriter.visitSource("Test.java".null);

        {
            methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "<init>"."()V".null.null);
            methodVisitor.visitCode();
            Label label0 = new Label();
            methodVisitor.visitLabel(label0);
            methodVisitor.visitLineNumber(7, label0);
            methodVisitor.visitVarInsn(ALOAD, 0);
            methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object"."<init>"."()V".false);
            methodVisitor.visitInsn(RETURN);
            Label label1 = new Label();
            methodVisitor.visitLabel(label1);
            methodVisitor.visitLocalVariable("this"."Lcom/shawn/krouter/Test;".null, label0, label1, 0);
            methodVisitor.visitMaxs(1.1);
            methodVisitor.visitEnd();
        }
        {
            methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "TestFun"."()V".null.null);
            methodVisitor.visitCode();
            Label label0 = new Label();
            methodVisitor.visitLabel(label0);
            methodVisitor.visitLineNumber(9, label0);
            methodVisitor.visitLdcInsn("suihw");
            methodVisitor.visitTypeInsn(NEW, "java/lang/StringBuilder");
            methodVisitor.visitInsn(DUP);
            methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder"."<init>"."()V".false);
            methodVisitor.visitLdcInsn("into ");
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder"."append"."(Ljava/lang/String;) Ljava/lang/StringBuilder;".false);
            methodVisitor.visitVarInsn(ALOAD, 0);
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Object"."getClass"."()Ljava/lang/Class;".false);
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/lang/Class"."getSimpleName"."()Ljava/lang/String;".false);
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder"."append"."(Ljava/lang/String;) Ljava/lang/StringBuilder;".false);
            methodVisitor.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder"."toString"."()Ljava/lang/String;".false);
            methodVisitor.visitMethodInsn(INVOKESTATIC, "com/shawn/krouter/uitl/UtilsKt"."MyLogD"."(Ljava/lang/String; Ljava/lang/String;) V".false);
            Label label1 = new Label();
            methodVisitor.visitLabel(label1);
            methodVisitor.visitLineNumber(10, label1);
            methodVisitor.visitLdcInsn("suihw");
            methodVisitor.visitLdcInsn("lalala");
            methodVisitor.visitMethodInsn(INVOKESTATIC, "android/util/Log"."d"."(Ljava/lang/String; Ljava/lang/String;) I".false);
            methodVisitor.visitInsn(POP);
            Label label2 = new Label();
            methodVisitor.visitLabel(label2);
            methodVisitor.visitLineNumber(11, label2);
            methodVisitor.visitInsn(RETURN);
            Label label3 = new Label();
            methodVisitor.visitLabel(label3);
            methodVisitor.visitLocalVariable("this"."Lcom/shawn/krouter/Test;".null, label0, label3, 0);
            methodVisitor.visitMaxs(3.1);
            methodVisitor.visitEnd();
        }
        classWriter.visitEnd();

        returnclassWriter.toByteArray(); }}Copy the code
  • Finding the similarities and differences is the relevant bytecode that we need to copy and insert.

eggs

BuildSrc unified versioning

The official introduction

The buildSrc module is executed before the entire project is compiled, before build.gradle is executed at the root of the project, so it can be used for uniform version control and code prompts, which is very handy.

The project structure

build.gradle.kts

Dependencies.kt

Introduce in related modules:

Talk is cheap. Show me the code.