今日头条 Android ‘秒‘ 级编译速度优化

为了能够弄清楚到底有哪些子模块真正用到了 kapt ,哪些没用到可以禁用掉 kapt 相关 task ,对项目中所有子模块进行了一遍扫描:

  1. 获取 kapt configuration 的所有依赖,可以得到 kapt 依赖库的 jar 包,利用 asm 获取所有 annotation.

  2. 遍历所有 subproject 的 sourceset 下所有 .java,.kt 源文件,解析 import 信息,看是否有步骤 1 中解析的 annotation

  3. package task 完成后遍历 所有 subproject 所有 generate/apt ,generate/kapt 目录下生成的 java 文件

使用上述方案,通过全源码打包最终扫描出来大概是 70+模块不会进行任何 kapt 的实际输出,且将这些不会进行输出的 kapt,kaptGenerateStub 的 task 耗时累加起来较高 217s (由于 task 并发执行所以实际总时长可能要少一些).

获取到不实际生成 kapt 内容的模块后,开始对这些模块进行细粒度的拆分,让它们从 apply library.gradle 改为没有 kapt 相关的 library-api.gradle ,该文件除了禁用 kapt 外,与 library 逻辑一致。

但是这样做算是在背后偷偷做了些更改,很可能后续新来的同学不知道有这种优化手段,可能新增了注解后却没有任何输出且找不到原因,而优化效果最好是尽量少给业务同学带来困扰。为了避免这种情况,便对这些 library-api 模块依赖的注解做隔离优化,即:把这些模块依赖的注解库全部 自动 exclude 掉,在尝试使用注解时会因获取不到引用(如下图所示),第一时间发现到依赖被移除的问题。

另一方面在编译出现错误时,对应 gradle 插件会自动解析找不到的符号,如果发现该符号是被隔离优化的注解,会提示将 library-api 替换成 library,尽可能降低优化方案对业务的负面影响。

收益

  1. mac 全源码场景中有 58s 左右的加速收益。

  2. ci 机器上由于 cpu 核数更多 ,task 并发性能更好,只有 10s 左右的收益。

transform 优化


背景

transform 作为 Android gradle plugin 提供给开发者的 API,用于在 apk 构建过程中,对 class 字节码,resources 等文件内容进行插桩修改,例如官方的 dex, proguard 等功能均由此 api 实现。

对于今日头条这种大型工程来说,有很多诸如性能插桩、自动埋点插桩等相关需求,因此基于此 api 开发了大量 transform,用于实现特定功能,但是这些 transform 基本上都是不支持增量编译的,即使只改动了一行代码,这 些 transform 都会遍历所有 class 文件,解析字节码中的方法字段信息,关键是这类 transform 数量有十几个,将这些遍历耗时乘以 10 累加之后,增量编译耗时自然居高不下。

根据分析,其中性能插桩等相关 transform 做的一些面向线上的插桩方案是完全可以只在 release 打包时打开的,因此可以直接在 debug 编译时禁用这些功能,用于提升开发期间的编译速度。而剩下的 9 个 transform 特征比较相似,可能在一些插桩细节上有所不同,它们大致的处理逻辑为:

  1. 在各个模块中使用 apt processor 收集模块 xx 注解的 class 信息然后生成一个 xxCollect 类,该类的作用是收集好 apt 阶段解析到的本模块的类信息

  2. 将所有模块收集到的信息进行汇总,利用 transform 阶段扫描出所有的 xxCollect 信息,通过 javaassit 或者 asm 往一个 xxCollectMgr 的某个 collectXxx 方法插桩注入之前收到的信息

  3. 业务代码可通过 xxCollectMgr 的 collectXxx 方法获取到在各个模块动态生成的所有 xxCollect 信息。(例: 页面路由相关框架便是通过该逻辑收集到所有子模块的路由注册信息)

由于这 9 个自定义 transform 的功能如此类似,便决定将这些 transform 合并成一个,这样同一个文件的读写操作只执行一次,并且可以做定制化的增量编译优化。虽然公司内有类似的 transform 合并优化方案 byteX ( 已在 github 开源),但是由于今日头条项目在 debug 阶段未开启该功能,且 ByteX 做了一些诸如 ClassGrapth 的构建,对类文件做两次遍历等操作,对于实现类信息收集和信息注入 这个功能来说,byteX 显得比较重 ,于是仍然针对类信息收集注入功能这个细分场景开发了一个收敛框架。

收益

该框架完成了内部 9 种类信息收集注入相关框架的收敛,编译耗时的绝对值加速了 25s 左右,且由于提供了统一的增量缓存功能,使得改动一行代码的耗时可以从 2 分 30s 降低到 35~40s ,实现了增量编译速度大的飞跃。最关键的是将所有自定义 transform 统一管控后,后续可以做统一定制化的需求,进一步优化编译速度。

