奇门遁甲之Transform API

奇门遁甲之字节码与JVM指令
奇门遁甲之ASM操纵字节码
奇门遁甲之Transform API

函数插桩技术是可以提高开发者开发效能的有力工具。常用的组合是TransformApi+ ASM,在打包apk的过程中,对特定的类最修改,偷梁换柱,以满足我们的一些特殊需要,如全局监控网络、计算方法耗时、组件化中的路由收集,自动加埋点等。
但是使用中对Transform API 的理解一直不是很到位,如Transform是在apk打包apk的哪个环节生效,Transfrom的边界是哪里?

本文就上面这些问题 做一些梳理和总结。

一、Transform API 的常规用法

Transform API 是gradle 1.5.0 开始引入的,它允许第三方插件在编译后的类文件转换为dex文件之前做处理操作. Transform API 可以让我们聚焦在如何对输入的类文件进行处理,而不用关系AppPlugin的编译流程。

1.1、注册Transform

使用Transform 只需要注册一个Plugin,在Apply方法中,在AppExtension对象上调用registerTransform() 将自定义Transform添加进去就可以了。

class TrPlugin : Plugin<Project> {
    override fun apply(project: Project) {

        var isApp =
            project.plugins.hasPlugin(AppPlugin::class.java) //是否引入了com.android.application 插件
        TrLogger.setLogger(project.logger)
        if (isApp) {
            //注册Transform
            val android = target.extensions.findByType(AppExtension::class.java)
            android?.registerTransform(CostTransform())

        }
    }

}

AppExtension 是实际上对应build.gradle中的android{}标签

AppExtension 集成自BaseExtension,可见注册Transform仅是将Transform对象 加入到了AppExtension的transforms容器中.

1.2、TransForm 的主要API

Transform的常用API 如下

public abstract class Transform {
    public abstract String getName();
    public abstract Set<ContentType> getInputTypes();
    public abstract Set<? super Scope> getScopes();
    public abstract boolean isIncremental();
    fun transform(transformInvocation:TransformInvocation) 
}

1.2.1、name :Transform的唯一名称。

Transform 最终会被封装成一个TransformTask,TransformTask的名称并不与Transform完全一致。

TransformTask的名称格式如下:

transform+"InputType"+With+"TransformName"+For+"BuildType"

 static String getTaskNamePrefix(Transform transform) {
        StringBuilder sb = new StringBuilder(100);
        sb.append("transform");
        sb.append((String)transform.getInputTypes().stream().map((inputType) -> {
            return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, inputType.name());
        }).sorted().collect(Collectors.joining("And"))).append("With").append(StringHelper.capitalize(transform.getName())).append("For");
        return sb.toString();
    }

如自定义的Transform 名称是Cost,对应的TransformTask的名称可能为:transformClassesWithCostForDebug

1.2.2、getInputTypes:transform 要处理的数据类型

我们可以用的只有两个:

  • CLASSES 表示要处理编译后的字节码,CLASSES已经包含了class文件和jar文件

  • RESOURCES 表示的是啥???? 没搞清楚,此处待定。

1.2.3、getScopes 表示transform 的作用域

type Des
PROJECT 只处理当前项目
SUB_PROJECTS 只处理子项目
PROJECT_LOCAL_DEPS 只处理当前项目的本地依赖,例如jar, aar
EXTERNAL_LIBRARIES 只处理外部的依赖库
PROVIDED_ONLY 只处理本地或远程以provided形式引入的依赖库
TESTED_CODE 测试代码

1.2.4、tranform方法

transform() 是Tranform进行数据处理的地方。

image

TransForm是链式调用的,如上图所示,TransformB的输入 是TransformA的输出,TransformB的输出同时也是TransformC的输入。

所以Transform.transform()方法 即使任何功能不实现,也需要完成一个将文件从input目录拷贝到output目录的动作,否则下一个Transform将会丢失待处理的文件(class或jar)。

加入Transform的名字为Cost,项目编译之后,在build/intermediates/transform目录下就会出现Cost目录,该目录就是Cost Transform的输出目录,同时也是下一级Tranform的输入目录。


