Transform API 废弃了,路由插件怎么办?

5,726 阅读10分钟

本文Demo地址:Router

前言

在 AGP 7.2 中,谷歌废弃了Android开发过程非常常用的Transform API,具体信息可以查看Android Gradle 插件 API 更新

image.png

可以看到Transform API在 AGP 7.2 标记了废弃,且在 AGP 8.0 将面临移除的命运。如果你将Android工程的AGP升级到7.2.+,尝试运行使用了Transform API的插件的项目时,将得到以下警告。

API 'android.registerTransform' is obsolete.
It will be removed in version 8.0 of the Android Gradle plugin.
The Transform API is removed to improve build performance. Projects that use the
Transform API force the Android Gradle plugin to use a less optimized flow for the
build that can result in large regressions in build times. It’s also difficult to
use the Transform API and combine it with other Gradle features; the replacement
APIs aim to make it easier to extend the build without introducing performance or
correctness issues.

There is no single replacement for the Transform API—there are new, targeted
APIs for each use case. All the replacement APIs are in the
`androidComponents {}` block.
For more information, see https://developer.android.com/studio/releases/gradle-plugin-api-updates#transform-api.
REASON: Called from: /Users/l3gacy/AndroidStudioProjects/Router/app/build.gradle:15
WARNING: Debugging obsolete API calls can take time during configuration. It's recommended to not keep it on at all times.

看到这种情况,相信很多人第一反应都是how old are you?。Gradle API的频繁变动相信写过插件的人都深受其害,又不是一天两天了。业界一些解决方案大都采用封装隔离来最小化Gradle API的变动。常见的如

此次 Transform API 将在 AGP 8.0 移除,这一改动对于目前一些常用的类库、插件都将面临一个适配的问题,常见的如路由、服务注册、字符串加密等插件都广泛使用了Transform API。那么究竟该怎么解决此类适配问题找到平替方案呢?本篇将探讨目前主流的一些观点是否能够满足需求以及如何真正的做到适配。

主流观点

当你尝试解决此问题时,一通检索基本会得到两种不同的见解,目前也有一些同学针对这两个API进行了探索。

那么上述提到的两种API是否真的就能解决我们的问题呢?其实行也不行!

AsmClassVisitorFactory

首先来看看AsmClassVisitorFactory

AsmClassVisitorFactory是没有办法做到像Transform一样,先扫描所有class收集结果,再执行ASM修改字节码。原因是AsmClassVisitorFactoryisInstrumentable方法中确定需要对哪些class进行ASM操作,当返回true之后,就执行了createClassVisitor方法进行字节码操作去了,这就导致可能你路由表都还没收集完成就去修改了目标class

机灵的小伙伴可能会想,那我再注册一个收集路由表的AsmClassVisitorFactory,然后在注册一个真正执行ASM操作的AsmClassVisitorFactory不就好了,那么这种做法可以吗,其实在你的插件想适配Transform Action? 可能还早了点 - 掘金这边文章里已经给出了答案。

TransformAction

既然 AsmClassVisitorFactory 不能打,那 TransformAction 能打不,我们来看下AGP中的实现。

可以看到是有相关ASM实现的。TransformAction 的应用目前较少,主要常见的有 JetifyTransformAarTransform等,主要做产物转换。但 TransformAction 操作起来比较麻烦,详细可以看Transforming dependency artifacts on resolution

平替方案

既然两种观点,一个不能打,一个嫌麻烦,那有没有简单既易用,又可少量修改即可完成适配的方案呢,答案当然是有了。不然水本篇就没有意义了。那么本篇就带大家来简单探索下 Transform API的废弃,针对路由类库的插件适配的一种平替方案。

首先我们要知道Transform在Gradle中其实也对应一个Task,只是有点特殊。我们来看下定义:

public abstract class Transform {

    ··· omit code ···
        
    public void transform(
        @NonNull Context context,
        @NonNull Collection<TransformInput> inputs,
        @NonNull Collection<TransformInput> referencedInputs,
        @Nullable TransformOutputProvider outputProvider,
        boolean isIncremental) throws IOException, TransformException, InterruptedException {
    }

