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修改字节码的粗略方式;还是比较有意义的,因此抽空记录下;