transform+asm进行字节码修改

前言

最近遇到一个问题,原来是通过在application初始化的时候通过代码进行运行时的反射修改,以修改某个属性,达到我们需要的切换效果; 但是因为需求的变化,导致了我们要修改的地方变成了private final String这样的类型了,jvm会将这个变量当做常量进行优化; 因此在运行时的修改已经不再生效了,那么我们只能在编译时期通过修改字节码的方式进行适配;

当然我们可以用Lancet进行修改,但是我们的sdk demo中并没有引入lancet;因此我们就用transform+asm的方式进行修改, 原理都是一致的; 这个原来不是很熟,因此这里把相应的步骤详细记录下;

详细步骤

1. 创建相应的module,以存放transform插件相关代码(创建目录+settiings.gradle.kts里面添加这个module name+路径)

2. 创建一个app plugin; 用于在合适的时机注册tranform task,以及通过extension来决定是否注册

class CronetAsmPlugin : Plugin {

    companion object {
        val EXT_NAME = "gCronetAsm"
    }

    lateinit var agp: AppPlugin
    lateinit var project: Project

    override fun apply(target: Project) {
        project = target
        val extn = project.extensions.create(EXT_NAME, CronetModifyExtn::class.java)
        agp = project.plugins.findPlugin("com.android.application") as AppPlugin

        project.gradle.addProjectEvaluationListener(object: ProjectEvaluationListener{
            override fun afterEvaluate(project: Project, state: ProjectState) {
                if (extn.enabled) {
                    agp.extension.registerTransform(ClassTransform(this@CronetAsmPlugin))
                }
            }

            override fun beforeEvaluate(project: Project) {

            }
        })

    }
}


open class CronetModifyExtn {
    var enabled: Boolean = true
}

3. 实现相应的transform task

class ClassTransform(val plugin: CronetAsmPlugin) : Transform() {

    override fun getName(): String {
        return "gCronetAsm"  //这个transform task的名字, 会生成:rocket_demo:transformClassesWithGCronetAsmForCnToutiaoDebug类似这样的名字
    }

    override fun getInputTypes(): MutableSet {
        return TransformManager.CONTENT_CLASS   //只修改class
    }

    override fun isIncremental(): Boolean {
        return false
    }

    override fun getScopes(): MutableSet {
        return TransformManager.SCOPE_FULL_PROJECT  
    }

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)  //真正开始执行的地方

        modify(transformInvocation)

        copyUtilClass(transformInvocation!!)
    }
}

transformInvocation的定义为

/**
 * An invocation object used to pass of pertinent information for a
 * {@link Transform#transform(TransformInvocation)} call.
 */
public interface TransformInvocation 

作为执行到transform方法时的入参,包含了这个tranform task的input,可以决定output, 参考

对input jar和dir分别进行遍历,transformInvocation.outputProvider.getContentLocation决定了output的存放路径,最后的参数代表生成产物的类型

private fun modify(transformInvocation: TransformInvocation?) {
    transformInvocation!!.outputProvider.deleteAll()

    transformInvocation.inputs.forEach {
        it.jarInputs.forEach { jar ->
           plugin.project.logger.info("Handling jar input: $jar")
            modifyJar(jar.file,
                    transformInvocation.outputProvider.getContentLocation(jar.name, TransformManager.CONTENT_CLASS, jar.scopes, Format.JAR))
        }
        it.directoryInputs.forEach { dir ->
            val dirName = dir.name
            val dstDir = transformInvocation.outputProvider.getContentLocation(dirName, TransformManager.CONTENT_CLASS, dir.scopes, Format.DIRECTORY)
            Files.move(Paths.get(dir.file.path), Paths.get(dstDir.path))
            val dstPath = Paths.get(dstDir.path)
            plugin.project.logger.info("Handling dir input: ${dir.file.absolutePath} dst dir: $dstPath")
            Files.walkFileTree(dstPath, object : SimpleFileVisitor() {
                override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult {
                    modifyClass(file!!, file)
                    return super.visitFile(file, attrs)
                }
            })
        }

    }
}

注: 调试的时候把log用println打印出来更方便

4. 在modifyClass中利用ASM进行具体的操作

首先modifyJar及时就是将jar包中的class进行遍历,代码如下