    public void transform(@NonNull TransformInvocation transformInvocation)
            throws TransformException, InterruptedException, IOException {
        // Just delegate to old method, for code that uses the old API.
        //noinspection deprecation
        transform(transformInvocation.getContext(), transformInvocation.getInputs(),
                transformInvocation.getReferencedInputs(),
                transformInvocation.getOutputProvider(),
                transformInvocation.isIncremental());
    }
    
    ··· omit code ···
}

看到这里,有些同学就要疑问了。你这不扯淡吗,Transform根本没有继承 DefaultTaskAbstractTask或者实现 Task 接口。你怎么断定Transform本质上也是一个GradleTask呢?这部分完全可以由Gradle的源码里找到答案,这里不赘述了。

Plugin

回到正题。究竟该怎么去使用Task去适配呢?我们先用伪代码来简要说明下。

class RouterPlugin : Plugin<Project> {

    override fun apply(project: Project) {
    
        ··· omit code ···
        
        with(project) {
        
            ··· omit code ···
            
            plugins.withType(AppPlugin::class.java) {
                val androidComponents =
                    extensions.findByType(AndroidComponentsExtension::class.java)
                androidComponents?.onVariants { variant ->
                    val name = "gather${variant.name.capitalize(Locale.ROOT)}RouteTables"
                    val taskProvider = tasks.register<RouterClassesTask>(name) {
                        group = "route"
                        description = "Generate route tables for ${variant.name}"
                        bootClasspath.set(androidComponents.sdkComponents.bootClasspath)
                        classpath = variant.compileClasspath
                    }
                    variant.artifacts.forScope(ScopedArtifacts.Scope.ALL)
                        .use(taskProvider)
                        .toTransform(
                            ScopedArtifact.CLASSES,
                            RouterClassesTask::jars,
                            RouterClassesTask::dirs,
                            RouterClassesTask::output,
                        )
                }
            }
        }
        
        ··· omit code ···
    }
}

我们使用了onVariants API 注册了一个名为gather[Debug|Release]RouteTablesTask,返回一个TaskProvider对象,然后使用variant.artifacts.forScope(ScopedArtifacts.Scope.ALL)来使用这个Task进行toTransform操作,可以发现我们无需手动执行该Task。来看下这个toTransform的定义。

注意ScopedArtifacts需要AGP 7.4.+以上才支持

/**
 * Defines all possible operations on a [ScopedArtifact] artifact type.
 *
 * Depending on the scope, inputs may contain a mix of [org.gradle.api.file.FileCollection],
 * [RegularFile] or [Directory] so all [Task] consuming the current value of the artifact must
 * provide two input fields that will contain the list of [RegularFile] and [Directory].
 *
 */
interface ScopedArtifactsOperation<T: Task> {

    /**
     * Append a new [FileSystemLocation] (basically, either a [Directory] or a [RegularFile]) to
     * the artifact type referenced by [to]
     *
     * @param to the [ScopedArtifact] to add the [with] to.
     * @param with lambda that returns the [Property] used by the [Task] to save the appended
     * element. The [Property] value will be automatically set by the Android Gradle Plugin and its
     * location should not be considered part of the API and can change in the future.
     */
    fun toAppend(
        to: ScopedArtifact,
        with: (T) -> Property<out FileSystemLocation>,
    )

    /**
     * Set the final version of the [type] artifact to the input fields of the [Task] [T].
     * Those input fields should be annotated with [org.gradle.api.tasks.InputFiles] for Gradle to
     * property set the task dependency.
     *
     * @param type the [ScopedArtifact] to obtain the final value of.
     * @param inputJars lambda that returns a [ListProperty] or [RegularFile] that will be used to
     * set all incoming files for this artifact type.
     * @param inputDirectories lambda that returns a [ListProperty] or [Directory] that will be used
     * to set all incoming directories for this artifact type.
     */
    fun toGet(
        type: ScopedArtifact,
        inputJars: (T) -> ListProperty<RegularFile>,
        inputDirectories: (T) -> ListProperty<Directory>)