dexBuilder 优化


背景

在 Android debug 编译 过程中,最主要的耗时在 transform 上,而上文 介绍 今日头条项目自定义 transform 已经被高度优化过,剩下的 dexBuilder(将 class 转换成 dex ) ,dexMerge 等 task 耗时就成为了性能瓶颈,dexBuilder 全量编译耗时 60s 左右,增量编译耗时 22s 左右。

根据 DexArchiveBuilderTransform 关键方法 launchProcessing 里面关键一行 isDirectoryBased,如果是目录类型的输入,会根据具体变动 class 文件做增量的 dex 编译 ,但是如果是 jar 输入类型,那只要 jar 里任何一个类变动,则整个 jar 所有类都需要重执行 dex,但是由于 gradle 的依赖特性,基本上只有 app 模块是目录类的输入,其他 library 都是 jar 输入类型,对于比较大的业务模块 ,如果该模块有几千个类,那每改动一次类,就会有几千类连带重新 执行 dex 编译。

dexBuilder 增量效果量化

在优化前为了得到真正的重新执行 dex 编译的数值,做到最佳优化,设计了一套 hook dex 编译流程的方法(该方法理论上可以 hook Android gradle plugin 任意类:大致就是 hook classLoader ,提前用 asm 修改 D8DexArchiveBuilder 中的 convert 方法

通过对 D8DexArchiveBuilder 的 hook ,统计到优化前改动一行代码会连带着 24968 个类重新执行 dex 编译,增量效果非常差。

优化方案

既然 jar 输入相比于 目录输入来说增量编译效果非常差,那么可以想到 hook TransformInvocation 中的 input 方法,动态将 project 的 jar 类型输入(JarInput)映射为一个 目录输入(DirectoryInput),那么子模块修改对应代码时,只重新编译目录中被修改的 class 为 dex(而不是原来的整个 jar 内所有 class 重新执行 dex 编译),整体 dex 重新编译的数量将大幅度减少。实现具体方案如下:

  • 自动发现源码依赖的子模块 project,配置经常需要变更的注入类所在的 SDK jar

  • hook TransformInvocation 的 input 将上面步骤中的 JarInput 映射为 DirectoryInput

  • 每次 hook input 前检查与上一次需要优化的 project,sdk 是否一致,否则直接抛异常(影响增量判断)

而 jar 转 目录的映射细节为:

  • 如果是新增的 jar, 那解压该 jar 所有类文件到目录,将该目录下所有类定义为 ADD

  • 如果是移除的 jar, 检查之前解压的目录,该目录下所有类文件定义为 REMOVE

  • 如果 jar 没有变更,那定义为之前解压的目录中没有任何子文件变更 NOT_CHANGE

  • 如果 jar 有修改,需要进一步判断内容有哪些修改,如果 jar 中有的文件在 解压目录不存在,该文件定义为 ADD,如果目录有的文件在 jar 中不存在,该文件定义为 REMOVE,如果都同时存在,比较文件内容(大小,hash) ,相同定义为 NOT_CHANGED 否则为 CHANGED

在第一次增量修改完成后,重新执行 dex 编译的类数量降低至 2152 个,但是其中仍然有很多迷惑的不该执行 dex 编译的类,预期是修改多少类,就重新执行 多少次 dex,因此继续对其中原因进行进一步的探索

desugarGraph 异常

由于 java8 的字节码有些指令在 Android 虚拟机中并不能得到支持,会在编译流程中,将这些指令进行脱糖,转换成已有的指令,而 d8 中 desugar 的流程合并到了 dexBuilder 中,为了避免某些类 desugar 后,依赖它的类的行为正确,需要把依赖它的所有类重新执行一遍 dex 编译。

而 d8 会根据 DesugaringGraph 查找 desguar 有变动的类及其依赖的 jar 包,如图下面获得到的 addtionalPaths 是 desguar 类可能直接间接相关的 jar 包,即使这些 jar 包没有任何文件内容变更,其中所有类也得重新全部执行一次 dex 编译。

DesugaringGraph 逻辑概述

该类用来辅助获取依赖或间接依赖到变更文件的所有文件,而它的生成逻辑为: 全量或增量编译类的时候记录类型之间的依赖和被依赖关系,依赖关系的判断条件有

  1. 父类

  2. 直接实现的接口

  3. 调用 dynamic 方法指令时的返回类型

DesugaringGraph 不仅记录了类依赖的类,和依赖它的类,同时也记录了一个文件路径包含了哪些类

  1. 如果文件路径是 class 文件,那路径就包含 1 个类

  2. 如果路径是 jar 文件,包含这个 jar 下所有类。

在增量编译时检查到变动的文件时,会检查这个文件路径包含的所有类, 然后递归查找所有直接/间接依赖它的类,并且找到这些依赖它的类后,会把这个类所在的 jar 包作为额外的处理类型(即使 jar 本身没有任何变动,里面所有的类仍然需要重新 dex 编译)

顺着这个解析关系,找到了一个不正常的 jar 包 bdjson_api ,这个 jar 只有 3 个文件 (IBDJson,BDJsonCollector, BDJsonConstants) 。但是 BDJsonCollector 是一个 stub 类,每次执行 transform 会收集到其他类的信息然后往该类的方法中注入,因此该文件每次编译时都会变动。这个类本身并没有多少直接依赖它的类,主要是 它所在的 jar 包还有个 IBDJson 接口。

按照之前的 DesugaringGraph 依赖关系,所有 IBDJson 接口的实现类被判断为依赖它,然后这些实现类如果出现在某个 dynamic 方法中,又会被层层查找,查找完了之后,还得计算所有依赖类所在的 jar 包,jar 包中其他没有依赖它的类也会被重新 dex 编译, 在这个 case 的依赖查找中,连带重新执行 dex 编译的类数量并不多,大概为 4 个 jar 包共 2000 多个类重新执行了无意义的 dex 流程,但是如果是其他 sdk jar 包,则可能就会给 dexBuilder 增量带来毁灭性的打击。上述问题的解决方法:

  1. 把每次都会修改的 Stub 注入类和其他接口类分离,放在不同 jar 包。(需要改造业务,比较麻烦)

  2. 动态把这个 sdk jar 输入转换成目录输入。(上文介绍的方法,也与上面 jar 转目录的初衷相符,只不过是漏掉了这个 case,但是却意外证明了:除了包含业务代码多的 project 的 jar 输入需要转换为目录外,sdk jar 同样有必要)

修复后修改一行代码重新执行 dex 的数量为 10 ,其中 9 个是每次 transform 会修改的 stub 类,1 个是实际修改类。做到了真正的 改多少类,执行多次 dex 编译。

收益

assemebleDebug 的增量编译中从原来(上文 transform 优化后)的 35s~40s 是降低至均值 17s,在 fast build 中效果最明显(屏蔽了 apt),第二次增量编译能突破到 9s 实现秒级编译。

而经过上面所有的优化后,耗时数据里耗时最严重的 dexBuilder 和 dex-merge 基本都降低在 1s 左右,自定义 transform 也是 1s 左右,其他 task 基本都是零点几秒。在不使用 hotfix 方案的情况下(由于今日头条项目使用了过多的自定义 transform 和插件方案,所以不好使用 instantrun 等 hostfix 方案),相关 task 的耗时基本达到了优化的极限。

build-cache 优化踩坑


Build-cache 是 gralde 提供的一个编译缓存方案,目的是在构建过程中当两个 task 的输入相同时,可以复用缓存内容,直接跳过 task 的执行拿到缓存好的执行结果。由于缓存结果既可以放在本地磁盘,也可以从远程获取,因此容易想到利用 ci 提前 构建缓存包,在其他 ci 机器和开发时利用缓存包获得加速效果。

那么如何判断 task 可以直接获取 之前 task 的缓存内容作为输出呢?定义为可缓存的 task ,会定义一些缓存相关的属性,task 执行时通过文件指纹,缓存属性等一大堆属性计算出缓存 key ,用于查找是否命中缓存,计算维度有:

  • 输入属性(如 jvm 参数,sourceCompatibility 等参数)。涉及到 各种 ValueSnapShot(值类型快照,string,file,list,等…)计算。以及 task 实现类 classpath 相关

  • 输入文件集相关:涉及到 依赖的输入文件的 hash 计算

  • 输出属性相关

  • 不可缓存属性相关

但是原生的 build-cahce 在缓存命中率上惨不忍睹,公司内抖音团队基于 gradle4.x 的源码做过一些提高命中率的修改,不过今日头条用的 gradle 版本是 5.1 ,受抖音团队的启发,也对 gradle5.1 源码做了些定制化的修改,用于 dump 缓存 key 的计算流程,快速发现缓存问题。相比于抖音发现的一些影响缓存命中的问题,额外发现了一些诸如 mbox , kapt 元素遍历顺序不固定的问题,这里只挑一个典型的 apt 顺序不一致的问题进行介绍:

apt 顺序不一致导致的缓存失效问题

经过修改 gradle5.1 源码后对编译流程的信息采集,发现有的 task 缓存无法命中是因为 kapt 时,很多生成代码块逻辑是一样的,但是顺序不一样(如下图 demo :下面两个生成方法的逻辑一致,但是判断顺序不一致,这应该是在 processor 中通过 RoundEnviroment 获取到 注解元素 elemnts 顺序不一致导致的 )

其内部的原因可能是文件遍历目录时获取子文件的顺序不一致,导致了子文件对应注解元素的顺序也不一致, 总之这个操作影响了生成文件内代码的顺序,也影响了该文件的 hash 计算结果,导致 build-cache 在计算 javac task 的 key 时会错乱导致缓存无法命中。

解决方案

但是注意到 AbstractProcessor 的核心方法 process 的两个参数都是接口,因此想到可以代理原来的 RoundEnvironment 接口,将其 getElementXx 的方法经过固定排序后返回,使得 apt 注解元素的顺序能够固定下来。

由于篇幅影响,其他影响缓存命中相关的 case 略(主要是一些涉及到文件绝对路径, classPath 相关的问题)

收益

  1. 由于大多开发场景是引入多少模块就修改多少模块内容,很难获得命中缓存, 收益很小

  2. 主要是全源码场景能稳定获得一些编译加速,基本上在 22~99s 左右。

编译耗时防恶化管控

=========

在今日头条这种大型工程中,有很多业务部门参与开发,仅 Android 工程 开发人员就有几百人且人员变动频繁,因此内部任何一项优化工作必然是得搭配上一些管控措施的,否则一边优化一边恶化,空浪费人力。

为此制定了一些管控方案,首先是 debug 阶段的 新增 transform 管控,设置为白名单形式,如果在开发阶段新增了 transform 直接终止编译流程,通过说明文档告知管控的规则,当然,管控的目的是尽可能减少一些不必要的不合理的编译问题,并不是与业务团队作对,如果某一个操作拖慢了整体的编译耗时,但是在 app 性能/稳定性方面有更大收益,且无法在编译期做更多的优化,仍然是允许添加的,只不过是得提前把这个问题暴露出来而已,能更快的找出更多的解决思路,比如引导使用 byteX 等 transform 收敛方案。

另一方面的是合码流程方面的阻塞 :今日头条 为了保障 app 的性能稳定性,在合码流程上设置了许多自动化的卡点:如 包大小检测,插件依赖变更检查, so 变更检查,启动性能检测等,检测到对应问题(如包大小增加异常)会阻塞合码流程。为了管控编译速度 ,使其不至于恶化的太快,也加上了对应的 基于 task 级别的管控,当某一个 task 耗时异常波动,或者新增全新类型的 task 时,能够自动的发现问题,通过机器人将相关人员拉到 mr 讨论群中, 尽量在 合码完成前能发现问题。

总结

==

为了持续稳定的保持较快的编译速度,可能需要做到以下几点:

  1. 项目需要有良好的工程结构,对业务模块进行适当粒度的拆分,做好 aar/源码的切换不仅能节省 javac/kotlinCompile 的耗时,也是其他优化方案的基础。

  2. 工程配置要有区分度,不要所有子模块都用同样的配置,比如根本不会用到 kapt 功能的模块就别打开 kapt task 了。

  3. transform 若无必要,无须新加,或者按级别划分,如今日头条在 debug,devMode,release 不同的构建级别用到的 transform 数量是不一致的,尽量让绝大多数人能获得相对最快的编译速度体验,而不会被用不到的功能拖慢速度。

  4. 一定要新增的 transform 可以先多用现有的增量方案,如 byteX 以及本文提供的类信息注入框架, 尽量把不要的文件 io 合并。

  5. 很多高耗时的 官方 task(dexBuilder) 都是有直接或间接的办法提升其效率的,并且如果除了耗时之外有其他的衡量手段,如本文提到的重新 dex 率,通过量化数据可以快速的发现问题,进而找到耗时的罪魁祸首。

  6. 与 app 性能优化等工作类似,编译速度优化既需要持续进行,也需要一定的问题发现手段,尽量避免问题出现很长一段时间后再去查找原因(那时候可能业务依赖程度会非常高,难以修改)。

关于面试的充分准备

一些基础知识和理论肯定是要背的,要理解的背,用自己的语言总结一下背下来。

虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,我能明显感觉到国庆后多了很多高级职位,所以努力让自己成为高级工程师才是最重要的。

好了,希望对大家有所帮助。

接下来是整理的一些Android学习资料,有兴趣的朋友们可以关注下我免费领取方式

①Android开发核心知识点笔记

②对标“阿里 P7” 40W+年薪企业资深架构师成长学习路线图

③面试精品集锦汇总

④全套体系化高级架构视频

**Android精讲视频领取学习后更加是如虎添翼!**进军BATJ大厂等(备战)!现在都说互联网寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水!

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

寒冬,其实无非就是你上错了车,且穿的少(技能),要是你上对车,自身技术能力够强,公司换掉的代价大,怎么可能会被裁掉,都是淘汰末端的业务Curd而已!现如今市场上初级程序员泛滥,这套教程针对Android开发工程师1-6年的人员、正处于瓶颈期,想要年后突破自己涨薪的,进阶Android中高级、架构师对你更是如鱼得水!

[外链图片转存中…(img-jr6l22dD-1714580835761)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化学习资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 9
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值