美文网首页Android进阶字节码插桩
Gradle+Transform+Asm自动化注入代码

Gradle+Transform+Asm自动化注入代码

作者: 有没有口罩给我一个 | 来源:发表于2020-03-22 18:19 被阅读0次

如果大家公司使用组件化开发可能都会碰到应该怎么样去初始化其他组件,比如:消息组件,那我们怎么去初始化呢?

  • 在BaseApp中直接初始化,但是这样耦合度就非常高了;
  • 在公共组件中定义IComponent接口并配置其他组件的全类名,通过反射初始化;
  • 与第二点差不多也是反射,但是配置不一样,这里是在Manifest中配置,然后解析Manifest文件,读取到类的全路径,通过反射,这种方式性能瓶颈更大,除了反射还有IO;

除了上面所说的方式,我们是否还有更好的方式去做到解耦合并且不会造成性能损耗呢?在编译时,扫描即将打包到apk中的所有类的字节码,将所有组件类收集起来,通过修改字节码的方式生成注册代码到组件管理类中,从而实现编译时自动注册的功能,不用再关心项目中有哪些组件类了。
特点:不需要注解,不会增加新的类;性能高,不需要反射,运行时直接调用组件的构造方法;能扫描到所有类,不会出现遗漏。

apk打包流程

我们平时在开发的过程中,每天在Android Studio run 项目,Android Studio就会将apk自动安装到手机上了,那么这中间都经历过哪些流程呢,来看看官方的项目构建流程图

build-process_2x.png

如图 所示,典型 Android 应用模块的构建流程通常按照以下步骤执行:
*1、 编译器将您的源代码转换成 DEX 文件(Dalvik 可执行文件,其中包括在 Android 设备上运行的字节码),并将其他所有内容转换成编译后的资源。

2、 APK 打包器将 DEX 文件和编译后的资源合并到一个 APK 中。不过,在将应用安装并部署到 Android 设备之前,必须先为 APK 签名。

3、APK 打包器使用调试或发布密钥库为 APK 签名:

  • 如果您构建的是调试版应用(即专用于测试和分析的应用),则打包器会使用调试密钥库为应用签名。Android Studio 会自动使用调试密钥库配置新项目。
  • 如果您构建的是打算对外发布的发布版应用,则打包器会使用发布密钥库为应用签名。要创建发布密钥库,请参阅在 Android Studio 中为应用签名

4、 在生成最终 APK 之前,打包器会使用 zipalign 工具对应用进行优化,以减少其在设备上运行时所占用的内存。

可能从图中并不会看出什么来,实际上对于Java编程语言来说,这个过程要从Java源代码到apk,那么我们来看一张图:


gradle打包.png

这张图就非常的清晰了,gradle打包过程中基本上是通过官方提供的Transform完成的,文章开始我就说了自动注入就是通过自定义Transform并将自定义Transform注册到自定义gradle插件中,而却我们自定义的Transform是优先于ProguardTransform执行的,所以不会造成因为混淆而无法扫描到类信息。

自定义gradle 插件

自定义gradle 插件网上有很多好的博客,目前大多数有两种方式:

  • 第一种就是配置maven发布到本地或者maven服务器上,绝大多数gradle插件,我们可能都是只要在公司内部使用,那么只要使用公司内部的maven仓库即可,即配置并运用maven插件,然后执行其upload task即可,但是这种方式在开发阶段是最麻烦的,调试还要手动发布到本地货远程;
  • 第一点说过gradle插件的maven发布,那如果我们在插件的代码编写阶段,总不能修改一点点代码,就发布一个版本,然后重新运用吧?在工程下创建buildSrc,这种方式网上有人说只能在本地使用,这种说法是错误的,其实和第一种方式是一样的原理,不过buildSrc在调试的时候非常方便,当你要复用你也可以发布的maven上;

这里我使用的比较方便buildSrc

配置自定义gradle 插件的环境

1、首先在工程下新建一个java Libray项目,把其他无用的资源文件和目录删掉就保留src目录和build.gradle文件;
2、gradle插件可以使用的groovy、Java和Kotlin语言编写,然后我们在main目录下新建resources/MATE-INF/gradle-plugins目录,如:


plugin.png

并在gradle-plugins目下新建xxx.properties文件,而xxx你可以随便取名字,这个名字就是你的插件名字,以后要引用该插件你可以通过 apply plugin: 'xxxx' 方式引用插件。
3、xxx.properties文件中的内容就是

implementation-class=com.github.plugin.ModuleComponentPluginKt

implementation-class是固定写法,而com.github.plugin.ModuleComponentPluginKt就是你的自定义插件类的全类名,同一个项目中还可以定义多个插件,你可以按照功能分插件引入项目。