    /**
     * Transform the current version of the [type] artifact into a new version. The order in which
     * the transforms are applied is directly set by the order of this method call. First come,
     * first served, last one provides the final version of the artifacts.
     *
     * @param type the [ScopedArtifact] to transform.
     * @param inputJars lambda that returns a [ListProperty] or [RegularFile] that will be used to
     * set all incoming files for this artifact type.
     * @param inputDirectories lambda that returns a [ListProperty] or [Directory] that will be used
     * to set all incoming directories for this artifact type.
     * @param into lambda that returns the [Property] used by the [Task] to save the transformed
     * element. The [Property] value will be automatically set by the Android Gradle Plugin and its
     * location should not be considered part of the API and can change in the future.
     */
    fun toTransform(
        type: ScopedArtifact,
        inputJars: (T) -> ListProperty<RegularFile>,
        inputDirectories: (T) -> ListProperty<Directory>,
        into: (T) -> RegularFileProperty)

    /**
     * Transform the current version of the [type] artifact into a new version. The order in which
     * the replace [Task]s are applied is directly set by the order of this method call. Last one
     * wins and none of the previously set append/transform/replace registered [Task]s will be
     * invoked since this [Task] [T] replace the final version.
     *
     * @param type the [ScopedArtifact] to replace.
     * @param into lambda that returns the [Property] used by the [Task] to save the replaced
     * element. The [Property] value will be automatically set by the Android Gradle Plugin and its
     * location should not be considered part of the API and can change in the future.
     */
    fun toReplace(
        type: ScopedArtifact,
        into: (T) -> RegularFileProperty
    )
}

可以看到不光有toTransform,还有toAppendtoGettoReplace等操作,这部分具体用法和案例感兴趣的同学可以自行尝试。接下来来看看Task中的简要代码

Task

abstract class RouterClassesTask : DefaultTask() {

    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val jars: ListProperty<RegularFile>

    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val dirs: ListProperty<Directory>

    @get:OutputFile
    abstract val output: RegularFileProperty

    @get:Classpath
    abstract val bootClasspath: ListProperty<RegularFile>

    @get:CompileClasspath
    abstract var classpath: FileCollection

    @TaskAction
    fun taskAction() {
        // 输入的 jar、aar、源码
        val inputs = (jars.get() + dirs.get()).map { it.asFile.toPath() }
        // 系统依赖
        val classpaths = bootClasspath.get().map { it.asFile.toPath() }
            .toSet() + classpath.files.map { it.toPath() }
        
        ··· omit code ···

        JarOutputStream(BufferedOutputStream(FileOutputStream(output.get().asFile))).use { jarOutput ->

            jars.get().forEach { file ->
                Log.d("handling jars:" + file.asFile.absolutePath)
                val jarFile = JarFile(file.asFile)
                jarFile.entries().iterator().forEach { jarEntry ->
                    if (jarEntry.isDirectory.not() &&
                        // 针对需要字节码修改的class进行匹配
                        jarEntry.name.contains("com/xxx/xxx/xxx", true)
                    ) {
                        // ASM 自己的操作
                    } else {
                        // 不处理,直接拷贝自身到输出
                    }
                    jarOutput.closeEntry()
                }
                jarFile.close()
            }
            
            ··· omit code ···
        }
    }

   ··· omit code ···

}

看完了伪代码,相信很多同学已经知道该怎么做了。那么我们再来个简单🌰来看下我们如何适配现有的路由插件。

在开始之前,我们要知道主流的路由插件使用Transform主要是干了啥,简单概括下其实就是两大步骤:

  • 扫描依赖,收集路由表使用容器存储结果
  • 根据收集到的路由表修改字节码进行路由注册

前面的伪代码其实也是按照这两大步来做的。

示例

chenenyu/Router 为例我们来具体实现以下,至于其他类似库如:alibaba/ARouter 操作方法也类似,这部分工作就留给其他说话又好听的同学去做了。

期望结果

chenenyu/Router需要进行ASM字节码操作的类是com.chenenyu.router.AptHub,这里仅以chenenyu/RouterSample进行演示。我们先看一下使用Transform进行字节码修改后的代码是什么,可以看到通过Gradle动态的新增了一个静态代码块,里面注册了各个module的路由表、拦截器表、路由拦截器映射表等。

