美文网首页
transform+asm进行字节码修改

transform+asm进行字节码修改

作者: weiinter105 | 来源:发表于2019-10-18 22:47 被阅读0次

    前言

    最近遇到一个问题,原来是通过在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修改字节码的粗略方式;还是比较有意义的,因此抽空记录下;

    相关文章

      网友评论

          本文标题:transform+asm进行字节码修改

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