4、build.gradle配置

apply plugin: 'groovy'
apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'kotlin-android-extensions'


buildscript {
ext.kotlin_version = '1.3.50'
repositories {
    mavenCentral()
    jcenter()
    google()
}
dependencies {
    classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
sourceSets {
    main {
        groovy {
            srcDir '../buildSrc/src/main/groovy'
      }

    java {
        srcDir "../buildSrc/src/main/java"
    }

    kotlin {
        srcDir "../buildSrc/src/main/kotlin"
    }

    resources {
        srcDir '../buildSrc/src/main/resources'
    }
}
}

dependencies {
repositories {
    mavenCentral()
    jcenter()
    google()
}
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation gradleApi()
implementation localGroovy()
implementation group: 'org.ow2.asm', name: 'asm', version: '7.1'
implementation group: 'org.ow2.asm', name: 'asm-commons', version: '7.1'
implementation 'com.android.tools.build:gradle:3.4.2'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
}
sourceCompatibility = "8"
targetCompatibility = "8"

因为我的插件是使用的Kotlin编写,所以这build.gradle的配置会有kotlin的配置,以及Asm的依赖等等相关的,需要注意的是你必须要引入

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

为什么我们要引入这个gardle插件?其实Transform Api Android 提供的,所以你必须引入这个依赖;

开发gradle 插件

正如xxx.properties文件中定义的全类名,所以在com.github.plugin包下
ModuleComponentPluginKt类,让ModuleComponentPluginKt实现至org.gradle.api.Plugin接口,代码如下:

class ModuleComponentPluginKt : Plugin<Project> {
private lateinit var mProject: Project
override fun apply(project: Project) {
    this.mProject = project
    KLogger.inject(project.logger)
    KLogger.e("自定义插件ModuleComponentPluginKt")
    PluginInitializer.initial(project)

    if (project.plugins.hasPlugin(AppPlugin::class.java)) {
        // 监听每个任务的执行时间
        project.gradle.addListener(BuildTimeListener())
        val android = project.extensions.getByType(AppExtension::class.java)
        //主要操作就是收集满足条件的类
        android.registerTransform(ScannerComponentTransformKt())
        //收集完毕,在这里完成代码的织入
        android.registerTransform(ScannerAfterTransformKt())
    }
}
}
  • 在ModuleComponentPluginKt 类中,通过project获取到AppExtension并调用registerTransform方法将我们自定义的Transform注册到AppExtension,而AppExtension就是application plugins.也就是App module的apply plugin: 'com.android.application'插件为com.android.application。

  • 在ModuleComponentPluginKt 中还有 PluginInitializer.initial(project)是什么意思呢?这个也你叫重要,代码如下:

    object PluginInitializer {
      fun initial(project: Project) {
      val hasAppPlugin = project.plugins.hasPlugin(AppPlugin::class.java)
      val hasLibPlugin = project.plugins.hasPlugin(LibraryPlugin::class.java)
      if (!hasAppPlugin && !hasLibPlugin) {
          throw  GradleException("Component: The 'com.android.application' or 'com.android.library' plugin is required.")
      }
      this.project = project
      //  创建extensions  ,可以通过extensions.getByType拿到这个拓展对象
      project.extensions.create(COMPONENT_CONFIG_NAME, ComponentExtension::class.java)
      }
      lateinit var project: Project
    }
    

在 PluginInitializer 类中比较重要的这行代码

project.extensions.create("componentExt", ComponentExtension::class.java)

拓展类:

open class ComponentExtension {
var matcherInterfaceType: String = "" //组件实现接口 如:com/github/plugin/common/IComponent
var matcherManagerTypeMethod: String = "" //管理类初始化方法  如: initComponent
var matcherManagerType: String = "" //管理类的全类名  如:com/github/plugin/common/InjectManager
}

这行代码就是给插件创建拓展(extensions)名字是componentExt,为什么会创建extensions,先看看使用就明白:

componentExt {
    matcherInterfaceType "com.github.plugin.common.IComponent"
    matcherManagerType "com.github.plugin.common.InjectManager"
    matcherManagerTypeMethod "initComponent"
}

是不是明白了extensions的作用了,其实就是我们需要提供开发者动态的配置一些信息,这样会更灵活。

Android Transform 结合Asm字节码插桩完成代码自动注入(重点)

自定义插件的代码比较简单,基本上都是套路,通过拿到AppExtension并将我们自定义的Transform注册进去,看看那Transform的代码,感兴趣可以去Transform API
class ScannerComponentTransformKt : Transform() {
override fun getName(): String {
return "scanner_component_result"
}

override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
    return TransformManager.CONTENT_CLASS
}

override fun isIncremental(): Boolean {
    return false
}

override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
    return TransformManager.SCOPE_FULL_PROJECT
}

override fun transform(transformInvocation: TransformInvocation) {

    if (!transformInvocation.isIncremental) {
        transformInvocation.outputProvider.deleteAll()
    }

    transformInvocation.inputs.forEach { input ->
        input.directoryInputs.forEach { dirInput ->
            //处理完输入文件之后,要把输出给下一个任务,就是在:transforms\ScannerComponentTransformKt\debug\0目录中
            // name就是会在__content__.json文件中的name,唯一的,随便取,但是一定要保证唯一
            val dest = transformInvocation.outputProvider.getContentLocation(DigestUtils.md5Hex(dirInput.name),
                    dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY).also(FileUtils::forceMkdir)

            //1、遍历目录中的文件;
            //2、修改这些文件;
            //3、然后将这些修改过的文件,复制到transforms的输出目录,那么为什么将这些修改过的文件放到transforms,
            // 就会被打包到apk中呢?因为我们自定义的transforms会优先于其他transform执行并且是优先于其他的执行,详细的
            //可以去看看BaseExtension的构造方法
            dirInput.file.eachFileRecurse { file ->
                // dest===> transforms\ScannerComponentTransformKt\debug\0 D8编译成dex文件
                // file===> build\intermediates\javac\debug\compileDebugJavaWithJavac\classes\com\github\plugin\examlple\MainActivity.class javac 编译生成的字节码


                //现在来认证一下,通过asm修改的字节码,是否在javac 或 transforms中?
                //确实会存在于transforms目录中,但是javac中不存在
                if (TypeUtil.isMatchCondition(file.name)) {
                    val outputFile = File(file.absolutePath.replace(dirInput.file.absolutePath, dest.absolutePath))
                    FileUtils.touch(outputFile)


                    //Dest目录: build\intermediates\transforms\ScannerComponentTransformKt\debug\0
                    //输入文件:  build\intermediates\javac\debug\compileDebugJavaWithJavac\classes\com\github\plugin\examlple\Inject.class
                    //输出文件: build\intermediates\transforms\ScannerComponentTransformKt\debug\0\com\github\plugin\exalple\Inject.class
                    KLogger.e("inputFile: ${file.absolutePath}   outputFile: ${outputFile.absolutePath}   destFile: ${dest.absolutePath}")

                    val inputStream = FileInputStream(file)
                    // 开始织入代码,修改这些文件,即:对输入的文件进行修改
                    val bytes = WeaveSingleClass.weaveSingleClassToByteArray(inputStream)//需要织入代码
                    //修改输入文件完毕复制输出文件中
                    val fos = FileOutputStream(outputFile)
                    fos.write(bytes)
                    fos.close()
                    inputStream.close()
                }
            }
            //这里和上面的处理是一样的,将目录中的文件复制到dest目录中
//                FileUtils.copyDirectory(dirInput.file, dest)
        }


        //首先jar是需要解压因为jar是通过zip进行压缩的
        // TODO 多模块需要处理Jar,因为lib最后打包是已jar形式引入
        //common\build\intermediates\runtime_library_classes\debug\classes.jar
        //usercenter\build\intermediates\runtime_library_classes\debug\classes.jar
        input.jarInputs.forEach { jarInput ->
            if (jarInput.file.absolutePath.endsWith(".jar")) {

                //用于存放临时操作的class文件,当操作完毕,便将临时文件拷贝到dest文件即可
                val tmpFile = File(jarInput.file.parent + File.separator + "classes_temp.jar")
                if (tmpFile.exists()) tmpFile.delete() //避免上次的缓存被重复插入
                val tmpJarOutputStream = JarOutputStream(FileOutputStream(tmpFile))

                //jar文件
                val jarFile = JarFile(jarInput.file)
                //拿到所有的jar中的文件
                val enumeration = jarFile.entries()

                //用于保存JAR文件,修改JAR中的class
                while (enumeration.hasMoreElements()) {
                    val jarEntry = enumeration.nextElement()
                    val entryName = jarEntry.name
                    val zipEntry = ZipEntry(entryName)

                    if (zipEntry.isDirectory) continue

                    //读取jar中的文件输入流
                    val inputStream = jarFile.getInputStream(jarEntry)

                    //插桩class
                    if (TypeUtil.isMatchCondition(entryName)) {
                        KLogger.e("ASM 开始处理Jar文件中${entryName}文件")
                        tmpJarOutputStream.putNextEntry(zipEntry)
                        val updateCodeBytes = WeaveSingleClass.weaveSingleClassToByteArray(inputStream)
                        tmpJarOutputStream.write(updateCodeBytes)
                        KLogger.e("ASM 结束处理Jar文件中${entryName}文件")
                    } else {
                        KLogger.e("不满足条件Jar文件中${entryName}文件")
                        tmpJarOutputStream.putNextEntry(zipEntry)
                        tmpJarOutputStream.write(IOUtils.toByteArray(inputStream))
                    }

                    tmpJarOutputStream.closeEntry()
                }
                //结束
                tmpJarOutputStream.close()
                jarFile.close()

                // 将临时class文件拷贝到目标dest文件
                var jarName = jarInput.name//重名名输出文件,因为可能同名,会覆盖
                val md5Name = DigestUtils.md5Hex(jarInput.file.absolutePath)
                //截取.jar,即 去掉.jar       name就是会在__content__.json文件中的name,唯一的
                // name就是会在__content__.json文件中的name,唯一的,随便取,但是一定要保证唯一
                if (jarName.endsWith(".jar")) jarName = jarName.substring(0, jarName.length - 4)
                val dest = transformInvocation.outputProvider.getContentLocation(jarName + md5Name,
                        jarInput.contentTypes, jarInput.scopes, Format.JAR)

                //input: build\intermediates\runtime_library_classes\debug\classes.jar
                //                    //output: build\intermediates\transforms\ScannerComponentTransformKt\debug\0.jar
                //                    //KLogger.e("input: ${jarInput.file.absolutePath}  output: ${dest.absolutePath}")
                //                    //KLogger.e("${jarInput.name}   $jarName     ${jarName + md5Name}")

                FileUtils.copyFile(tmpFile, dest)
                tmpFile.delete()
            }
        }
    }

    KLogger.e("transform..................end")
}

}

  • 可以看到在Transform的transform方法中通过directoryInputs和jarInputs就可以拿到目录下的.class文件和Jar中的.class文件,也叫输入数据,这里我叫上游,而TransformOutputProvider的getContentLocation方法就是输入,也叫下游,在Transform有个坑,就是不管你是否修改或不修改这个输入文件,你都必须复制到指定transform的目录中,不然打包先回来的apk是找不到类的。
  • 众所周知,asm操作的是class字节码,程中所有的class字节码所以我们在transform中只要是为了遍历工,满足条件的那么就使用Asm修改字节码,对于我们的transform而言就是收集满足条件的字节码文件,然后通过asm织入一个我们的指定管理类即可,对于Transform Api我不过多的介绍,网上很多博客写得非常好,大家可以去看看。
  • 还是那句话Transform 不管你是否修改class或不修改class这个class输入文件,都必须复制到指定transform的目录中,不然打包的apk是找不到类的,比如:及你的MainActivity 继承Androidx 的AppConpatActivity,那么如果你不处理AppConpatActivity的Jar,就会奔溃抛出ClassFileNotFoundException异常。但是你将MainActivity 的父类继承为Activity,那么就不会奔溃,因为Activity属于Android.jar,而Android.jar,属于系统类,Transform不会对android.jar中class做任何输入,即Transform你必须处理文件并将其写入输出文件夹。即使不处理类文件,你仍然必须将它们复制到输出文件夹。如果你不这样做,所有的类文件都会被删除。

