背景
gradle 工程往往会在 repositories 中添加一些列的 maven 仓库地址,作为组件依赖获取的查找路径,早期在今日头条的项目中配置了十几个 maven 的地址,但是依赖获取是按照 maven 仓库配置的顺序依次查找的,如果某个组件存在于最后一个仓库中,那前面的十几个仓库得依次发起网络请求查找,并在网络请求返回失败后才查找下一个,如果项目中大多组件都在较后仓库的位置,累加起来的查找时间就会很长。
优化方案
-
使用公司内部搭建的 maven 私服,在私服上设置代理仓库,为其他仓库配置代理(例如 google、jcenter、mavenCentral 等仓库),代理仓库创建好后,在 Negative Cache 配置项中关闭其 cache 开关:如果查找时没有找到某版本依赖库时会缓存失败结果,一段时间内不会重新去 maven 仓库查找对应依赖库,即使 maven 仓库中已经有该版本的依赖库,查找时仍然返回失败的结果。
-
建立仓库组,将所有仓库归放到一个统一的仓库组里,依赖查找时只需要去这个组仓库中查找,这样能大大降低多次发起网络请求遍历仓库的耗时。
模块 aar 化
背景
今日头条项目进行了多次组件化和模块化的重构,分拆出了 200 多个子模块,这些子模块如果全都 include 进项目,那么在 clean build 的时候,所有子模块的代码需要重新编译,而对于大多数开发人员来说,基本上只关心自己负责的少数几个模块,根本不需要改动其他模块的代码,这些其他 project 的配置和编译时间就成为了不必要的代价。
优化方案
对于以上子模块过多的解决方案是:将所有模块发布成 aar ,在项目中全部默认通过 maven 依赖这些编译好的组件,而在需要修改某个模块时,通过配置项将该模块的依赖形式改为源码依赖,做到在编译时只编译改动的模块。但是这样做会导致模块渐渐的又全部变为源码依赖的形式,除非规定每次修改完对应模块后,开发人员自己手动将模块发布成 aar ,并改回依赖形式。这种严重依赖开发人员自觉,并且在模块数量多依赖关系复杂的时候会显得异常繁琐,因此为了开发阶段的便利,设计了一整套更完整细致的方案:
-
开发时,从主分支拉取的代码一定是全 aar 依赖的,除了 app 模块没有任何子模块是源码引入。
-
需要修改对应模块时,通过修改 local.properties 里的 INCLUDES 参数指定源码引入的模块。
-
开发完成后,push 代码至远端,触发代码合并流程后,在 ci 预编译过程与合码目标分支对比,检测修改的模块,将这些模块按照依赖关系依次发布成 aar ,并在工程中修改依赖为新版本的 aar, 这一步保证了每次代码合入完成后,主分支上的依赖都是全 aar 依赖的。
收益
通过上述改造,将源码模块切换成 aar 依赖后,clean build 耗时从 7,8 分钟降低至 4,5 分钟,收益接近 50%,效果显著。
增量 java/kotlin 编译
背景
在非 clean build 的情况下,更改 java/kotlin 代码虽然会做增量编译,但是为了绝对的正确性,gradle 会根据一些列依赖关系计算,选择需要重新编译的代码,这个计算粒度比较粗,稍微改动一个类的代码,就可能导致大量代码重新执行 apt, 编译等流程。
由于 gradle 作为通用框架,其设计的基本原则是绝对的正确,因此很容易导致增量编译失效,在实际开发中,为了快速编译展示结果,可以在编译正确性和编译速度上做一个折中的方案:
-
禁用原始的 javac/kotlinCompile 等 task, 自行实现代码增量修改判断,只编译修改的代码。
-
动态禁用 kapt 相关的 task, 降低 kapt,kaptGenerateStub 等 task 的耗时。
以上方案(下文全部简称为 fastbuild) 虽然在涉及常量修改,方法签名变更方面 存在一定的问题(常量内联等),但是能换来增量编译从 2 分多降低至 20~30s,极大的提升编译效率,且有问题的场景并不常见,因此整体上该方案是利大于弊的。
编译耗时恶化
======
通过上文介绍的几个优化方案和其他优化方式,在 18 年时,今日头条 Android 项目的整体编译速度(clean build 4~5min, fast 增量编译 20~30s)在同量级的大型工程中来说是比较快的 ,然而后期随着业务发展的需求,编译脚本添加了很多新的逻辑:
-
kotlin 大规模使用,kapt 新增了很多注解处理逻辑。
-
引入对 java8 语法的支持 , java8 语法的 desugar(脱糖)操作增加了编译耗时。
-
大量的字节码插桩需求,添加了许多 transform ,大幅度提升了增量编译耗时。
这些逻辑的引入,使得增量编译耗时恶化到 2 分 30s,即使采用 fastbuild,改动一行代码编译也需要 1 分 30s 之多,开发体验非常差。而下文将着重描述最近一段时间对上述问题的优化过程。
近期优化方案
======
app 壳模块 kapt 优化
背景
今日头条工程经过多次模块化,组件化重构后, app 模块(NewsArticle)的大部分代码都已经迁移到子模块(上文已经介绍过子模块可以采用 aar 化用于编译速度优化,app 模块只剩下一个壳而已。
但是从 build profile 数据(执行 gradle 命令时添加 --profile 参数会在编译完成后输出相关 task 耗时的统计文件) 中发现到一个异常 case:明明只有 2 个类的 app 模块 kapt(annotationProcessor 注解处理) 相关耗时近 1 分钟。
通过进一步观察,虽然 app 模块拆分后只有 2 个简单类的代码,但是却用了 6 种 kapt 库, 且实际生效的只是其中 ServiceImpl 一个注解 (内部 ServiceManager 框架,用于指示生产 Proxy 类,对模块之间代码调用进行解耦)。如此一顿操作猛如虎,每次编译却只生成固定的两个 Proxy 类,与 53s 的高耗时相比,投入产出比极低。
优化方案
把固定生成的 Proxy 类从 generate 目录移动到 src 目录,然后禁止 app 模块中 kapt 相关 task ,并添加相关管控方案(如下图: 检测到不合理情况后立刻抛出异常),防止其他人添加新增的 kapt 库。
收益
-
在 mac clean build 中平均有 40s 收益
-
在 ci clean build 中平均有 20s 收益
kapt 隔离优化
背景
通过上文介绍在 app 模块发现的异常的 kapt case, 进而发现在工程中为了方便,定义了一个 library.gradle ,该文件的作用是定义项目中通用的 Android dsl 配置和共有的基础依赖,因此项目中所有子模块均 apply 了这个文件,但是这个文件陆陆续续的被不同的业务添加新的 kapt 注解处理库,在全源码编译时,所有子模块都得执行 library 模块中定义的全部 6 个 kapt ,即使该模块没有任何注解相关的处理也不例外。
而上述情况的问题在于:相比纯 java 模块的注解处理,kotlin 代码需要先通过 kaptGenerateStub 将 kt 文件转换成为 java ,让 apt 处理程序能够统一的面向 java 做注解扫描和处理。但是上面讲到其实有很多模块是根本不会有任何实际 kapt 处理过程的,却白白的做了一次 kt 转 java 的操作,源码引入的模块越多,这种无意义的耗时累加起来也非常可观。
为了能够弄清楚到底有哪些子模块真正用到了 kapt ,哪些没用到可以禁用掉 kapt 相关 task ,对项目中所有子模块进行了一遍扫描:
-
获取 kapt configuration 的所有依赖,可以得到 kapt 依赖库的 jar 包,利用 asm 获取所有 annotation.
-
遍历所有 subproject 的 sourceset 下所有 .java,.kt 源文件,解析 import 信息,看是否有步骤 1 中解析的 annotation
-
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,尽可能降低优化方案对业务的负面影响。
收益
-
mac 全源码场景中有 58s 左右的加速收益。
-
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 特征比较相似,可能在一些插桩细节上有所不同,它们大致的处理逻辑为:
-
在各个模块中使用 apt processor 收集模块 xx 注解的 class 信息然后生成一个 xxCollect 类,该类的作用是收集好 apt 阶段解析到的本模块的类信息
-
将所有模块收集到的信息进行汇总,利用 transform 阶段扫描出所有的 xxCollect 信息,通过 javaassit 或者 asm 往一个 xxCollectMgr 的某个 collectXxx 方法插桩注入之前收到的信息
-
业务代码可通过 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 逻辑概述
该类用来辅助获取依赖或间接依赖到变更文件的所有文件,而它的生成逻辑为: 全量或增量编译类的时候记录类型之间的依赖和被依赖关系,依赖关系的判断条件有
-
父类
-
直接实现的接口
-
调用 dynamic 方法指令时的返回类型
DesugaringGraph 不仅记录了类依赖的类,和依赖它的类,同时也记录了一个文件路径包含了哪些类
-
如果文件路径是 class 文件,那路径就包含 1 个类
-
如果路径是 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 增量带来毁灭性的打击。上述问题的解决方法:
-
把每次都会修改的 Stub 注入类和其他接口类分离,放在不同 jar 包。(需要改造业务,比较麻烦)
-
动态把这个 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 的耗时基本达到了优化的极限。
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
总结
开发是面向对象。我们找工作应该更多是面向面试。哪怕进大厂真的只是去宁螺丝,但你要进去得先学会面试的时候造飞机不是么?
作者13年java转Android开发,在小厂待过,也去过华为,OPPO等,去年四月份进了阿里一直到现在。等大厂待过也面试过很多人。深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。
这里附上上述的技术体系图相关的几十套腾讯、头条、阿里、美团等公司的面试题,把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分。
相信它会给大家带来很多收获:
资料太多,全部展示会影响篇幅,暂时就先列举这些部分截图
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
这里附上上述的技术体系图相关的几十套腾讯、头条、阿里、美团等公司的面试题,把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分。
相信它会给大家带来很多收获:
[外链图片转存中…(img-jgnrPIGj-1712889578657)]
[外链图片转存中…(img-GVIi22Ni-1712889578658)]
资料太多,全部展示会影响篇幅,暂时就先列举这些部分截图
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
[外链图片转存中…(img-TE4Q90cU-1712889578658)]