WMRouter源码解析之Transform

现在有很多的框架用到APT的技术,可以很好的解耦,实现编译期生成文件或者修改class文件,实现插桩的功能,Android提供了Transform的接口,在编译期间可以拿到所有编译后的class文件和jar包(包括aar包)。 美团开源的WMRouter中就用到这项技术,在编译期找到所有的ServiceInit_XXX.class文件(包括本地主工程、子工程和依赖的aar/jar包),结合ASM生成ServiceLoaderInit.class文件,在框架运行时进行异步加载所有的Service实现。 今天重点在分析里面的Transform技术,ServiceInit_XXX.class等文件什么功能不会做介绍,这个以后的系列文章会进行阐述。

1. Transform插件开发流程

Transform其实也是可以理解成一个gradle task,是Google写的可以在这个task里面拿到安装包的所有class文件。 为了编译期间能执行该task,需要编写一个gradle plugin(这方面可以参考我之前的文章 一步步自定义Gradle插件),并且在我们的app工程的build.gradle中apply这个插件,看下实现步骤和代码。

第一步,先建一个Java Library工程,最后整体结构如下


WMRouterTransformPlugin.png

其中META-INF中的文件名是插件的名称,在app build.gradle中需要apply:

apply plugin: 'WMRouter'

WMRouter {
    enableDebug = true // 调试开关
    enableLog = true
}

看下WMRouter.properties文件的内容:

implementation-class=com.sankuai.waimai.router.plugin.WMRouterPlugin

上面就是插件的入口WMRouterPlugin,将本文的主角WMRouterTransform注册到project中,这样编译时才会被执行。

public class WMRouterPlugin implements Plugin<Project> {

    @Override
    public void apply(Project project) {
        WMRouterExtension extension = project.getExtensions()
                .create(Const.NAME, WMRouterExtension.class);

        WMRouterLogger.info("register transform");
        project.getExtensions().findByType(BaseExtension.class)
                .registerTransform(new WMRouterTransform());

        project.afterEvaluate(p -> WMRouterLogger.setConfig(extension));
    }
}

2. Transform源码

先看下其父类Transform的源码,需要实现下面四个抽象方法:

public abstract class Transform {

    /**
     * Returns the unique name of the transform.
     *
     * <p>This is associated with the type of work that the transform does. It does not have to be
     * unique per variant.
     */
    @NonNull
    public abstract String getName();

    /**
     * Returns the type(s) of data that is consumed by the Transform. This may be more than
     * one type.
     *
     * <strong>This must be of type {@link QualifiedContent.DefaultContentType}</strong>
     */
    @NonNull
    public abstract Set<ContentType> getInputTypes();


    /**
     * Returns the scope(s) of the Transform. This indicates which scopes the transform consumes.
     */
    @NonNull
    public abstract Set<? super Scope> getScopes();


    /**
     * Returns whether the Transform can perform incremental work.
     *
     * <p>If it does, then the TransformInput may contain a list of changed/removed/added files, unless
     * something else triggers a non incremental run.
     */
    public abstract boolean isIncremental();


    /**
     * @deprecated replaced by {@link #transform(TransformInvocation)}.
     */
    @Deprecated
    @SuppressWarnings("UnusedParameters")
    public void transform(
            @NonNull Context context,
            @NonNull Collection<TransformInput> inputs,
            @NonNull Collection<TransformInput> referencedInputs,
            @Nullable TransformOutputProvider outputProvider,
            boolean isIncremental) throws IOException, TransformException, InterruptedException {
    }
}

解释如下:

  1. getName就是这个task的名字,在编译时可以看到,比如打debug包是有下面的task::demoapp:transformClassesWithWMRouterForDebug
  2. getInputTypes,声明我们感兴趣的文件类型,这里就是class,还可以有jar,dex, resource