asm 代码如下:

object WeaveSingleClass {
fun weaveSingleClassToByteArray(inputStream: InputStream): ByteArray {
    //1、解析字节码
    val classReader = ClassReader(inputStream)
    //2、修改字节码
    val classWriter = ExtendClassWriter(ClassWriter.COMPUTE_MAXS)
    val customClassVisitor = CustomInjectClassVisitor(classWriter)
    //3、开始解析字节码
    classReader.accept(customClassVisitor, ClassReader.EXPAND_FRAMES)
    return classWriter.toByteArray()
        }


fun weaveSingleClassToByteArrayAutoInject(inputStream: InputStream): ByteArray {
    //1、解析字节码
    val classReader = ClassReader(inputStream)
    //2、修改字节码
    val classWriter = ExtendClassWriter(ClassWriter.COMPUTE_MAXS)
    val customClassVisitor = AutoInjectComponentClassVisitor(classWriter)
    //3、开始解析字节码
    classReader.accept(customClassVisitor, ClassReader.EXPAND_FRAMES)
    return classWriter.toByteArray()
}
}

// 访问class信息

class AutoInjectComponentClassVisitor(classVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM7, classVisitor) {
//如果是实现了IComponent接口的话,将所有组件类收集起来,通过修改字节码的方式生成注册代码到组件管理类中
override fun visit(version: Int, access: Int, name: String?, signature: String?, superName: String?, interfaces: Array<out String>?) {
    KLogger.e("${interfaces?.joinToString { it }}")
    KLogger.e("name>>>----$name")
    if (interfaces?.contains(PluginInitializer.getComponentInterfaceName()) == true && name != "") {
        ComponentNameCollection.add("$name")
    }
    super.visit(version, access, name, signature, superName, interfaces)
}

override fun visitMethod(access: Int, name: String, descriptor: String, signature: String?, exceptions: Array<out String>?): MethodVisitor {
    KLogger.e("name:$name     descriptor:$descriptor")

    val visitMethod = super.visitMethod(access, name, descriptor, signature, exceptions)
    if (PluginInitializer.getComponentManagerTypeInitMethodName() != name) {
        return visitMethod
    }
    return AutoInjectComponentMethodVisitor(visitMethod, access, name, descriptor)
}
}

