AGP8.0 插件适配中 学到的一些知识点

1,019 阅读6分钟

最近一直在做agp8.0+的插件适配,涉及到不少知识点,踩到不少坑,特此记录下

gradle和gradle-api的区别

image.png

我们在插件开发的时候 如果你看官方的demo 你会发现他们现在都是给你gradle-api 这个依赖,但实际开发的时候我们会发现还会直接用gradle 依赖比较好

简单来说gradle-api 是官方给你的一个简易依赖,对外暴露的api更少,但是更加稳定,agp本身的api变化 会在这个gradle-api依赖中抹平(是不是有一点像booster做的事?)

但是这个gradle-api 对外暴露的api太少了,相信我 至少短期内你不会想直接用gradle-api依赖的

建议插件开发时 直接使用gradle的依赖

任务的输入和输出

查询agp中某个task的具体实现

./gradlew help --task <taskName> 

例如:

./gradlew help --task dexBuilderDefaultNewSignDebug

image.png

这样可以方便的让我们找到task对应的代码实现位置在哪里 方便定位问题

任务可以没有输入 但是不能没有输出

输入类型和输出类型 不是11对应的

仅支持输入,不支持输出的类型

  • String或者任何实现了JDK中 序列化接口Ser的类
  • Java的ClassPath

仅支持输出 不支持输入的类型

  • Map<String,File>
  • Iterable

一些重要且特殊的注解说明

  • @input 一般string类型或者序列化的java类就用这个,很多人会把这个和internal作用搞混
  • @internal 既不是输入属性也不是输出属性, 也不影响updateToWhen的判定
  • @PathSensitive 搭配一些input注解使用 告诉gradle 的 输入文件 路径变化的敏感等级用的,一共是4个级别 (经常用的就这2个,还有2个就不写了)absoulte 任何路径更改 都会触发update,none 忽略所有路径变更 只考虑内容
  • @optional 这个就是和部分注解搭配使用时,跳过文件检测,不传也可以正确执行task
  • @Incremental 搭配 inputfiles和inputDir 使用,可以通过filechanges 来获取文件变化的细节

任务的状态

  • executed 执行
  • up-to-date 没有执行 一般是输入输出没改变,大家增量构建时 很多任务都是这个状态 剩下的 from-cache skipped no-source 也是代表任务不执行的状态,只是原因不同

FileCollection和FileTree的区别

更多细节参见这里 简单来说就是FileCollection没有保存文件的树形结构,而FileTree保存了

image.png

Artifacts API

这个api 在agp8.0以后 会变得非常重要,在8.0之前 我们做agp插件开发的时候 通常的逻辑是是 先找到agp默认的2个任务 比如 a和c

然后在ac之间插入你自定义的任务b,具体插入方式为 c.dependsOn(b) b.dependsOn(a)

然而这套机制在agp8.0之后 完全失效了, 你在onVarint回调里 是没办法拿到agp的默认任务的 这样就是导致了 你无法插入任务到agp中


val androidComponents = target.extensions.getByType(AndroidComponentsExtension::class.java)
androidComponents.onVariants {
}    
    

目前还不知道怎么绕过这套机制,如果有知道的可以评论区留言。

谷歌在8.0以后推荐的方式 是希望你利用Artifacts API 来完成你的任务编排

8.0之后 如何修改manifest文件?

首先还是定义一下我们的task


abstract class  DeleteSmsPermissionForNewSignAppTask : DefaultTask() {

    @get:InputFile
    @get:PathSensitive(PathSensitivity.NONE)
    abstract val mergedManifest:RegularFileProperty


    @get:OutputFile
    abstract val updatedManifest: RegularFileProperty

    @TaskAction
    fun taskAction() {

        val file  = mergedManifest.get().asFile

       
        val document = SAXReader().read(file)
        // 随便更改你的xml 就可以了

        OutputFormat.createPrettyPrint().apply {
            encoding = "UTF-8"
            XMLWriter(OutputStreamWriter(FileOutputStream(updatedManifest.get().asFile)), this).apply {
                write(document)
                flush()
                close()
            }
        }

    }
}
    

最关键的就是如何构建你的task关系,在agp8.0之前我们需要找到mergeManifest 这个task 然后插入 agp8.0之后 会简单很多

variant.artifacts.use(modifyTask)
    .wiredWithFiles(
        DeleteSmsPermissionForNewSignAppTask::mergedManifest,
        DeleteSmsPermissionForNewSignAppTask::updatedManifest
    ).toTransform(SingleArtifact.MERGED_MANIFEST)

SingleArtifact

可以看下 有多少种单个的Arifact 可以供我们使用