static {
    HashMap hashMap = new HashMap();
    routeTable = hashMap;
    HashMap hashMap2 = new HashMap();
    interceptorTable = hashMap2;
    LinkedHashMap linkedHashMap = new LinkedHashMap();
    targetInterceptorsTable = linkedHashMap;
    new Module1RouteTable().handle(hashMap);
    new Module2RouteTable().handle(hashMap);
    new AppRouteTable().handle(hashMap);
    new AppInterceptorTable().handle(hashMap2);
    new AppTargetInterceptorsTable().handle(linkedHashMap);
}

Plugin

  1. RouterPlugin代码与伪代码基本一致

Task

RouterClassesTask大部分实现与伪代码也相同。这里我们主要以说明用法为主,相应的接口设计以及优化不做处理。通俗点说,就是代码将就看~

abstract class RouterClassesTask : DefaultTask() {

    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val jars: ListProperty<RegularFile>

    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val dirs: ListProperty<Directory>

    @get:OutputFile
    abstract val output: RegularFileProperty

    @get:Classpath
    abstract val bootClasspath: ListProperty<RegularFile>

    @get:CompileClasspath
    abstract var classpath: FileCollection

    @TaskAction
    fun taskAction() {
        val inputs = (jars.get() + dirs.get()).map { it.asFile.toPath() }
        val classpaths = bootClasspath.get().map { it.asFile.toPath() }
            .toSet() + classpath.files.map { it.toPath() }
        val grip: Grip = GripFactory.newInstance(Opcodes.ASM9).create(classpaths + inputs)
        val query = grip select classes from inputs where interfaces { _, interfaces ->
            descriptors.map(::getType).any(interfaces::contains)
        }
        val classes = query.execute().classes

        val map = classes.groupBy({ it.interfaces.first().className.separator() },
            { it.name.separator() })

        Log.v(map)

        JarOutputStream(BufferedOutputStream(FileOutputStream(output.get().asFile))).use { jarOutput ->

            jars.get().forEach { file ->
                println("handling jars:" + file.asFile.absolutePath)
                val jarFile = JarFile(file.asFile)
                jarFile.entries().iterator().forEach { jarEntry ->
                    if (jarEntry.isDirectory.not() &&
                        jarEntry.name.contains("com/chenenyu/router/AptHub", true)
                    ) {
                        println("Adding from jar ${jarEntry.name}")
                        jarOutput.putNextEntry(JarEntry(jarEntry.name))
                        jarFile.getInputStream(jarEntry).use {
                            val reader = ClassReader(it)
                            val writer = ClassWriter(reader, 0)
                            val visitor =
                                RouterClassVisitor(writer, map.mapValues { v -> v.value.toSet() })
                            reader.accept(visitor, 0)
                            jarOutput.write(writer.toByteArray())
                        }
                    } else {
                        kotlin.runCatching {
                            jarOutput.putNextEntry(JarEntry(jarEntry.name))
                            jarFile.getInputStream(jarEntry).use {
                                it.copyTo(jarOutput)
                            }
                        }
                    }
                    jarOutput.closeEntry()
                }
                jarFile.close()
            }
            
            dirs.get().forEach { directory ->
                println("handling " + directory.asFile.absolutePath)
                directory.asFile.walk().forEach { file ->
                    if (file.isFile) {
                        val relativePath = directory.asFile.toURI().relativize(file.toURI()).path
                        println("Adding from directory ${relativePath.replace(File.separatorChar, '/')}")
                        jarOutput.putNextEntry(JarEntry(relativePath.replace(File.separatorChar, '/')))
                        file.inputStream().use { inputStream ->
                            inputStream.copyTo(jarOutput)
                        }
                        jarOutput.closeEntry()
                    }
                }
            }
        }
    }

    companion object {
        @Suppress("SpellCheckingInspection")
        val descriptors = listOf(
            "Lcom/chenenyu/router/template/RouteTable;",
            "Lcom/chenenyu/router/template/InterceptorTable;",
            "Lcom/chenenyu/router/template/TargetInterceptorsTable;"
        )
    }

}