public static final Set<ContentType> CONTENT_CLASS = ImmutableSet.of(CLASSES);
public static final Set<ContentType> CONTENT_JARS = ImmutableSet.of(CLASSES, RESOURCES);
public static final Set<ContentType> CONTENT_RESOURCES = ImmutableSet.of(RESOURCES);
public static final Set<ContentType> CONTENT_NATIVE_LIBS =
ImmutableSet.of(NATIVE_LIBS);
public static final Set<ContentType> CONTENT_DEX = ImmutableSet.of(ExtendedContentType.DEX);
public static final Set<ContentType> CONTENT_DEX_WITH_RESOURCES =
ImmutableSet.of(ExtendedContentType.DEX, RESOURCES);
public static final Set<ContentType> DATA_BINDING_BASE_CLASS_LOG_ARTIFACT =
ImmutableSet.of(ExtendedContentType.DATA_BINDING_BASE_CLASS_LOG);
  1. getScopes, 声明该task作用的范围,一般常用的是这些,我们这里声明作用范围包括主工程,子工程和外部依赖包
enum Scope implements ScopeType {
        /** Only the project (module) content */
        PROJECT(0x01),
        /** Only the sub-projects (other modules) */
        SUB_PROJECTS(0x04),
        /** Only the external libraries */
        EXTERNAL_LIBRARIES(0x10),
...
}

public static final Set<Scope> SCOPE_FULL_PROJECT =
      Sets.immutableEnumSet(
              Scope.PROJECT,
              Scope.SUB_PROJECTS,
              Scope.EXTERNAL_LIBRARIES);
  1. isIncremental,是否支持增量编译,这里一般返回false
  2. 主要工作在最后一个方法transformz中,通过入参TransformInvocation可以拿到工程文件。下面就具体看下WMRouterTransform的该方法实现。

3. WMRouterTransform

目的是扫描com.sankuai.waimai.router.generated.service目录下的class文件,这些class文件也是通过编译时生成,这个本文先不介绍。

扫描到的文件名称保存到initClasses容器下。

invocation有两种输入类型,一种是依赖包jarInput(包括aar),一种是目录directoryInput