// 访问method信息

class AutoInjectComponentMethodVisitor(methodVisitor: MethodVisitor?, access: Int, name: String?, descriptor: String?)
: AdviceAdapter(Opcodes.ASM7, methodVisitor, access, name, descriptor) {
override fun onMethodExit(opcode: Int) {
    KLogger.e("${ComponentNameCollection.size}    $opcode")

    mv.visitVarInsn(ALOAD, 0)
    mv.visitFieldInsn(GETFIELD, PluginInitializer.getComponentManagerTypeName(), "components", "Ljava/util/List;")
    mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "clear", "()V", true)

    ComponentNameCollection.forEach { name ->
        KLogger.e(">><<<>>>>>>${name}")
        // 加载this
        mv.visitVarInsn(ALOAD, 0)
        //拿到类的成员变量     坑,你需要注意的类名不要写错了
        mv.visitFieldInsn(GETFIELD, PluginInitializer.getComponentManagerTypeName().replace(".", "/"), "components", "Ljava/util/List;")
        //用无参构造方法创建一个组件实例
        mv.visitTypeInsn(Opcodes.NEW, name)
        mv.visitInsn(Opcodes.DUP)
        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, name, "<init>", "()V", false)
        mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "add", "(Ljava/lang/Object;)Z", true)
        mv.visitInsn(POP)
    }
}
}

