dd-booster源码分析(gradle插件开发、字节码、ASM、Booster)

一、背景

Booster 是一款专门为移动应用设计的易用、轻量级且可扩展的质量优化框架,其目标主要是为了解决随着 APP 复杂度的提升而带来的性能、稳定性、包体积等一系列质量问题。 Booster 提供了性能检测、多线程优化、资源索引内联、资源去冗余、资源压缩、系统 Bug 修复等一系列功能模块,可以使得稳定性能够提升 15% ~ 25%,包体积可以减小 1MB ~ 10MB。

github地址:github.com/didi/booste...

Booster是一款设计方面非常广的优化框架,研究Booster之前需要先了解Gradle插件、字节码、ASM。

二、gradle插件开发

开始之前先查看版本兼容性(重要!重要!重要!)

我使用的gradle-plugin版本是4.0.0;gradle版本是6.1.1

developer.android.google.cn/studio/rele...

研究Booster源码首先需要了解如何开发一个gradle插件,下面是快速上手版本

2.1 打开android studio创建新的android工程

2.2 创建moudle

2.3 配置moudle的build.gradle

php 复制代码
apply plugin: 'maven'
apply plugin: 'org.jetbrains.kotlin.jvm'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation gradleApi()

    implementation 'com.android.tools.build:gradle:4.0.0'

    implementation 'org.ow2.asm:asm:7.1'
    implementation 'org.ow2.asm:asm-commons:7.1'
}

group='com.tt.plugin'
version='1.0.0'

uploadArchives {
    repositories {
        mavenDeployer {
            group(group)
            version(version)
            //本地的Maven地址设置
            repository(url: uri('../maven_repo'))
        }
    }
}

2.4 实现

在main/resources/META_INF/gradle-plugins文件下创建如下文件

创建插件类

kotlin 复制代码
class DemoPlugin : Plugin<Project> {

    companion object {
        private const val TAG = "kotlin-DemoPlugin: "
    }
    
    override fun apply(project: Project) {
        println(TAG + project.name + ";num = 0")
        printDependencies(project)
    }

    private fun printDependencies(project: Project) {
        val dependExtension = project.extensions.create("printDepend", DenpeExtension::class.java)
        project.afterEvaluate {
            // build.gradle配置执行完成
            if (dependExtension.enable?.get() == true) {
                val configuration = project.configurations.getByName("debugCompileClasspath")
                val allDependencies = configuration.allDependencies
                allDependencies.forEach { denp ->
                    println("${TAG}group = ${denp.group};name = ${denp.name};version = ${denp.version}")
                }
            }
        }
    }

}
kotlin 复制代码
internal interface DenpeExtension {
    val enable: Property<Boolean?>?
}

2.5 发布

在2.3中我们已经配置好发布信息,执行uploadArchives命令即可

执行发布命令后,在项目根目录下出现maven-repo,说明成功了

2.6 使用

在项目的build.gradle中声明仓库地址和依赖

typescript 复制代码
buildscript {
    ext.kotlin_version = '1.6.10'
    repositories {
        google()
        mavenCentral()
        maven {
            url('./maven_repo')
        }
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:4.0.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath 'com.tt.plugin:plugin:1.0.0'
    }
}