需要额外说明一下的是,一般我们进行路由表收集的工作都是扫描所有classesjarsaars,找到匹配条件的class即可,这里我们引入了一个com.joom.grip:grip:0.9.1依赖,能够像写SQL语句一样帮助我们快速查询字节码。感兴趣的可以详细了解下grip的用法。

  1. 这里我们把所有依赖产物作为Input输入,然后创建grip对象。
val inputs = (jars.get() + dirs.get()).map { it.asFile.toPath() }
val classpaths = bootClasspath.get().map { it.asFile.toPath() }
    .toSet() + classpath.files.map { it.toPath() }
    
val grip: Grip = GripFactory.newInstance(Opcodes.ASM9).create(classpaths + inputs)
  1. 查询所有满足特定描述符的类。
val query = grip select classes from inputs where interfaces { _, interfaces ->
    descriptors.map(::getType).any(interfaces::contains)
}
val classes = query.execute().classes
  1. 对查询的结果集进行分类,组装成ASM需要处理的数据源。标注的separator扩展函数是由于字节码描述符中使用/,在ASM操作中需要处理为.
val map = classes.groupBy({ it.interfaces.first().className.separator() }, { it.name.separator() })

通过打印日志,可以看到路由表已经收集完成。

  1. 至此几行简单的代码即实现了字节码的收集工作,然后把上面的map集合直接交给ASM去处理。ASM的操作可以沿用之前的ClassVisitorMethodVisitor,甚至代码都无需改动。至于ASM的操作代码该如何编写,这个不在本篇的讨论范围。由于我们需要修改字节码的类肯定位于某个jar中,所以我们直接针对输入的jars进行编译,然后根据特定条件过滤出目标字节码进行操作。
JarOutputStream(BufferedOutputStream(FileOutputStream(output.get().asFile))).use { jarOutput ->

    jars.get().forEach { file ->
        val jarFile = JarFile(file.asFile)
        jarFile.entries().iterator().forEach { jarEntry ->
            if (jarEntry.isDirectory.not() &&
                jarEntry.name.contains("com/chenenyu/router/AptHub", true)
            ) {
                println("Adding from jar ${jarEntry.name}")
                jarOutput.putNextEntry(JarEntry(jarEntry.name))
                jarFile.getInputStream(jarEntry).use {
                    val reader = ClassReader(it)
                    val writer = ClassWriter(reader, 0)
                    val visitor =
                        RouterClassVisitor(writer, map.mapValues { v -> v.value.toSet() })
                    reader.accept(visitor, 0)
                    jarOutput.write(writer.toByteArray())
                }
            } else {
                kotlin.runCatching {
                    jarOutput.putNextEntry(JarEntry(jarEntry.name))
                    jarFile.getInputStream(jarEntry).use {
                        it.copyTo(jarOutput)
                    }
                }
            }
            jarOutput.closeEntry()
        }
        jarFile.close()
    }

    dirs.get().forEach { directory ->
        println("handling " + directory.asFile.absolutePath)
        directory.asFile.walk().forEach { file ->
            if (file.isFile) {
                val relativePath = directory.asFile.toURI().relativize(file.toURI()).path
                println("Adding from directory ${relativePath.replace(File.separatorChar, '/')}")
                jarOutput.putNextEntry(JarEntry(relativePath.replace(File.separatorChar, '/')))
                file.inputStream().use { inputStream ->
                    inputStream.copyTo(jarOutput)
                }
                jarOutput.closeEntry()
            }
        }
    }
}

所有需要修改的代码已经写完了,是不是很简单。最后我们来验证下是否正常。执行编译后发现字节码修改成功,且与Transform执行结果一致。至此,基本完成了功能适配工作。

总结

  • 本篇通过一些伪代码对适配 AGP 7.4.+ 的 Transform API 进行了简单说明,并通过一个示例进行了实践。
  • 实践证明,对于 Transform API 的废弃,此方案简单可用,但此方案存在一定限制,需AGP 7.4.+。
  • 相比较于TransformAction,迁移成本小且支持增量编译、缓存

示例代码已上传至Router,有需要的请查看v1.7.6分支代码。主要代码在RouterPluginRouterClassesTask