image.png

  • AAR 可获取AAR
  • APK 可获取APK
  • Bundle 可获取AAB
  • MERGED_MANIFEST 可获取合并后的manifest文件

其他类型 大家有兴趣看看注释即可

其他类型的Artifact

image.png

这个我们一般不会用到它,大家知道有这么个东西即可

下面这个就不一样了

image.png

这个ScopedArtifact 在 8.0以后的字节码 插件中会扮演重要地位

这个我们后面再说,大家只要谨记,agp8.0以后 你要想插入任务到agp之中 暂时只能支持这几种了。 确实很不方便,比如8.0之前 你要做图片压缩的时候 你插入一个任务到mergeRes之后就可以了

但是现在没有Resources 这个 artifact 这就导致 我们无法插入任务。

aritfact 处理的几种形式

varint.artifacts.use(xxxTask).wiredWithFiles/wireDirectories()/wiredWith()
    .toTransform/toCreate/toAppendTo

具体你最终用到的artifact 支持哪种 wired 取决于 源码中实现的接口

image.png

遗留问题

artifact 目前还没支持 mergeResources 这个任务, 这会导致 在8.0+中 你如果想做编译期对图片资源压缩之类的任务就没办法做了

有知道的可以评论区留言说下方案

8.0+中的 字节码处理

asm

整体来说 asm的字节码修改 代码写起来 比 8.0之前要简单方便很多,只有当你处理类似像arouter这样的代码的时候会麻烦一些(需要先全盘扫描再做处理),路由插件的asm 8.0 适配如何做 可以看我之前的文章即可。

这里介绍下普通字节码修改的的写法

variant.instrumentation.transformClassesWith(
    OkHttpClassVisitorFactory::class.java,
    InstrumentationScope.ALL
) {}
    
abstract class OkHttpClassVisitorFactory : AsmClassVisitorFactory<InstrumentationParameters.None> {
    override fun createClassVisitor(classContext: ClassContext, nextClassVisitor: ClassVisitor): ClassVisitor {
        if (classContext.currentClassData.className != "okhttp3.OkHttpClient") {
            return nextClassVisitor
        }

        return object : ClassNode(ASM7) {
            override fun visitEnd() {
                super.visitEnd()
                methods?.find {
                    it.name == "<init>" && it.desc != "()V"
                }.let {
                    it?.instructions
                            ?.iterator()
                            ?.asIterable()
                            ?.filterIsInstance(FieldInsnNode::class.java)
                            ?.filter { fieldInsnNode ->
                                fieldInsnNode.opcode == PUTFIELD &&
                                        fieldInsnNode.owner == "okhttp3/OkHttpClient" &&
                                        fieldInsnNode.name == "networkInterceptors" &&
                                        fieldInsnNode.desc == "Ljava/util/List;"
                            }?.forEach { fieldInsnNode ->
                                it.instructions.insert(fieldInsnNode, createOkHttpClientInsnList())
                            }
                }
                accept(nextClassVisitor)
            }
        }

    }

    override fun isInstrumentable(classData: ClassData): Boolean {
        return true
    }

    private fun createOkHttpClientInsnList(): InsnList {
        return with(InsnList()) {
            // 插入application 拦截器
            add(VarInsnNode(ALOAD, 0))

            add(
                    MethodInsnNode(
                            INVOKESTATIC,
                            "com/xxx/xxxx/hook/OkHttpHook",
                            "addInterceptor",
                            "(Lokhttp3/OkHttpClient;)V",
                            false,
                    ),
            )
            this
        }
    }

    fun <T> Iterator<T>.asIterable(): Iterable<T> = Iterable { this }

}

javaassist

js修改字节码会复杂一些,可以参考下面的写法

这个例子稍微复杂一点 其实主要也是想办法把android.jar 加入到js的环境中,否则部分字节码修改会失败

val taskProvider = target.project.tasks.register<ModifyGlideClassesTask>("${variant.name}ModifyGlideClasses") {
    // 必须把android.jar  加入到 ClassPool 中
    bootClasspath = androidComponents.sdkComponents.bootClasspath
}
variant.artifacts.forScope(ScopedArtifacts.Scope.ALL)
    .use(taskProvider)
    .toTransform(
        ScopedArtifact.CLASSES,
        ModifyGlideClassesTask::allJars,
        ModifyGlideClassesTask::allDirectories,
        ModifyGlideClassesTask::output
    )
/**
 * 修改glide的 gifdrawable了
 * gifdrawable 对draw方法进行try catch
 */
abstract class ModifyGlideClassesTask : DefaultTask() {

    @get:Internal
    abstract var bootClasspath: Provider<List<RegularFile>>

    @get:InputFiles
    abstract val allJars: ListProperty<RegularFile>