allprojects {
    repositories {
        google()
        mavenCentral()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

在app的build.gradle使用插件,到这里你的插件就已经运行起来了

bash 复制代码
apply plugin: 'com.tt.plugin'

printDepend {
    enable = true
}

2.7 debug调试

打开Edit Configurations,创建Remote JVM Debug

修改项目的gradle.properties,增加如下配置,如果出现错误,将原来的值注释

ini 复制代码
org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
-Dorg.gradle.debug=true

断点调试,在代码处打断点,点击debug甲壳虫就可以了,如下

参考文章

如果对gradle不熟悉可以参考以下文章,再进行demo开发

1、入门gradle

juejin.cn/post/715510...

2、一文搞懂gradle配置

juejin.cn/post/716033...

3、gradle中的DSL、Groovy、Kotlin

juejin.cn/post/716663...

4、gradle的生命周期

juejin.cn/post/717068...

5、gradle常用命令与参数

juejin.cn/post/717149...

6、gradle依赖管理和版本决议

juejin.cn/post/721557...

7、gradle构建核心之Task指南

juejin.cn/post/724820...

8、gradle插件发布指南

juejin.cn/post/728006...

9、gradle plugin插件开发及其断点

juejin.cn/post/694862...

三、字节码

字节码文件本质上就是一张表,如下

字节码主要由以下七部分组成:

  • 魔数与Class文件版本
  • 常量池
  • 访问标志
  • 类索引、父类索引、接口索引
  • 字段表集合
  • 方法表集合
  • 属性表集合

创建Demo.java类,执行javac命令,得到Demo.class字节码文件

typescript 复制代码
public class Demo {
	public static void main(String args[]){
		System.out.println("Hello World.");
  }
}

使用文本编辑器打开Demo.class,字段含义如下(最好手动解析加深印象)

yaml 复制代码
cafe babe 魔数
0000      次版本号
0034      主版本号(jdk1.8)
001d      常量池(16进制1d转换为十进制是29,常量数量是29-1=28)
0a        (1)Methodref_info  
0006      常量池第6个常量所表示class_info信息         java/lang/Object    
000f      常量池第15个常量所表示name_and_type信息      <init>()V
09        (2)Fieldref_info
0010      常量池第16个常量所表示class_info信息        java/lang/System
0011      常量池第17个常量所表示name_and_type信息     out Ljava/io/PrintStream
08        (3)String_info                          
0012      常量池第18个常量索引                       Hello World.
0a        (4)Methodref_info 
0013      常量池第19个常量所表示class_info信息        java/io/PrintStream
0014      常量池第20个常量所表示name_and_type信息     println (Ljava/lang/String;)V
07        (5)Class_info
0015      常量池第21个常量索引                       Demo
07        (6)Class_info
0016      常量池第22个常量索引                       java/lang/Object
01        (7)Utf8_info
0006      字符串长度为6个字节
3c696e69743e    字符串的值                          <init>
01        (8)Utf8_info
0003      字符串长度为3个字节
282956                                            ()V
01        (9)Utf8_info
0004      字符串长度为4个字节
436f6465                                          Code
01        (10)Utf8_info
000f      字符串长度为15个字节
4c696e654e756d6265725461626c65                    LineNumberTable
01        (11)Utf8_info
0004      字符串长度为4个字节
6d61696e                                          main  
01        (12)Utf8_info
0016      字符串长度为22个字节
285b4c6a6176612f6c616e672f537472696e673b2956     ([Ljava/lang/String;)V
01        (13)Utf8_info
000a      字符串长度为10个字节
536f7572636546696c65                             SourceFile
01        (14)Utf8_info
0009      字符串长度为9个字节
44656d6f2e6a617661                               Demo.java
0c        (15)NameAndType_info
0007      常量池第7个常量所表示的信息                <init>
0008      常量池第8个常量所表示的信息                ()V
07        (16)Class_info
0017      常量池第23个常量所表示的信息               java/lang/System
0c        (17)NameAndType_info
0018      常量池第24个常量所表示的信息               out
0019      常量池第25个常量所表示的信息               Ljava/io/PrintStream;
01        (18)Utf8_info
000c      字符串长度为12个字节
48656c6c6f20576f726c642e                         Hello World.
07        (19)Class_info
001a      常量池第26个常量所表示的信息               java/io/PrintStream
0c        (20)NameAndType_info
001b      常量池第27个常量所表示的信息               println
001c      常量池第28个常量所表示的信息               (Ljava/lang/String;)V
01        (21)Utf8_info
0004      字符串长度为4个字节                 
44656d6f                                         Demo
01        (22)Utf8_info 
0010      字符串长度为16个字节
6a6176612f6c616e672f4f626a656374                 java/lang/Object
01        (23)Utf8_info
0010      字符串长度为16个字节
6a6176612f6c616e672f53797374656d                 java/lang/System
01        (24)Utf8_info
0003      字符串长度为3个字节
6f7574                                           out
01        (25)Utf8_info
0015      字符串长度为21个字节
4c6a6176612f696f2f5072696e7453747265616d3b       Ljava/io/PrintStream;
01        (26)Utf8_info
0013      字符串长度为19个字节
6a6176612f696f2f5072696e7453747265616d           java/io/PrintStream
01        (27)Utf8_info
0007      字符串长度为7个字节
7072696e746c6e                                   println
01        (28)Utf8_info
0015      字符串长度为21个字节
284c6a6176612f6c616e672f537472696e673b2956       (Ljava/lang/String;)V

0021      访问标志,0001或0020,public 并且允许使用invokespecial字节码指令的新语义
0005      类索引,常量池第5个常量所表示的信息          Demo
0006      父类索引,常量池第6个常量所表示的信息        java/lang/Object
0000      接口索引,没有实现接口,所以是0,所以没有后面的接口内容
0000      字段表数量,没有声明类级或实例级变量,所以没有后面的字段表
0002      方法表方法数量,每个方法都用method_info表示
0001      方法访问标识,                            ACC_PUBLIC
0007      方法名称索引项,指向常量池第7个常量所表示的信息    <init>
0008      方法描述符索引项,指向常量池第8个常量所表示的信息  ()V
0001      属性计表器
0009      指向常量池第9个常量所表示的信息                  Code
0000001d  属性长度29                                      
0001      max_stack,操作数栈深度最大值
0001      max_locals,局部变量所需的存储空间
00000005  字节码指令长度
2a        对应的指令为aload_0
b7        指令为invokespecial
0001      这是invokespecial的参数
b1        对应的指令为return
0000      异常表数据
0001      属性表长度
000a      指向常量池第10个常量所表示的信息                 LineNumberTable
00000006  属性长度6个字节
0001      line_number_info长度
0000      start_pc 表示的字节码行号为第 0 行
0001      line_number 表示 Java 源码行号为第 1 行
0009      方法访问标识,                                public static void
000b      常量池第11个常量所表示的信息                    main
000c      常量池第12个常量所表示的信息                    ([Ljava/lang/String;)V 
0001      属性表长度
0009      指向常量池第9个常量所表示的信息                  Code
00000025  属性长度为37
0002      max_stack,操作数栈深度最大值2,说明有2个局部变量
0001      max_locals,局部变量所需的存储空间
00000009  字节码指令长度
b2
0002
1203 
b600 
04b1
0000     异常表数据 
0001     属性表长度
000a     常量池第10个常量所表示的信息                      LineNumberTable
0000000a 属性长度10个字节
0002     line_number_info长度
0000     start_pc 表示的字节码行号为第 0 行
0003     line_number 表示 Java 源码行号为第 3 行
0008     start_pc 表示的字节码行号为第 8 行
0004     line_number 表示 Java 源码行号为第 4 行
0001     1个属性
000d     常量池第13个常量所表示的信息                      SourceFile
00000002 长度2个字节
000e     常量池第14个常量所表示的信息                      Demo.java

使用javap -verbose Demo.class命令自动解析字节码,和我们手动解析的进行对比,是一致的

less 复制代码
public class Demo

  minor version: 0

  major version: 52

  flags: ACC_PUBLIC, ACC_SUPER

Constant pool:

   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V

   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;

   #3 = String             #18            // Hello World.

   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V

   #5 = Class              #21            // Demo

   #6 = Class              #22            // java/lang/Object

   #7 = Utf8               <init>

   #8 = Utf8               ()V

   #9 = Utf8               Code

  #10 = Utf8               LineNumberTable

  #11 = Utf8               main

  #12 = Utf8               ([Ljava/lang/String;)V

  #13 = Utf8               SourceFile

  #14 = Utf8               Demo.java

  #15 = NameAndType        #7:#8          // "<init>":()V

  #16 = Class              #23            // java/lang/System

  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;

  #18 = Utf8               Hello World.

  #19 = Class              #26            // java/io/PrintStream

  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V

  #21 = Utf8               Demo

  #22 = Utf8               java/lang/Object

  #23 = Utf8               java/lang/System

  #24 = Utf8               out

  #25 = Utf8               Ljava/io/PrintStream;

  #26 = Utf8               java/io/PrintStream

  #27 = Utf8               println

  #28 = Utf8               (Ljava/lang/String;)V

{

  public Demo();

    descriptor: ()V

    flags: ACC_PUBLIC

    Code:

      stack=1, locals=1, args_size=1

         0: aload_0

         1: invokespecial #1                  // Method java/lang/Object."<init>":()V

         4: return

      LineNumberTable:

        line 1: 0

 
  public static void main(java.lang.String[]);

    descriptor: ([Ljava/lang/String;)V

    flags: ACC_PUBLIC, ACC_STATIC

    Code:

      stack=2, locals=1, args_size=1

         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;

         3: ldc           #3                  // String Hello World.

         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

         8: return

      LineNumberTable:

        line 3: 0

        line 4: 8

}

字节码指令官网docs.oracle.com/javase/spec...

加载指令load

load类指令是将局部变量表中指定位置的变量加载到操作数栈中,比如 iload_0 将局部变量表中下标(slot)为 0 的 int 型变量加载到操作数栈上。

存储指令store

store 类指令是将操作数栈栈顶的数据存储到局部变量表中,比如 istore_0 将操作数栈顶的元素存储到局部变量表中下标为 0 的位置,这个位置的元素类型为 int,store 指令和 load 指令用法类似

访问 Filed

getFiled:比如 getfield #2 ,会获取常量池中的 #2 字段压入栈顶,同时将 this 出栈

putFiled:设置字段的值

访问方法

invokestatic:用于调用静态方法

invokespecial:用于调用私有实例方法、构造器方法以及使用super关键字调用父类的实例方法等

invokevirtual:用于调用非私有实例方法

invokeinterface:用于调用接口方法

invokedynamic:用于调用动态方法,比如 lambda

栈帧

JVM 是一个基于栈的虚拟机,每个线程都有一个虚拟机栈用来存储栈帧( stack frame ),栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,栈帧随着方法调用而创建,随着方法结束而销毁。 每个栈帧可以简单的认为由三部分组成:

局部变量表的大小在编译期间就已经确定,对应 Code 属性中的 locals 字段 局部变量区一般用来缓存一些临时数据,比如计算的结果。实际上,JVM 会把局部变量区当成一个 数组,里面会依次缓存 this 指针(非静态方法)、参数、局部变量。

实战分析

arduino 复制代码
public class Demo {
    public static int add() {
    	int a = 1;
    	int b = 2;
    	return a + b;
    }
}

public class Demo
{
  public static int add();

    descriptor: ()I

    flags: ACC_PUBLIC, ACC_STATIC

    Code:

      stack=2, locals=2, args_size=0

         0: iconst_1 // 常量1入栈

         1: istore_0 // 将栈顶元素出栈并且存到局部变量表slot0处

         2: iconst_2 // 常量2入栈

         3: istore_1 // 将栈顶元素出栈并且存到局部变量表slot1处

         4: iload_0  // 加载局部变量表slot0处的元素到栈顶

         5: iload_1  // 加载局部变量表slot1处的元素到栈顶

         6: iadd     // 将操作数栈内的两个元素出栈,相加后将结果入栈

         7: ireturn  // 返回栈顶元素,方法结束

      LineNumberTable:

        line 6: 0

        line 7: 2

        line 8: 4
}
SourceFile: "Demo.java"

参考文章:

字节码文件结构:

www.cnblogs.com/chanshuyi/p...

字节码增强技术探索:

tech.meituan.com/2019/09/05/...

ASM基础知识:

juejin.cn/post/700057...

四、ASM

访问者模式主要用于修改或操作一些数据结构比较稳定的数据,字节码文件的结构是由JVM固定的,所以很适合利用访问者模式对字节码文件进行修改,ASM对字节码操作使用的就是访问者模式,访问者模式代码如下:

typescript 复制代码
interface CarElement {
    void accept(CarElementVisitor visitor);
}

interface CarElementVisitor {
    void visit(Body body);
    void visit(Car car);
    void visit(Engine engine);
    void visit(Wheel wheel);
}

class Wheel implements CarElement {
    private final String name;
    public Wheel(final String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    @Override
    public void accept(CarElementVisitor visitor) {
        visitor.visit(this);
    }
}

class Body implements CarElement {
    @Override
    public void accept(CarElementVisitor visitor) {
        visitor.visit(this);
    }
}

class Engine implements CarElement {
    @Override
    public void accept(CarElementVisitor visitor) {
        visitor.visit(this);
    }
}

class Car implements CarElement {
    private final List<CarElement> elements;
    public Car() {
        elements = new ArrayList<>();
        elements.add(new Wheel("front left"));
        elements.add(new Wheel("front right"));
        elements.add(new Wheel("back left"));
        elements.add(new Wheel("back right"));
        elements.add(new Body());
        elements.add(new Engine());
    }
    @Override
    public void accept(CarElementVisitor visitor) {
        for (CarElement element : elements) {
            element.accept(visitor);
        }
        visitor.visit(this);
    }
}

class CarElementDoVisitor implements CarElementVisitor {
    @Override
    public void visit(Body body) {
        System.out.println("Moving my body");
    }
    @Override
    public void visit(Car car) {
        System.out.println("Starting my car");
    }
    @Override
    public void visit(Wheel wheel) {
        System.out.println("Kicking my " + wheel.getName() + " wheel");
    }
    @Override
    public void visit(Engine engine) {
        System.out.println("Starting my engine");
    }
}

class CarElementPrintVisitor implements CarElementVisitor {
    @Override
    public void visit(Body body) {
        System.out.println("Visiting body");
    }
    @Override
    public void visit(Car car) {
        System.out.println("Visiting car");
    }
    @Override
    public void visit(Engine engine) {
        System.out.println("Visiting engine");
    }
    @Override
    public void visit(Wheel wheel) {
        System.out.println("Visiting " + wheel.getName() + " wheel");
    }
}

public class VisitorDemo {
    public static void main(final String[] args) {
        Car car = new Car();
        car.accept(new CarElementPrintVisitor());
        car.accept(new CarElementDoVisitor());
    }
}

Android打包过程:

Transform官网:tools.android.com/tech-docs/n...

Transform详解: www.jianshu.com/p/37a5e0588...

自定义Transform: juejin.cn/post/715984...

根据官网介绍,Transform API允许第三方的plugin在生成dex文件前操作class文件,一次app打包可能会经历多次Transform,Transform将输入进行处理,然后写到指定的目录作为下一个Transform输入源,如下

自定义Transform

封装一套通用的模版代码

kotlin 复制代码
package com.tt.plugin.base

import com.android.SdkConstants
import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.builder.utils.isValidZipEntryName
import com.android.utils.FileUtils
import com.google.common.io.Files
import java.io.*
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream

abstract class BaseCustomTransform(private val enableLog: Boolean) : Transform() {

    //线程池,可提升 80% 的执行速度
//    private var waitableExecutor: WaitableExecutor = WaitableExecutor.useGlobalSharedThreadPool()

    /**
     * 此方法提供给上层进行字节码插桩
     */
    abstract fun provideFunction(): ((InputStream, OutputStream) -> Unit)?

    /**
     * 上层可重写该方法进行文件过滤
     */
    open fun classFilter(className: String) = className.endsWith(SdkConstants.DOT_CLASS)


    /**
     * 默认:获取输入的字节码文件
     */
    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 默认:检索整个项目的内容
     */
    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return TransformManager.SCOPE_FULL_PROJECT
    }


    /**
     * 默认开启增量编译
     */
    override fun isIncremental(): Boolean {
        return true
    }

    /**
     * 对输入的数据做检索操作:
     * 1、处理增量编译
     * 2、处理并发逻辑
     */
    override fun transform(transformInvocation: TransformInvocation) {
        super.transform(transformInvocation)

        log("Transform start...")

        //输入内容
        val inputProvider = transformInvocation.inputs
        //输出内容
        val outputProvider = transformInvocation.outputProvider

        // 1. 子类实现字节码插桩操作
        val function = provideFunction()

        // 2. 不是增量编译,删除所有旧的输出内容
        if (!transformInvocation.isIncremental) {
            outputProvider.deleteAll()
        }

        for (input in inputProvider) {
            // 3. Jar 包处理
            log("Transform jarInputs start.")
            for (jarInput in input.jarInputs) {
                val inputJar = jarInput.file
                val outputJar = outputProvider.getContentLocation(jarInput.name, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                if (transformInvocation.isIncremental) {
                    // 3.1. 增量编译中处理 Jar 包逻辑
                    when (jarInput.status ?: Status.NOTCHANGED) {
                        Status.NOTCHANGED -> {
                            // Do nothing.
                        }
                        Status.ADDED, Status.CHANGED -> {
                            // Do transform.
//                            waitableExecutor.execute {
//                                doTransformJar(inputJar, outputJar, function)
//                            }
                            doTransformJar(inputJar, outputJar, function)
                        }
                        Status.REMOVED -> {
                            // Delete.
                            FileUtils.delete(outputJar)
                        }
                    }
                } else {
                    // 3.2 非增量编译中处理 Jar 包逻辑
//                    waitableExecutor.execute {
//                        doTransformJar(inputJar, outputJar, function)
//                    }
                    doTransformJar(inputJar, outputJar, function)
                }
            }
            // 4. 文件夹处理
            log("Transform dirInput start.")
            for (dirInput in input.directoryInputs) {
                val inputDir = dirInput.file
                val outputDir = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)
                if (transformInvocation.isIncremental) {
                    // 4.1. 增量编译中处理文件夹逻辑
                    for ((inputFile, status) in dirInput.changedFiles) {
                        val outputFile = concatOutputFilePath(outputDir, inputFile)
                        when (status ?: Status.NOTCHANGED) {
                            Status.NOTCHANGED -> {
                                // Do nothing.
                            }
                            Status.ADDED, Status.CHANGED -> {
                                // Do transform.
//                                waitableExecutor.execute {
//                                    doTransformFile(inputFile, outputFile, function)
//                                }
                                doTransformFile(inputFile, outputFile, function)
                            }
                            Status.REMOVED -> {
                                // Delete
                                FileUtils.delete(outputFile)
                            }
                        }
                    }
                } else {
                    // 4.2. 非增量编译中处理文件夹逻辑
                    // Traversal fileTree (depthFirstPreOrder).
                    for (inputFile in FileUtils.getAllFiles(inputDir)) {
//                        waitableExecutor.execute {
//                            val outputFile = concatOutputFilePath(outputDir, inputFile)
//                            if (classFilter(inputFile.name)) {
//                                doTransformFile(inputFile, outputFile, function)
//                            } else {
//                                // Copy.
//                                Files.createParentDirs(outputFile)
//                                FileUtils.copyFile(inputFile, outputFile)
//                            }
//                        }
                        val outputFile = concatOutputFilePath(outputDir, inputFile)
                        if (classFilter(inputFile.name)) {
                            doTransformFile(inputFile, outputFile, function)
                        } else {
                            // Copy.
                            Files.createParentDirs(outputFile)
                            FileUtils.copyFile(inputFile, outputFile)
                        }
                    }
                }
            }
        }
//        waitableExecutor.waitForTasksWithQuickFail<Any>(true)
        log("Transform end...")
    }

    /**
     * Do transform Jar.
     */
    private fun doTransformJar(inputJar: File, outputJar: File, function: ((InputStream, OutputStream) -> Unit)?) {
        // Create parent directories to hold outputJar file.
        Files.createParentDirs(outputJar)
        // Unzip.
        FileInputStream(inputJar).use { fis ->
            ZipInputStream(fis).use { zis ->
                // Zip.
                FileOutputStream(outputJar).use { fos ->
                    ZipOutputStream(fos).use { zos ->
                        var entry = zis.nextEntry
                        while (entry != null && isValidZipEntryName(entry)) {
                            if (!entry.isDirectory) {
                                zos.putNextEntry(ZipEntry(entry.name))
                                if (classFilter(entry.name)) {
                                    // Apply transform function.
                                    applyFunction(zis, zos, function)
                                } else {
                                    // Copy.
                                    zis.copyTo(zos)
                                }
                            }
                            entry = zis.nextEntry
                        }
                    }
                }
            }
        }
    }

    /**
     * Do transform file.
     */
    private fun doTransformFile(inputFile: File, outputFile: File, function: ((InputStream, OutputStream) -> Unit)?) {
        // Create parent directories to hold outputFile file.
        Files.createParentDirs(outputFile)
        FileInputStream(inputFile).use { fis ->
            FileOutputStream(outputFile).use { fos ->
                // Apply transform function.
                applyFunction(fis, fos, function)
            }
        }
    }

    private fun applyFunction(input: InputStream, output: OutputStream, function: ((InputStream, OutputStream) -> Unit)?) {
        try {
            if (null != function) {
                function.invoke(input, output)
            } else {
                // Copy
                input.copyTo(output)
            }
        } catch (e: UncheckedIOException) {
            throw e.cause!!
        }
    }

    /**
     * 创建输出的文件
     */
    private fun concatOutputFilePath(outputDir: File, inputFile: File) = File(outputDir, inputFile.name)

    /**
     * log 打印
     */
    private fun log(logStr: String) {
        if (enableLog) {
            println("$name - $logStr")
        }
    }
}

通用模版实现类

kotlin 复制代码
package com.tt.plugin

import com.tt.plugin.base.BaseCustomTransform
import java.io.InputStream
import java.io.OutputStream

class DemoTransform(val enableLog: Boolean): BaseCustomTransform(enableLog) {
    /**
     * 此方法提供给上层进行字节码插桩
     */
    override fun provideFunction() = { inputStream: InputStream, outputStream: OutputStream ->
        outputStream.write(inputStream.readBytes())
    }

    /**
     * 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.
     */
    override fun getName(): String {
        return "DemoTransform"
    }

}

注册Transform,写在plugin中

kotlin 复制代码
private fun registerTransform(project: Project) {
    val appExtension = project.extensions.getByType(AppExtension::class.java)
    appExtension.registerTransform(DemoTransform(true))
}

构建app后,app/build/intermediates/transforms文件下出现DemoTransform文件夹,里面有很多jar包

ASM统计方法耗时

kotlin 复制代码
package com.tt.plugin

import com.tt.plugin.asm_visitor.CostTimeClassVisitor
import com.tt.plugin.base.BaseCustomTransform
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import java.io.InputStream
import java.io.OutputStream

class DemoTransform(val enableLog: Boolean): BaseCustomTransform(enableLog) {

    companion object {
        const val TAG = "DemoTransform: "
    }

    override fun classFilter(className: String): Boolean {
        return className.endsWith("Activity.class")
                || className.endsWith("Test.class")
    }

    /**
     * 此方法提供给上层进行字节码插桩
     */
    override fun provideFunction() = { inputStream: InputStream, outputStream: OutputStream ->
        // 使用input输入流构建ClassReader
        val classReader = ClassReader(inputStream)
        // 使用ClassReader和flags构建ClassWriter
        val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES)
        // 使用 ClassWriter 构建我们自定义的 ClassVisitor
        val visitor = CostTimeClassVisitor(classWriter)
        // 最后通过 ClassReader 的 accept 将每一条字节码指令传递给 ClassVisitor
        classReader.accept(visitor, ClassReader.EXPAND_FRAMES)
        //将修改后的字节码文件转换成字节数组
        val byteArray = classWriter.toByteArray()
        //最后通过输出流修改文件,这样就实现了字节码的插桩
        outputStream.write(byteArray)
    }

    /**
     * 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.
     */
    override fun getName(): String {
        return "DemoTransform"
    }

}
kotlin 复制代码
package com.tt.plugin.asm_visitor

import org.objectweb.asm.AnnotationVisitor
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
import org.objectweb.asm.commons.AdviceAdapter

class CostTimeClassVisitor(val nextVisitor: ClassVisitor) :
    ClassVisitor(Opcodes.ASM6, nextVisitor) {

    private var className: String? = null

    companion object {
        val TAG = "CostTimeClassVisitor: "
    }

    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
        this.cv.visitField(Opcodes.ACC_PRIVATE, "time", "J", null, 0L)
        println("${TAG} visit: version = ${version}; access = $access; name = $name; superName = $superName; signature = $signature")
    }

    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val visitor = super.visitMethod(access, name, descriptor, signature, exceptions)
        println("${TAG} visitMethod: access = $access; name = $access; descriptor = $descriptor; signature = $signature")
        return object : AdviceAdapter(Opcodes.ASM6, visitor, access, name, descriptor) {
            var ishook = false
            /**
             * 开始扫描
             */
            override fun visitCode() {
                super.visitCode()
                println("${TAG}name = ${name} visitCode; ishook = $ishook")
            }

            /**
             * 访问注解
             */
            override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor {
                if ("Lcom/tt/pluginappthird/CostTime;" == descriptor) {
                    ishook = true
                }
                println("${TAG}name = ${name} visitAnnotation: descriptor = $descriptor; ishook = $ishook")
                return super.visitAnnotation(descriptor, visible)
            }

            /**
             * 方法开始
             */
            override fun onMethodEnter() {
                println("${TAG}name = ${name} onMethodEnter; ishook = $ishook")
                if (ishook) {
                    visitVarInsn(ALOAD, 0)
                    visitVarInsn(ALOAD, 0)
                    visitFieldInsn(GETFIELD, className, "time", "J")
                    visitMethodInsn(
                        INVOKESTATIC,
                        "android/os/SystemClock",
                        "uptimeMillis",
                        "()J",
                        false
                    )
                    visitInsn(LSUB)
                    visitFieldInsn(PUTFIELD, className, "time", "J")
                }
            }

            /**
             * 方法结束
             */
            override fun onMethodExit(opcode: Int) {
                println("${TAG}name = ${name} onMethodExit; ishook = $ishook")
                if (ishook) {
                    visitVarInsn(ALOAD, 0)
                    visitVarInsn(ALOAD, 0)
                    visitFieldInsn(GETFIELD, className, "time", "J")
                    visitMethodInsn(
                        INVOKESTATIC,
                        "android/os/SystemClock",
                        "uptimeMillis",
                        "()J",
                        false
                    )
                    visitInsn(LADD)
                    visitMethodInsn(
                        PUTFIELD,
                        className,
                        "time",
                        "J",
                        false
                    )
                    visitLdcInsn("MainActivity")
                    visitLdcInsn("$name: time = ")
                    visitVarInsn(ALOAD, 0)
                    visitFieldInsn(GETFIELD, className, "time", "J")
                    visitMethodInsn(
                        INVOKESTATIC,
                        "java/lang/Long",
                        "valueOf",
                        "(J)Ljava/lang/Long;",
                        false
                    )
                    visitMethodInsn(
                        INVOKESTATIC,
                        "kotlin/jvm/internal/Intrinsics",
                        "stringPlus",
                        "(Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/String;",
                        false
                    )
                    visitMethodInsn(INVOKESTATIC,
## "android/util/Log",
                        "d",
                        "(Ljava/lang/String;Ljava/lang/String;)I",
                        false)
                    visitInsn(POP)
                }
            }

            /**
             * 访问结束
             */
            override fun visitEnd() {
                super.visitEnd()
                println("${TAG}name = ${name} visitEnd; ishook = $ishook")
            }
        }
    }
}

studio安装ASMPlugin插件后,可以将.java文件转换为字节码格式,ASM的代码照着敲就可以了,遇到不清楚的去官网查一下就可以了,如下:

五、Booster

Booster地址:github.com/didi/booste...

从booster-transform-thread分析booster

项目的build.gradlet添加如下代码:

bash 复制代码
buildscript {
    ext.kotlin_version = '1.6.10'
    ext.booster_version = '4.14.2'
    repositories {
        google()
        mavenCentral()
        maven {
            url('./maven_repo')
        }
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:4.0.0'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath 'com.tt.plugin:plugin:1.0.0'
        classpath "com.didiglobal.booster:booster-gradle-plugin:$booster_version"
        classpath "com.didiglobal.booster:booster-transform-thread:$booster_version"
    }
}

app的build.gradle添加

arduino 复制代码
apply plugin: 'com.didiglobal.booster'

测试代码,在下面的代码中打印子线程的名字,我们在创建线程时并没有设置线程名字,运行后看下效果

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    companion object {
        const val TAG = "MainActivity"
    }

    @CostTime
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val t1 = Thread {
            Log.d(TAG, "t1 name = ${Thread.currentThread().name}")
        }
        t1.start()
    }
    

日志输入结果如下,可以看到线程被赋予了名字

ini 复制代码
com.tt.pluginappthird D/MainActivity: t1 name = com.tt.pluginappthird.MainActivity

booster是怎么做的呢?从源码可知插件入口是BoosterPlugin,那我们继续看看BoosterPlugin做了什么

kotlin 复制代码
class BoosterPlugin : Plugin<Project> {

    override fun apply(project: Project) {
        // android项目
        project.extensions.findByName("android") ?: throw GradleException("$project is not an Android project")
        // 插件版本小于3.6 添加监听
        if (!GTE_V3_6) {
            project.gradle.addListener(BoosterTransformTaskExecutionListener(project))
        }
        // 加载处理器
        val processors = loadVariantProcessors(project)

        // gradle生命周期初始化、配置、执行,state.executed配置完成(可以拿到依赖哪些三方库)
        if (project.state.executed) {
            project.setup(processors)
        } else {
            project.afterEvaluate {
                project.setup(processors)
            }
        }
        // 注册transform
        project.getAndroid<BaseExtension>().registerTransform(BoosterTransform.newInstance(project))
    }
}
kotlin 复制代码
// 内联函数
@Throws(ServiceConfigurationError::class)
internal fun loadVariantProcessors(project: Project): List<VariantProcessor> {
    // 创建ServiceLoader,传参classloder和project的class文件
    // 加载
    return newServiceLoader<VariantProcessor>(project.buildscript.classLoader, Project::class.java).load(project)
}

internal inline fun <reified T> newServiceLoader(classLoader: ClassLoader, vararg types: Class<*>): ServiceLoader<T> {
    // 返回ServiceLoaderFactory,T是VariantProcessor,*types是Project::class.java
    return ServiceLoaderFactory(classLoader, T::class.java).newServiceLoader(*types)
}

// 内联类
internal class ServiceLoaderFactory<T>(private val classLoader: ClassLoader, private val service: Class<T>) {
    // ServiceLoader的实现类是ServiceLoaderImpl
    fun newServiceLoader(vararg types: Class<*>): ServiceLoader<T> = ServiceLoaderImpl(classLoader, service, *types)
}

由以上代码得知newServiceLoader().load(project)最后调用的是ServiceLoaderImpl.load(),然后看看ServiceLoaderImpl.load()做了什么

kotlin 复制代码
override fun load(vararg args: Any): List<T> {
    // args是传递project
    // 调用了lookup,看下做了什么
    return lookup<T>().map { provider ->
        // provider是加载的类文件
        try {
            try {
                // *types是Project::class.java,T是VariantProcessor
                // 利用反射创建META-INF/services/${VariantProcessor::class.java.name}下的类对象
                provider.getConstructor(*types).newInstance(*args) as T
            } catch (e: NoSuchMethodException) {
                provider.getDeclaredConstructor().newInstance() as T
            }
        } catch (e: Throwable) {
            throw ServiceConfigurationError("Provider $provider not found")
        }
    }
}
kotlin 复制代码
fun <T> lookup(): Set<Class<T>> {
    // name = META-INF/services/${service.name}
    // sevice是传进来的VariantProcessor的class文件
    // 加载META-INF/services/${VariantProcessor::class.java.name}文件
    return classLoader.getResources(name)?.asSequence()?.map(::parse)?.flatten()?.toSet()?.mapNotNull { provider ->
        try {
            // classloader加载文件
            val providerClass = Class.forName(provider, false, classLoader)
            if (!service.isAssignableFrom(providerClass)) {
                throw ServiceConfigurationError("Provider $provider not a subtype")
            }
            providerClass as Class<T>
        } catch (e: Throwable) {
            null
        }
    }?.toSet() ?: emptySet()
}

META-INF/services/${VariantProcessor::class.java.name},这个又是什么?其实这是spi机制,就是将实现类注册到META-INF/services中,然后使用serviceLoader去加载;具体请参考下面的链接 juejin.cn/post/684490...

到这里也知道了loadVariantProcessors(project)其实就是利用@AutoService加载VariantProcessor实现类,booster-transform-thread中的是ThreadVariantProcessor,获取到实现类后调用了setup函数

kotlin 复制代码
private fun Project.setup(processors: List<VariantProcessor>) {
    val android = project.getAndroid<BaseExtension>()
    when (android) {
        is AppExtension -> android.applicationVariants
        is LibraryExtension -> android.libraryVariants
        else -> emptyList<BaseVariant>()
    }.takeIf<Collection<BaseVariant>>(Collection<BaseVariant>::isNotEmpty)?.let { variants ->
        variants.forEach { variant ->
            processors.forEach { processor ->
                // 执行了每个实现类的process函数
                processor.process(variant)
            }
        }
    }
}
kotlin 复制代码
@AutoService(VariantProcessor::class)
class ThreadVariantProcessor : VariantProcessor {
    override fun process(variant: BaseVariant) {
        if (variant !is LibraryVariant && !variant.isDynamicFeature) {
            // 给项目添加依赖:booster-android-instrument-thread
            variant.project.dependencies.add("implementation", "${Build.GROUP}:booster-android-instrument-thread:${Build.VERSION}")
        }
    }

}

booster-android-instrument-thread项目代码如下,主要是和线程相关,到这里我们的demo项目已经引用了booster-android-instrument-thread

接下来看下booster如何给线程添加名字,在BoosterPlugin的apply函数中注册了BoosterTransform

kotlin 复制代码
// 注册transform
project.getAndroid<BaseExtension>().registerTransform(BoosterTransform.newInstance(project))
// newInstance函数,在不同版本创建不同的transform
companion object {
    fun newInstance(project: Project, name: String = "booster"): BoosterTransform {
        // 创建TransformParameter
        val parameter = project.newTransformParameter(name)
        return when {
            GTE_V3_4 -> BoosterTransformV34(parameter)
            else -> BoosterTransform(parameter)
        }
    }
}
// 执行transform函数
final override fun transform(invocation: TransformInvocation) {
    BoosterTransformInvocation(invocation, this).apply {
        if (isIncremental) {
            // 增量编译
            doIncrementalTransform()
        } else {
            // 非增量编译
            outputProvider?.deleteAll()
            doFullTransform()
        }
    }
}

internal fun doIncrementalTransform() = doTransform(this::transformIncrementally)

private fun doTransform(block: (ExecutorService, Set<File>) -> Iterable<Future<*>>) {
    this.outputs.clear()
    this.collectors.clear()

    val executor = Executors.newFixedThreadPool(NCPU)
    // transform执行之前
    this.onPreTransform()

    // Look ahead to determine which input need to be transformed even incremental build
    val outOfDate = this.lookAhead(executor).onEach {
        project.logger.info("✨ ${it.canonicalPath} OUT-OF-DATE ")
    }

    try {
        // 执行transformIncrementally函数
        block(executor, outOfDate).forEach {
            it.get()
        }
    } finally {
        executor.shutdown()
        executor.awaitTermination(1, TimeUnit.HOURS)
    }
    // transform执行之后
    this.onPostTransform()

    if (transform.verifyEnabled) {
        this.doVerify()
    }
}

private fun transformIncrementally(executor: ExecutorService, outOfDate: Set<File>) = this.inputs.map { input ->
    input.jarInputs.filter {
        it.status != NOTCHANGED || outOfDate.contains(it.file)
    }.map { jarInput ->
        // 处理jar包
        executor.submit {
            doIncrementalTransform(jarInput)
        }
    } + input.directoryInputs.filter {
        it.changedFiles.isNotEmpty() || outOfDate.contains(it.file)
    }.map { dirInput ->
        executor.submit {
            // 处理非jar包
            doIncrementalTransform(dirInput, dirInput.file.toURI())
        }
    }
}.flatten()

// 无论是jar包还是非jar处理,最后会执行到这里
private fun ByteArray.transform(): ByteArray {
    // 遍历transformers执行transformer.transform
    return transformers.fold(this) { bytes, transformer ->
        transformer.transform(this@BoosterTransformInvocation, bytes)
    }
}

从上面代码可以分析出transform()最后执行的是transformers: List中的transformer,接下来看看transformers: List是什么

kotlin 复制代码
// 构建TransformParameter
fun Project.newTransformParameter(name: String): TransformParameter {
    return TransformParameter(name, buildscript, plugins, properties, lookupTransformers(buildscript.classLoader))
}

// parameter就是在创建BoosterTransform时传进来的TransformParameter
// parameter.transformers就是lookupTransformers(buildscript.classLoader)的返回值
private val transformers: List<Transformer> = transform.parameter.transformers.map {
    try {
        it.getConstructor(ClassLoader::class.java).newInstance(transform.parameter.buildscript.classLoader)
    } catch (e1: Throwable) {
        try {
            it.getConstructor().newInstance()
        } catch (e2: Throwable) {
            throw e2.apply {
                addSuppressed(e1)
            }
        }
    }
}

// 这套代码和上面的使用Autoservice加载VariantProcessor基本一样,不过在这里加载的是Transformer实现类
@Throws(ServiceConfigurationError::class)
internal fun lookupTransformers(classLoader: ClassLoader): Set<Class<Transformer>> {
    return ServiceLoaderImpl(classLoader, Transformer::class.java, ClassLoader::class.java).lookup()
}

到这里其实已经知道BoosterTransform.transform()利用spi技术执行到Transformer实现类,在 AsmTransformer是其对应的实现类,而AsmTransformer加载的是ClassTransformer

css 复制代码
constructor(classLoader: ClassLoader = Thread.currentThread().contextClassLoader) : this(ServiceLoader.load(ClassTransformer::class.java, classLoader).sortedBy {
    it.javaClass.getAnnotation(Priority::class.java)?.value ?: 0
}, classLoader)

在booster-transform-thread中ClassTransformer实现类是ThreadTransformer,最后会执行 transform()::ThreadTransformer;代码如下

kotlin 复制代码
@AutoService(ClassTransformer::class)
class ThreadTransformer : ClassTransformer {
    // ...省略代码
    // 执行transform
    override fun transform(context: TransformContext, klass: ClassNode): ClassNode {
        // ...省略代码
        klass.methods?.forEach { method ->
            method.instructions?.iterator()?.asIterable()?.forEach {
                when (it.opcode) {
                    Opcodes.INVOKEVIRTUAL -> (it as MethodInsnNode).transformInvokeVirtual(context, klass, method)
                    Opcodes.INVOKESTATIC -> (it as MethodInsnNode).transformInvokeStatic(context, klass, method)
                    Opcodes.INVOKESPECIAL -> (it as MethodInsnNode).transformInvokeSpecial(context, klass, method)
                    // 创建线程,传名字
                    Opcodes.NEW -> (it as TypeInsnNode).transform(context, klass, method)
                    Opcodes.ARETURN -> if (method.desc == "L$THREAD;") {
                        method.instructions.insertBefore(it, LdcInsnNode(makeThreadName(klass.className)))
                        method.instructions.insertBefore(it, MethodInsnNode(Opcodes.INVOKESTATIC, SHADOW_THREAD, "setThreadName", "(Ljava/lang/Thread;Ljava/lang/String;)Ljava/lang/Thread;", false))
                        logger.println(" + $SHADOW_THREAD.makeThreadName(Ljava/lang/String;Ljava/lang/String;): ${klass.name}.${method.name}${method.desc}")
                    }
                }
            }
        }
        return klass
    }

    private fun TypeInsnNode.transform(context: TransformContext, klass: ClassNode, method: MethodNode) {
        when (this.desc) {
            /*-----------*/ HANDLER_THREAD -> this.transformNew(context, klass, method, SHADOW_HANDLER_THREAD)
            // new Thread
            /*-------------------*/ THREAD -> this.transformNew(context, klass, method, SHADOW_THREAD)
            /*-----*/ THREAD_POOL_EXECUTOR -> this.transformNew(context, klass, method, SHADOW_THREAD_POOL_EXECUTOR, true)
            SCHEDULED_THREAD_POOL_EXECUTOR -> this.transformNew(context, klass, method, SHADOW_SCHEDULED_THREAD_POOL_EXECUTOR, true)
            /*--------------------*/ TIMER -> this.transformNew(context, klass, method, SHADOW_TIMER)
        }
    }

    private fun TypeInsnNode.transformNew(@Suppress("UNUSED_PARAMETER") context: TransformContext, klass: ClassNode, method: MethodNode, type: String, optimizable: Boolean = false) {
        this.find {
            (it.opcode == Opcodes.INVOKESPECIAL) &&
                    (it is MethodInsnNode) &&
                    (this.desc == it.owner && "<init>" == it.name)
        }?.isInstanceOf { init: MethodInsnNode ->
            // 替换desc
            // 例如 android/os/HandlerThread => com/didiglobal/booster/instrument/ShadowHandlerThread
            this.desc = type

            // 替换构造函数
            // 例如 android/os/HandlerThread(Ljava/lang/String;) => com/didiglobal/booster/instrument/ShadowHandlerThread(Ljava/lang/String;Ljava/lang/String;)
            val rp = init.desc.lastIndexOf(')')
            init.apply {
                owner = type
                desc = "${desc.substring(0, rp)}Ljava/lang/String;${if (optimizable) "Z" else ""}${desc.substring(rp)}"
            }
            // 使用ASM进行替换
            method.instructions.insertBefore(init, LdcInsnNode(makeThreadName(klass.className)))
            if (optimizable) {
                method.instructions.insertBefore(init, LdcInsnNode(optimizationEnabled))
            }
        } ?: throw TransformException("`invokespecial $desc` not found: ${klass.name}.${method.name}${method.desc}")
    }
}

ThreadTransformer就是利用将demo项目中使用线程的地方,通过asm替换为booster-android-instrument-thread中封装的线程类,并且加上线程的名字及优化等

这边文章涉及到gradle、字节码、ASM、Booster较多内容,技术细节写的比较宽松!!!

相关推荐
诸神黄昏EX1 小时前
Android 分区相关介绍
android
大白要努力!2 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle
Estar.Lee2 小时前
时间操作[取当前北京时间]免费API接口教程
android·网络·后端·网络协议·tcp/ip
Winston Wood2 小时前
Perfetto学习大全
android·性能优化·perfetto
Dnelic-5 小时前
【单元测试】【Android】JUnit 4 和 JUnit 5 的差异记录
android·junit·单元测试·android studio·自学笔记
Eastsea.Chen7 小时前
MTK Android12 user版本MtkLogger
android·framework
长亭外的少年15 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
建群新人小猿17 小时前
会员等级经验问题
android·开发语言·前端·javascript·php
1024小神18 小时前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
兰琛18 小时前
20241121 android中树结构列表(使用recyclerView实现)
android·gitee