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<Project> {

    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<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS   //只修改class
    }

    override fun isIncremental(): Boolean {
        return false
    }

    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        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<Path>() {
                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<String>?): 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<List<File>> 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修改字节码的粗略方式;还是比较有意义的,因此抽空记录下;

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容

  • 简介 本文主要介绍gradle打包过程中transform阶段,这里大概说下AOP(Aspect Oriented...
    lycknight阅读 4,515评论 1 20
  • 自定义插件涉及到几个知识点,比如Gradle构建工具、Groovy语法、Gradle插件开发流程等等。这些知识我就...
    nailperry阅读 6,477评论 10 37
  • 必会赢 任何演讲都包含以下三个目的:——表达观点,传播理念。比如:TED演讲,演讲者主要是为了传播一种观点或理念。...
    必会赢阅读 5,216评论 0 5
  • 大连高中同学 中学同学聚会好去处 如果你正在筹备一场人数众多的聚会,但由于众口难调各有所爱,迟迟找不到合适的地方。...
    爱接电话阅读 98评论 0 0
  • 天空飞过一只飞机 真的 很白很小很远很慢 我大声呼喊 吓不到它 眼前开着一朵花 假的 很红很大很灿很烂 我小心触摸...
    咔嚓崔阅读 322评论 2 2