private fun modifyJar(inputJar: File, outJar: File) {
    val zos = ZipOutputStream(FileOutputStream(outJar))
    val zf = ZipFile(inputJar.absolutePath)
    val entries = zf.entries()
    val buffer = ByteArray(4096)
    val baos = ByteArrayOutputStream(4096)
    while (entries.hasMoreElements()) {
        val entry = ZipEntry(entries.nextElement().name)
        zos.putNextEntry(entry)
        val zis = zf.getInputStream(entry)
        var len: Int
        while (true) {
            len = zis.read(buffer)
            if (len <= 0) break
            baos.write(buffer, 0, len)
        }

        val modifiedBytes: ByteArray
        modifiedBytes = if (entry.name.endsWith(".class")) {
            try {
                plugin.project.logger.info("Modifying cls: ${entry.name}")
                modifyClass(baos.toByteArray())
            } catch (e: Exception) {
                plugin.project.logger.warn("Fail to modify class: ${entry.name} from jar: $inputJar")
                e.printStackTrace()
                baos.toByteArray()
            }
        } else {
            baos.toByteArray()
        }

        zos.write(modifiedBytes, 0, modifiedBytes.size)
        baos.reset()
        zis.close()
    }

    zos.close()
}

modifyclass的相关逻辑为

private fun modifyClass(clsPath: Path, dstPath: Path) {
    try {
        plugin.project.logger.info("Modifying cls: $clsPath dstPath: $dstPath")
        Files.write(dstPath, modifyClass(Files.readAllBytes(clsPath)))
    } catch (e: Exception) {
        plugin.project.logger.warn("Fail to modify class: $clsPath")
        Files.copy(dstPath, clsPath)
    }
}

@Throws(Exception::class)
fun modifyClass(bytes: ByteArray): ByteArray {
    val cr = ClassReader(bytes)
    val cw = ClassWriter(cr, 0)
    try {
        cr.accept(ClassTransformer(Opcodes.ASM5, cw, plugin), 0)
    } catch (e: Exception) {
        throw e
    }
    return cw.toByteArray()
}

ClassReader 读取class的数据, ClassWriter将修改过后的class写出来

中间的过滤层是个ClassVisitor,遍历class中的相关元素,可以重载其中的方案已达到修改的目的

这里遍历class中的方法调用,通过修改返回的MethodVisitor来达到修改调用方法的目的

class ClassTransformer @Inject constructor(api: Int, cv: ClassVisitor, val plugin: CronetAsmPlugin) : ClassVisitor(api, cv) {

    override fun visitMethod(access: Int, name: String?, desc: String?, signature: String?, exceptions: Array?): MethodVisitor {
        var mv = super.visitMethod(access, name, desc, signature, exceptions)
        mv = RTransformer(Opcodes.ASM7, mv, plugin)
        return mv
    }
}

真正的修改规则如下

class RTransformer @Inject constructor(api: Int, mv: MethodVisitor, val plugin: CronetAsmPlugin) : MethodVisitor(api, mv) {

    override fun visitMethodInsn(opcode: Int, owner: String?, name: String?, descriptor: String?, isInterface: Boolean) {
        if (opcode == Opcodes.INVOKEVIRTUAL && owner == "org/chromium/CronetClient" && name == "getConfigFromAssets" && descriptor == "(Landroid/content/Context;Ljava/lang/String;)Ljava/lang/String;") {
            plugin.project.logger.info("CronetClient#getConfigFromAssets invoked")
            super.visitMethodInsn(Opcodes.INVOKESTATIC, "g/cronet/asm/CronetUtil", "getCronetConfigFromAssets", "(Lorg/chromium/CronetClient;Landroid/content/Context;Ljava/lang/String;)Ljava/lang/String;", isInterface)
            return
        }
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface)
    }
}

具体就不再详细解释了,其实就是smali的规则;

5. 将插件中定义的替代方法也打到包里

插件中的class默认是不打进包里的,那么虽然编译时不会出错,真正调用时也会因为找不到相关类而失败; 因此将插件中的util class也作为这个task的产物,放到一个out dir中

private fun copyUtilClass(transformInvocation: TransformInvocation) {
    val cls = "/g/cronet/asm/CronetUtil.class"
    val dstDir = transformInvocation.outputProvider.getContentLocation("gCronet", TransformManager.CONTENT_CLASS, scopes, Format.DIRECTORY)
    File(dstDir, cls).run {
        parentFile.mkdirs()
        delete()
        createNewFile()
        Files.copy(ClassTransform::class.java.getResourceAsStream(cls), Paths.get(this.path), StandardCopyOption.REPLACE_EXISTING)
    }
}

ClassTransform::class.java.getResourceAsStream(cls) 注意这里的用法,用于调用jar中的资源,包括class以及其他资源;

6. 其他编译错误