image

Cost目录目录之下还会生成一个content.json ,类似一个文件清单的样子。

[{
    "name": "org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.3.72_7b6c9b0015ab57b3a6475f5627bb94c0",
    "index": 0,
    "scopes": ["EXTERNAL_LIBRARIES"],
    "types": ["CLASSES"],
    "format": "JAR",
    "present": true
}, {
    "name": "androidx.core:core-ktx:1.3.2_004fa720a5219b591486b877aa0fab1c",
    "index": 1,
    "scopes": ["EXTERNAL_LIBRARIES"],
    "types": ["CLASSES"],
    "format": "JAR",
    "present": true
}
...
]

在完成class和jar文件从input目录拷贝到output的基础之上,可以完成一些额外的处理操作,如利用ASM 对特定类进行修改。

  override fun transform(transformInvocation: TransformInvocation?) {
        //TransformInput 包含两个类型的输入:jar文件和文件夹
        transformInvocation?.inputs?.forEach { input ->

            //jar输入,它代表着以jar包方式参与项目编译的所有本地jar包或远程jar包,
            input.jarInputs.forEach { jarInput ->

                //输入文件名
                val destName = jarInput.name.let {
                    //jar文件去掉.jar后缀
                    if (it.endsWith(".jar")) it.substring(0, it.length - 4) else it

                }
                //确定输出文件名
                val finalDestName = "${destName}_${DigestUtils.md5Hex(jarInput.file.absolutePath)}"
                //确定输出文件
                val destFile = transformInvocation.outputProvider.getContentLocation(
                    finalDestName,
                    jarInput.contentTypes,
                    jarInput.scopes,
                    Format.JAR
                )

                //(1) 此处完成对jar文件的额外处理

                //通用操作,将jar文件 从输入copy到输出目的地
                FileUtils.copyFile(jarInput.file, destFile)
            }

            //目录输入,它代表着以源码方式参与项目编译的所有目录结构及其目录下的源码文件
            input.directoryInputs.forEach { directoryInput ->

                //(2)此处可以完成对class文件的额外处理操作

                //确定输出文件des
                val dest: File = transformInvocation.getOutputProvider().getContentLocation(
                    directoryInput.name,
                    directoryInput.contentTypes,
                    directoryInput.scopes,
                    Format.DIRECTORY
                )
                //完成从source到dest的拷贝操作
                TrLogger.e("DirectoryInput:${directoryInput.file.absolutePath},dest:${dest.absolutePath}")
                FileUtils.copyDirectory(directoryInput.file, dest)
            }

        }
    }

二、Tranform 是如何工作的

2.1、AppPlugin

Android项目build.gradle中通常会引入application插件

apply plugin: 'com.android.application'

com.android.application 其实是Gradle内置的一个用于构建apk的gradle插件,对应AppPlugin.class,它负责完成apk整个构建过程。

2.2、Extention

Plugin的入口函数Apply()接收一个参数Project,Project代表运行该插件的项目.

project.extensions.getByType(AppExtension::class.java)

Project中可以注册一些可以供用户个性化配置的信息,称作Extention,通过Extension用户向Plugin插件传递参数。
Extention通过Project.ExtensionContainer进行维护,支持通过名称、类名查找,支持新增Extention

public interface Project extends Comparable<Project>, ExtensionAware, PluginAware {
        ExtensionContainer getExtensions();
}

AppExtension是AppPlugin会默认创建的一个Extension

//注册AppExtention,取名为android
project.getExtensions()
                .create(
                        "android",//指定extension的名称
                        AppExtention(),
                        project,
                        projectOptions,
                        globalScope,
                        sdkHandler,
                        buildTypeContainer,
                        productFlavorContainer,
                        signingConfigContainer,
                        buildOutputs,
                        sourceSetManager,
                        extraModelInfo,
                        isBaseApplication);

AppExtension 对我们其实并不陌生,它实际上就是build.gradle中的android