    @get:InputFiles
    abstract val allDirectories: ListProperty<Directory>

    @get:OutputFile
    abstract val output: RegularFileProperty

    @Internal
    val jarPaths = mutableSetOf<String>()

    companion object {
        const val GLIDE_HOOK_SWITCH = "image.helper.glide.gifdrawble.HookSwitch"

        //GifDrawable的全名 包含他所属的包名
        const val GLIDE_GIF_CLASS_NAME = "com.bumptech.glide.load.resource.gif.GifDrawable"

        //要修改的是GifDrawable的 draw方法
        const val METHOD_NAME_DRAW = "draw"

        //新方法命名为tryDraw
        const val NEW_METHOD_NAME_TRY_DRAW = "tryDraw"
    }

    @TaskAction
    fun taskAction() {

        val pool = ClassPool(ClassPool.getDefault())
//        (allJars.get() + allDirectories.get()).map { it.asFile }.forEach {
//            pool.appendClassPath(it.canonicalPath)
//        }

        // 不要遗漏添加android.jar 到ClassPool中
        bootClasspath.get().map(RegularFile::getAsFile).forEach {
            pool.appendClassPath(it.canonicalPath)
        }

        val jarOutput = JarOutputStream(
            BufferedOutputStream(
                FileOutputStream(
                    output.get().asFile
                )
            )
        )
        // copy classes from jar files without modification
        allJars.get().forEach outer@{ file ->
            val jarFile = JarFile(file.asFile)
            jarFile.entries().iterator().forEach { jarEntry ->
                if (jarEntry.name == "com/bumptech/glide/load/resource/gif/GifDrawable.class") {
                    val jarInputStream = jarFile.getInputStream(jarEntry)
                    val klass = pool.makeClass(jarInputStream)
                    // 能走到这 就证明找到 GifDrawable这个class了 ,此时我们直接取获取draw方法
                    val drawMethod: CtMethod = klass.getDeclaredMethod(METHOD_NAME_DRAW)
                    println("HelpThirdClassTransform :找到draw方法了")
                    //复制一个名为try-draw的新方法
                    val newMethod: CtMethod = CtNewMethod.copy(drawMethod, NEW_METHOD_NAME_TRY_DRAW, klass, null)
                    klass.addMethod(newMethod)
                    //这里就将原来的draw方法的内容 替换成 调用我们的tryDraw方法 并在调用tryDraw的地方 加上try catch代码块
                    val sb = StringBuffer()
                    sb.append("{try{")
                    sb.append(NEW_METHOD_NAME_TRY_DRAW)
                    //这里要传递canvas这个参数 注意javaassist的写法
                    sb.append("($1)")
                    sb.append(";}catch(Exception e){  android.util.Log.e("GifDrawable", "draw", e);}")
                    sb.append("}")
                    //改写我们的draw方法
                    drawMethod.setBody(sb.toString())
                    jarInputStream.close()
                    jarOutput.writeEntity(jarEntry.name, klass.toBytecode())
                    return@forEach
                }

                jarOutput.writeEntity(jarEntry.name, jarFile.getInputStream(jarEntry))
            }
            jarFile.close()
        }
        // Iterating through class files from directories
        // Looking for SomeSource.class to add generated interface and instrument with additional output in
        // toString methods (in our case it's just System.out)
        allDirectories.get().forEach { directory ->
            directory.asFile.walk().forEach { file ->
                if (file.isFile) {
                    // if class is not SomeSource.class - just copy it to output without modification
                    val relativePath = directory.asFile.toURI().relativize(file.toURI()).getPath()
                    jarOutput.writeEntity(relativePath.replace(File.separatorChar, '/'), file.inputStream())
                }
            }
        }
        jarOutput.close()
    }

    // writeEntity methods check if the file has name that already exists in output jar
    private fun JarOutputStream.writeEntity(name: String, inputStream: InputStream) {
        // check for duplication name first
        if (jarPaths.contains(name)) {
            printDuplicatedMessage(name)
        } else {
            putNextEntry(JarEntry(name))
            inputStream.copyTo(this)
            closeEntry()
            jarPaths.add(name)
        }
    }

    private fun JarOutputStream.writeEntity(relativePath: String, byteArray: ByteArray) {
        // check for duplication name first
        if (jarPaths.contains(relativePath)) {
            printDuplicatedMessage(relativePath)
        } else {
            putNextEntry(JarEntry(relativePath))
            write(byteArray)
            closeEntry()
            jarPaths.add(relativePath)
        }
    }

    private fun printDuplicatedMessage(name: String) =
        println("Cannot add ${name}, because output Jar already has file with the same name.")
}