// WMRouterTransform.java
@Override
public void transform(TransformInvocation invocation) {
        WMRouterLogger.info(TRANSFORM + "start...");
        long ms = System.currentTimeMillis();

        Set<String> initClasses = Collections.newSetFromMap(new ConcurrentHashMap<>());

        for (TransformInput input : invocation.getInputs()) {
            input.getJarInputs().parallelStream().forEach(jarInput -> {
                File src = jarInput.getFile();
                File dst = invocation.getOutputProvider().getContentLocation(
                        jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(),
                        Format.JAR);
                try {
                    scanJarFile(src, initClasses);
                    FileUtils.copyFile(src, dst);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            input.getDirectoryInputs().parallelStream().forEach(directoryInput -> {
                File src = directoryInput.getFile();
                File dst = invocation.getOutputProvider().getContentLocation(
                        directoryInput.getName(), directoryInput.getContentTypes(),
                        directoryInput.getScopes(), Format.DIRECTORY);
                try {
                    scanDir(src, initClasses);
                    FileUtils.copyDirectory(src, dst);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        }
        File dest = invocation.getOutputProvider().getContentLocation(
                "WMRouter", TransformManager.CONTENT_CLASS,
                ImmutableSet.of(QualifiedContent.Scope.PROJECT), Format.DIRECTORY);
        generateServiceInitClass(dest.getAbsolutePath(), initClasses);

        WMRouterLogger.info(TRANSFORM + "cost %s ms", System.currentTimeMillis() - ms);
    }

打个断点看了下jarInput打出来的组成,该jarInput是依赖的子工程(scopes:SUB_PROJECT) demolib2(name=:demolib2),里面的文件类型是CLASSES

WMRouterTransform1.png

而编译后的文件包在/demolib2/build/intermediates/intermediate-jars/debug/classes.jar
看下目录截图

jarinput directory.png

这里面就能拿到子工程demolib2中的指定文件,再接着源码往下看.

src就是上面编译后的classes.jar,而每次transform修改后的文件要复制到指定输出目录,否则下一个transform或者task就拿不到文件。

而这里dst就是输出目录,在上面的jarinput中可以看到dst=''WMRouter/demoapp/build/intermediates/transforms/WMRouter/debug/17.jar''

// WMRouterTransform.java
input.getJarInputs().parallelStream().forEach(jarInput -> {
    File src = jarInput.getFile();
    File dst = invocation.getOutputProvider().getContentLocation(
            jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(),
            Format.JAR);
    try {
        scanJarFile(src, initClasses);
        FileUtils.copyFile(src, dst);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
});

再看下scanJarFile(src, initClasses):

在子工程demolib2的目标目录com.sankuai.waimai.router.generated.service目录下有两个ServiceInit_xxx文件,把这两个文件添加到initClasses保存。

WMRouterTransform0.png

而对于工程里的另外一个子工程demolib1也是同样的逻辑,输出目录在

dst=''WMRouter/demoapp/build/intermediates/transforms/WMRouter/debug/18.jar''

WMRouterTransform2.png

再来看下主工程的目录文件扫描过程,结合图片会比较好理解,是directoryInput打出来的组成,该directoryInput是主工程工程(scopes:PROJECT),里面的文件类型是CLASSES

input.getDirectoryInputs().parallelStream().forEach(directoryInput -> {
    File src = directoryInput.getFile();
    File dst = invocation.getOutputProvider().getContentLocation(
            directoryInput.getName(), directoryInput.getContentTypes(),
            directoryInput.getScopes(), Format.DIRECTORY);
    try {
        scanDir(src, initClasses);
        FileUtils.copyDirectory(src, dst);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
});
WMRouterTransform3.png

源路径src=WMRouter/demoapp/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes,输出路径和前面两个子工程在同一个目录下,包名不一样是22.jar

看下源路径的截图:

WMRouterTransformApp.png

再回到源码,循环扫描完成后就是需要生成目标文件了,生成的目标文件位置在dest="WMRouter/demoapp/build/intermediates/transforms/WMRouter/debug/23",文件名是ServiceLoaderInit.class

File dest = invocation.getOutputProvider().getContentLocation(
      "WMRouter", TransformManager.CONTENT_CLASS,
      ImmutableSet.of(QualifiedContent.Scope.PROJECT), Format.DIRECTORY);
generateServiceInitClass(dest.getAbsolutePath(), initClasses);

WMRouterLogger.info(TRANSFORM + "cost %s ms", System.currentTimeMillis() - ms);
WMRouterTransform4.png

打开看一下历经千辛万苦生产的目标文件长啥样,就是主工程,子工程,依赖包下的所有复合指定包名和后缀的ServiceInit_xxx文件。

package com.sankuai.waimai.router.generated;

import xxx

public class ServiceLoaderInit {
    public static void init() {
        ServiceInit_aea7f96d0419b507d9b0ef471913b2f5.init();
        ServiceInit_f3649d9f5ff15a62b844e64ca8434259.init();
        ServiceInit_eb71854fbd69455ef4e0aa026c2e9881.init();
        ServiceInit_b57118238b4f9112ddd862e55789c834.init();
        ServiceInit_f1e07218f6691f962a9f674eb5b4b8bd.init();
        ServiceInit_4268a3e74040533ba48f2e1679155468.init();
        ServiceInit_e694d982fb5d7a3a8c6b7085829e74a6.init();
        ServiceInit_ee5f6404731417fe1433da40fd3c9708.init();
        ServiceInit_9482ef47a8cf887ff1dc4bf705d5fc0a.init();
        ServiceInit_36ed390bf4b81a8381d45028b37cc645.init();
    }
}

再看下生成文件的操作generateServiceInitClass(dest.getAbsolutePath(), initClasses);

4. ASM

先上源码,

首先构造一个ClassWriter,通过它后面写入到文件

再通过ClassVisitor来构造类ServiceLoaderInit,父类默认Object

通过MethodVisitor来构造init方法,方法体里面内容通过遍历classes调用visitMethodInsn生成

最后通过FileOutputStream写出到文件ServiceLoaderInit.class

private void generateServiceInitClass(String directory, Set<String> classes) {

    if (classes.isEmpty()) {
        WMRouterLogger.info(GENERATE_INIT + "skipped, no service found");
        return;
    }

    try {
        WMRouterLogger.info(GENERATE_INIT + "start...");
        long ms = System.currentTimeMillis();

        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, writer) {
        };
        String className = Const.SERVICE_LOADER_INIT.replace('.', '/');
        cv.visit(50, Opcodes.ACC_PUBLIC, className, null, "java/lang/Object", null);

        MethodVisitor mv = cv.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
                Const.INIT_METHOD, "()V", null, null);

        mv.visitCode();

        for (String clazz : classes) {
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, clazz.replace('.', '/'),
                    "init",
                    "()V",
                    false);
        }
        mv.visitMaxs(0, 0);
        mv.visitInsn(Opcodes.RETURN);
        mv.visitEnd();
        cv.visitEnd();

        File dest = new File(directory, className + SdkConstants.DOT_CLASS);
        dest.getParentFile().mkdirs();
        new FileOutputStream(dest).write(writer.toByteArray());

        WMRouterLogger.info(GENERATE_INIT + "cost %s ms", System.currentTimeMillis() - ms);

    } catch (IOException e) {
        WMRouterLogger.fatal(e);
    }
}

5.总结

在整个WMRouterTransform中都有通过日志来打印一些关键节点,看下整个编译过程的日志。

首先是在plugin入口调用的[WMRouter] register transform,

接下来会分别编译子工程router->demolib2->demokotlin->demolib1->demoapp 的compileDebugJavaWithJavac,这里会得到class文件

最后执行:demoapp:transformClassesWithWMRouterForDebug,分别找到10个ServiceInitClass文件,

生成ServiceLoaderInit.class文件耗时19ms,整个Transform过程耗时188ms

最后通过:demoapp:transformClassesWithDexBuilderForDebug打dex包

Executing tasks: [:demoapp:assembleDebug] in project 

[WMRouter] register transform
...
:router:compileDebugJavaWithJavac UP-TO-DATE
:router:processDebugJavaRes NO-SOURCE
:router:transformClassesAndResourcesWithPrepareIntermediateJarsForDebug UP-TO-DATE
...
:demolib2:compileDebugJavaWithJavac
:demolib2:processDebugJavaRes NO-SOURCE
:demolib2:transformClassesAndResourcesWithPrepareIntermediateJarsForDebug
:demokotlin:kaptGenerateStubsDebugKotlin
:demokotlin:kaptDebugKotlin
:demokotlin:compileDebugKotlin
:demokotlin:prepareLintJar UP-TO-DATE
:demokotlin:generateDebugSources UP-TO-DATE
:demokotlin:javaPreCompileDebug
:demokotlin:compileDebugJavaWithJavac
:demokotlin:processDebugJavaRes NO-SOURCE
:demokotlin:transformClassesAndResourcesWithPrepareIntermediateJarsForDebug
:demolib1:generateDebugBuildConfig
...
:demolib1:compileDebugJavaWithJavac
:demolib1:processDebugJavaRes NO-SOURCE
:demolib1:transformClassesAndResourcesWithPrepareIntermediateJarsForDebug UP-TO-DATE
:demoapp:javaPreCompileDebug UP-TO-DATE
...
:demoapp:compileDebugJavaWithJavac
...
:demoapp:transformClassesWithWMRouterForDebug
[WMRouter] Transform: start...
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_9482ef47a8cf887ff1dc4bf705d5fc0a
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_4268a3e74040533ba48f2e1679155468
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_ee5f6404731417fe1433da40fd3c9708
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_f1e07218f6691f962a9f674eb5b4b8bd
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_aea7f96d0419b507d9b0ef471913b2f5
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_f3649d9f5ff15a62b844e64ca8434259
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_e694d982fb5d7a3a8c6b7085829e74a6
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_eb71854fbd69455ef4e0aa026c2e9881
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_36ed390bf4b81a8381d45028b37cc645
[WMRouter]     find ServiceInitClass: com.sankuai.waimai.router.generated.service.ServiceInit_b57118238b4f9112ddd862e55789c834
[WMRouter] GenerateInit: start...
[WMRouter] GenerateInit: cost 19 ms
[WMRouter] Transform: cost 188 ms
:demoapp:transformClassesWithDexBuilderForDebug
...


BUILD SUCCESSFUL in 21s
107 actionable tasks: 40 executed, 67 up-to-date
w: Detected multiple Kotlin daemon sessions at build/kotlin/sessions

通过上面所有几个步骤就把WMRouter中的Transform技术给说清楚了,没错就是为了生成一个文件费这么大周章。但是也有好处,主app没有强依赖子工程或者依赖包的具体服务,这个技术也可以用于主app分发生命周期,只需要在Application的生命周期中调用生成文件的方法即可。另外其实通过传统的ServiceLoader也可以,但是有一个缺点就是需要运行时去IO读取文件再反射构造调用,而通过这种方式编译期生成文件就可以避免掉IO读取接口文件的步骤,性能是比较好的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345