android {
    compileSdkVersion 29
    buildToolsVersion "30.0.2"

    defaultConfig {
        applicationId "com.sogou.iot.trplugin"
        minSdkVersion 16
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

通过AppExtension 我们可以告诉AppPlugin,我们所使用的compileSdkVersion、buildToolsVersion、buildTypes、Flavor 等等。

此外我们在自定义Plugin中,也可以为Project注册Extention,来供用户传递参数。

注册Extension

class TrPlugin : Plugin<Project> {
    override fun apply(project: Project) {

        var isApp =
            project.plugins.hasPlugin(AppPlugin::class.java) //是否引入了com.android.application 插件
        TrLogger.setLogger(project.logger)
        if (isApp) {

      
            //注册Extension
            project.extensions.create(PluginHolder.componentExt, ComponentExtension::class.java)

            project.extensions.getByType(AppExtension::class.java)?.apply {
                registerTransform(CostTransform())
            //扫描,收集类信息
            }

        }
    }

}

open class ComponentExtension {
    //待搜集的接口类
    var matchInterfaceType: String = ""

    //Container容器类
    var matchInjectManagerType: String = ""

    //容器类的
    var matchInjectManagerInjectMethod: String = ""

    var openLog:Boolean = false

    var logLevel:Int = TrLogger.LogLevelDebug
}

build.gradle 设置componentExt参数

componentExt{
    matchInterfaceType =  "com.sogou.iot.trplugin.IComponent"
    matchInjectManagerType  = "com.sogou.iot.trplugin.ComponentManager"
    matchInjectManagerInjectMethod = "initComponet"
    openLog = true
    logLevel = TrLogger.LogLevelDebug
}

插件中提取参数

(project?.extensions?.getByName(componentExt) as ComponentExtension).openLog

讲了这么多,Extension和Transform有什么关系呢?上面有点扯远了,下面进入正题。

AppExtension继承BaseExtension,BaseExtension中有一个Transform类型的数组

public abstract class BaseExtension implements AndroidConfig {
    private final List<Transform> transforms = Lists.newArrayList();
}

所以registerTransform()注册Transform 实际上是把Tranform对象加入到transforms数组中,以便后面构建TransformTask时使用。

## BaseExtension.class 
    public void registerTransform(@NonNull Transform transform, Object... dependencies) {
        transforms.add(transform);
        transformDependencies.add(Arrays.asList(dependencies));
    }

2.3、自定义TransformTask的边界在哪里

上面提到一个问题:Transfrom 工作在apk构架的哪一环节?它的边界再哪里?

我们看一下Apk的整体构建流程:


image

我们自定义的Transform 是在javac将java文件编译成.cass文件之后。也就是上图中第4步dex的过程。

dex过程内部又包含了一些列的TrasformTask,完整TransformTask链如下图所示:


dex完整流程
  • jacoco 是用于统计代码覆盖率的Task,在isTestCoverageEnabled= true时会加入jacoco Task
  • desuger 脱糖处理,将Java8 的特性语法糖(如lamda表达式) 替换为java 7中的标准语言,以实现对仅支持java7的编译工具的兼容.
android.enableD8.desugaring = false时,会加入desuger TranfromTask
  • MergeJavaRes:合并资源,处理lib/目录下的aar和so文件
Transform to merge all the Java resources.
  • 自定义Transfrom,假设加入了两个自定义的Transform:Cost和Scan
  • MergeClass:将class文件合并成jar
A transform that takes the FULL_PROJECT's CLASSES streams and merges the class files into a single jar.
  • AdvancedProfiling 可选
  • Proguard:混淆和去除无用代码
  • PreColdSwap:可选,好像和InstanceRun有关,没弄太明白。
/**
 * Task to disable execution of the InstantRun slicer, dexer and packager when they are not needed.
 *
 * <p>The next time they run they will pick up all intermediate changes.
 *
 * <p>With multi apk (N or above device) resources are packaged in the main split APK. However when
 * a warm swap is possible, it is not necessary to produce immediately the new main SPLIT since the
 * runtime use directly the resources.ap_ file. However, as soon as an incompatible change forcing a
 * cold swap is triggered, the main APK must be rebuilt (even if the resources were changed in a
 * previous build).
 */
  • D8MainDexList 可选,通过D8计算哪些类应该加入到主Dex中
Calculate the main dex list using D8.
  • Dex: 将class文件生成dex文件
  • ResourcesShrinker:资源压缩,可选。
  • DexSplitter:拆分Dex为多个,可选。
/**
 * Transform that splits dex files depending on their feature sources
 */

可以看到Dex的过程有非常多的系统Transform非常复杂,这些Transform有些是在特定条件才添加进Transfrom链中的。
假设我们自定义的Transform 处理的InputType 为TransformManager.CONTENT_CLASS (class和jar),去掉哪些可有可无的系统Transform流程就会清晰很多。如下:


dex简化流程

假设我们定义了两个Transform(Cost和Scan),那么

  • Cost Transform的上一个Task为Javac,Cost的input目录为app/build/intermediates/javac,Cost的输出目录为app/build/intermediates/Cost
  • Scan Transform的上一个Task为Cost,Scan的输入为app/build/intermediates/Cost,Scan的输出为app/build/intermediates/Scan

2.4 源码

上面Transform的构建流程,可以参考AppPlugin和TaskManager

AppPlugin启动时调用apply()方法,主要做了三件事情:

// 配置项目,设置构建回调
this::configureProject
// 配置Extension
this::configureExtension
// 创建任务
this::createTasks
  • configureProject 做的事情,主要是进行版本有效性的判断,创建了 AndroidBuilder 对象,并设置了构建流程的回调来处理依赖和dex的加载和缓存清理
  • configureExtension 方法的作用,主要是创建 AppExtention扩展对象, 创建taskManager。
  • createTasks 主要是通过taskManager等工具,构建编译任务。

TaskManager是构建任务Task的大管家,它负责组织编译任务,其中就包括Transform的任务链。

##TaskMapager.java
protected void createCompileTask(@NonNull VariantScope variantScope) {
        //(1)创建Javac Task
        TaskProvider<? extends JavaCompile> javacTask = createJavacTask(variantScope);
        addJavacClassesStream(variantScope);
        setJavaCompilerTask(javacTask, variantScope);
        //(2)构建Javac之后的TransformTask任务链
        createPostCompilationTasks(variantScope);
    }
  • 在添加javac Task任务之后,调用了createPostCompilationTasks()方法
  • 了createPostCompilationTasks()中完成了Transform任务链的构建

2.5 小结

TransformTask位于Javac Task之后,主要职责是完成生成dex文件。TransformTask会组成一个"Dex任务链"。自定义的Transform 会插入到任务链的最前面,而DexTransform位于"Dex任务链"的末尾。

所以自定义的Transform 仅能在javac 将java文件编译成class文件之后,在class文件转换成dex之前做一些class处理操作,其输入是class和jar,输出也是class和jar。

其他

相关知识点:

三、Transform的优化

Tranform的增量编译和并发编译可以参照 一起玩转Android项目中的字节码 一文

四、其他

4.1、常用名字解释

  • D8相关 用于替代dx工具的, 职责是 将class文件转化成dex文件
  • Proguard 压缩与优化(minification、shrinking、optimization)部分的替代品,依然使用与Proguard一样的keep规则。

4.2、如何查看gradle Task执行时间

./gradlew clean assembleDebug --profile

profile参数 可以查看Gradle 编译各阶段 各任务的耗时,并生成一个Profile网页

See the profiling report at: file:///Users/feifei/Desktop/TM/Demo/TrPlugin/build/reports/profile/profile-2021-01-26-10-10-14.html
image

4.3、Transform+ASM实践

可参照trplugin:
利用函数插桩实现了自定计算方法耗时,自动收集组件信息等功能。

五、参考文章

https://juejin.cn/post/6844903829671002126

https://cloud.tencent.com/developer/article/1378925

AppPlugin源码分析

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

推荐阅读更多精彩内容