一开始因为使用的不熟练,没有注意到要替换的方法的第一个参数是个this,因此替换成static invoke时少了一个参数; 导致编译时失败; 报错栈:

Caused by: com.android.builder.dexing.DexArchiveBuilderException: Error while dexing.
        at com.android.builder.dexing.D8DexArchiveBuilder.getExceptionToRethrow(D8DexArchiveBuilder.java:124)
        at com.android.builder.dexing.D8DexArchiveBuilder.convert(D8DexArchiveBuilder.java:101)
        at com.android.build.gradle.internal.transforms.DexArchiveBuilderTransform.launchProcessing(DexArchiveBuilderTransform.java:904)
        ... 6 more
Caused by: java.lang.ArrayIndexOutOfBoundsException: 0

很明显看到是DexArchiveBuilderTransform这个transform task失败,这个问题如果正面去看,需要对整体打包流程非常熟悉才可以,比较困难; 那么能不能反过来去猜测呢;

执行 ./gradlew --dry-run时发现

:rocket_demo:transformClassesWithGCronetAsmForCnToutiaoDebug SKIPPED
:rocket_demo:transformClassesWithDexBuilderForCnToutiaoDebug SKIPPED
DexArchiveBuilderTransform(
        @NonNull Supplier> androidJarClasspath,
        @NonNull DexOptions dexOptions,
        @NonNull MessageReceiver messageReceiver,
        @Nullable FileCache userLevelCache,
        int minSdkVersion,
        @NonNull DexerTool dexer,
        boolean useGradleWorkers,
        @Nullable Integer inBufferSize,
        @Nullable Integer outBufferSize,
        boolean isDebuggable,
        @NonNull VariantScope.Java8LangSupport java8LangSupportType,
        @NonNull String projectVariant,
        @Nullable Integer numberOfBuckets,
        boolean includeFeaturesInScopes,
        boolean isInstantRun,
        boolean enableDexingArtifactTransform) {
    this.androidJarClasspath = androidJarClasspath;
    this.dexOptions = dexOptions;
    this.messageReceiver = messageReceiver;
    this.minSdkVersion = minSdkVersion;
    this.dexer = dexer;
    this.projectVariant = projectVariant;
    this.executor = WaitableExecutor.useGlobalSharedThreadPool();
    this.cacheHandler =
            new DexArchiveBuilderCacheHandler(
                    userLevelCache, dexOptions, minSdkVersion, isDebuggable, dexer);
    this.useGradleWorkers = useGradleWorkers;
    this.inBufferSize =
            (inBufferSize == null ? DEFAULT_BUFFER_SIZE_IN_KB : inBufferSize) * 1024;
    this.outBufferSize =
            (outBufferSize == null ? DEFAULT_BUFFER_SIZE_IN_KB : outBufferSize) * 1024;
    this.isDebuggable = isDebuggable;
    this.java8LangSupportType = java8LangSupportType;
    if (isInstantRun) {
        this.numberOfBuckets = NUMBER_OF_SLICES_FOR_PROJECT_CLASSES;
    } else {
        this.numberOfBuckets = numberOfBuckets == null ? DEFAULT_NUM_BUCKETS : numberOfBuckets;
    }
    this.includeFeaturesInScopes = includeFeaturesInScopes;
    this.isInstantRun = isInstantRun;
    this.enableDexingArtifactTransform = enableDexingArtifactTransform;
}

@NonNull
@Override
public String getName() {
    return "dexBuilder";
}

看这个name,果然DexArchiveBuilderTransform就是我们自定义transform task的下一个;


gradle transform.png

也就是说我们的output错误可能造成了这个问题;首先调试证明下


debug result.png

果然,DexArchiveBuilderTransform的input就是我们自定义task的output,那么我们就回过头来看output的问题; 最终发现了替代函数的参数与原函数不匹配;

7. 插件中找不到aar中的类

因为我们的插件只apply了org.gradle.java; 而需要的类是个打进rocketdemo中的aar; 因此我们compileOnly这个aar是不生效的;

那么就有两种方法
(1) 将aar中的jar包抽出来compileOnly
(2) 更简单的方法,构造一个同名stub类放在插件module中,因为这个不会被打到rocket_demo中,所以不会产生类冲突 (这其实是一种很常见的设计思想,但一开始就是没想到)

总结

因为这个需求,大体了解了gradle transform task的注册,输入,输出; 以及利用asm修改字节码的粗略方式;还是比较有意义的,因此抽空记录下;

你可能感兴趣的:(transform+asm进行字节码修改)