本文Demo地址:Router

前语

在 AGP 7.2 中,谷歌抛弃了Android开发过程非常常用的Transform API,具体信息能够检查Android Gradle 插件 API 更新。

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

能够看到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的变动。常见的如

  • didi/booster
  • bytedance/ByteX

此次 Transform API 将在 AGP 8.0 移除,这一改动关于现在一些常用的类库、插件都将面对一个适配的问题,常见的如路由、服务注册、字符串加密等插件都广泛运用了Transform API。那么终究该怎样处理此类适配问题找到平替计划呢?本篇将讨论现在干流的一些观念是否能够满意需求以及怎么真实的做到适配。

干流观念

当你测验处理此问题时,一通检索根本会得到两种不同的见解,现在也有一些同学针对这两个API进行了探究。

  • AsmClassVisitorFactory

    • 你的插件想适配Transform Action? 或许还早了点 –
  • TransformAction

    • 为什么说TransformAction不是Transform的替代品 –

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

AsmClassVisitorFactory

首要来看看AsmClassVisitorFactory

AsmClassVisitorFactory是没有办法做到像Transform一样,先扫描一切class搜集成果,再履行ASM修正字节码。原因是AsmClassVisitorFactory在isInstrumentable办法中确定需求对哪些class进行ASM操作,当返回true之后,就履行了createClassVisitor办法进行字节码操作去了,这就导致或许你路由表都还没搜集完成就去修正了方针class

机灵的小伙伴或许会想,那我再注册一个搜集路由表的AsmClassVisitorFactory,然后在注册一个真实履行ASM操作的AsmClassVisitorFactory不就好了,那么这种做法能够吗,其实在你的插件想适配Transform Action? 或许还早了点 – 这边文章里现已给出了答案。

TransformAction

已然 AsmClassVisitorFactory 不能打,那 TransformAction 能打不,咱们来看下AGP中的完成。

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

能够看到是有相关ASM完成的。TransformAction 的运用现在较少,首要常见的有 JetifyTransform、AarTransform等,首要做产品转化。但 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/Router的Sample进行演示。咱们先看一下运用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() })

经过打印日志,能够看到路由表现已搜集完成。

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

  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履行成果一致。至此,根本完成了功能适配作业。

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

总结

  • 本篇经过一些伪代码对适配 AGP 7.4.+ 的 Transform API 进行了简略阐明,并经过一个示例进行了实践。
  • 实践证明,关于 Transform API 的抛弃,此计划简略可用,但此计划存在必定约束,需AGP 7.4.+。
  • 相比较于TransformAction,搬迁成本小且支持增量编译、缓存

示例代码已上传至Router,有需求的请检查v1.7.6分支代码。首要代码在RouterPlugin和RouterClassesTask