最后产生的字节码之前和之后对比如下:

public class InjectManager {
public synchronized void initComponent() { }
}

   ..........之后..........

public class InjectManager {
private List<IComponent> components = new ArrayList();
public synchronized void initComponent() {
    this.components.clear();
    this.components.add(new MainComponent());
    this.components.add(new UserComponent());
    this.components.add(new OrderComponent());
}
  }

这样就完成了组件化在编译期自动注入其他组件初始化,当你要使用的就直接调用InjectManager .initComponent()就可以了。其实还有更好的方式就是像 android hilt 那样通过修改类的继承方式,把所有的逻辑放在了父类中,让我们Application 去继承该Application即可。
AOP 的利器:ASM 3.0 介绍

相关文章

  • Gradle+Transform+Asm自动化注入代码

    如果大家公司使用组件化开发可能都会碰到应该怎么样去初始化其他组件,比如:消息组件,那我们怎么去初始化呢? 在Bas...

  • Android Hilt实战初体验: Dagger替换成Hilt

    在组件化AwesomeGithub项目中使用了Dagger来减少手动依赖注入代码。虽然它能自动化帮我们管理依赖项,...

  • iOS逆向 代码注入+Hook

    iOS逆向 代码注入+HookiOS逆向 代码注入+Hook

  • iOS应用代码注入防护

    iOS应用代码注入防护 iOS应用代码注入防护

  • iOS开发逆向之代码注入(上)

    本文主要讲解代码注入的两种方式:FrameWork注入、dylib注入 代码注入 一般修改原始的程序,是利用代码注...

  • 代码注入

    Framework库中代码注入工程的步骤:(选择iOS下创建库) Dylib库中代码注入工程的步骤:(选择MacO...

  • 代码注入

  • 代码注入

    typedef注意用法

  • 代码注入

    在学习代码注入之前,先看一下iOS 程序 main 函数之前发生了什么 一、framework形式代码注入 1.创...

  • 代码注入

    代码注入 一般修改原始的程序,是利用代码注入的方式,注入代码就会选择利用FrameWork或者Dylib等第三方库...

网友评论

    本文标题:Gradle+Transform+Asm自动化注入代码

    本文链接:https://www.haomeiwen.com/subject/yxzcyhtx.html