34岁安卓开发大叔感慨,月薪30K

# 背景介绍 Android 项目一般使用 gradle 作为构建打包工具,而其执行速度慢也一直为人所诟病,对于今日头条 Android 项目这种千万行级别的大型工程来说,全量编译一次的时间可能高达六七分钟,在某些需要快速验证功能的场景,改动一行代码的增量编译甚至也需要等两三分钟,这般龟速严重影响了开发体验与效率,因此针对 gradle 编译构建耗时进行优化显得尤为重要。 在今日头条 Android 项目上,编译构建速度的优化和恶化一直在交替执行,18 年时由于模块化拆分等影响,clean build 一次的耗时达到了顶峰 7 分 30s 左右,相关同学通过模块 aar 化,maven 代理加速,以及增量 java 编译等优化手段,将 clean build 耗时优化到 4 分钟,增量编译优化到 20~30s 。但是后面随着 kotlin 的大规模使用,自定义 transform 以及 apt 库泛滥,又将增量编译速度拖慢到 2 分 30s ,且有进一步恶化的趋势。为了优化现有不合理的编译耗时,防止进一步的恶化,最近的 5,6 双月又针对编译耗时做了一些列专项优化(kapt,transform,dexBuilder,build-cache 等) 并添加了相关的防恶化管控方案。 从 4.27 截止到 6.29 ,整体的优化效果如下: ![](http://www.icode9.com/i/li/?n=2&i=images/20210706/1625582860217964.jpg) # 历史优化方案 由于 18 年左右客户端基础技术相关同学已经对今日头条 Android 工程做了许多 gradle 相关的优化,且这些优化是近期优化的基础,因此先挑选几个具有代表性的方案进行介绍,作为下文的背景同步。 ## maven 代理优化 sync 时间 ### 背景 gradle 工程往往会在 repositories 中添加一些列的 maven 仓库地址,作为组件依赖获取的查找路径,早期在今日头条的项目中配置了十几个 maven 的地址,但是依赖获取是按照 maven 仓库配置的顺序依次查找的,如果某个组件存在于最后一个仓库中,那前面的十几个仓库得依次发起网络请求查找,并在网络请求返回失败后才查找下一个,如果项目中大多组件都在较后仓库的位置,累加起来的查找时间就会很长。 ### 优化方案 1. 使用公司内部搭建的 maven 私服,在私服上设置代理仓库,为其他仓库配置代理(例如 google、jcenter、mavenCentral 等仓库),代理仓库创建好后,在 Negative Cache 配置项中关闭其 cache 开关:如果查找时没有找到某版本依赖库时会缓存失败结果,一段时间内不会重新去 maven 仓库查找对应依赖库,即使 maven 仓库中已经有该版本的依赖库,查找时仍然返回失败的结果。 2. 建立仓库组,将所有仓库归放到一个统一的仓库组里,依赖查找时只需要去这个组仓库中查找,这样能大大降低多次发起网络请求遍历仓库的耗时。 ## 模块 aar 化 ### 背景 今日头条项目进行了多次组件化和模块化的重构,分拆出了 200 多个子模块,这些子模块如果全都 include 进项目,那么在 clean build 的时候,所有子模块的代码需要重新编译,而对于大多数开发人员来说,基本上只关心自己负责的少数几个模块,根本不需要改动其他模块的代码,这些其他 project 的配置和编译时间就成为了不必要的代价。 ### 优化方案 对于以上子模块过多的解决方案是:将所有模块发布成 aar ,在项目中全部默认通过 maven 依赖这些编译好的组件,而在需要修改某个模块时,通过配置项将该模块的依赖形式改为源码依赖,做到在编译时只编译改动的模块。但是这样做会导致模块渐渐的又全部变为源码依赖的形式,除非规定每次修改完对应模块后,开发人员自己手动将模块发布成 aar ,并改回依赖形式。这种严重依赖开发人员自觉,并且在模块数量多依赖关系复杂的时候会显得异常繁琐,因此为了开发阶段的便利,设计了一整套更完整细致的方案: 1. 开发时,从主分支拉取的代码一定是全 aar 依赖的,除了 app 模块没有任何子模块是源码引入。 2. 需要修改对应模块时,通过修改 local.properties 里的 INCLUDES 参数指定源码引入的模块。 3. 开发完成后,push 代码至远端,触发代码合并流程后,在 ci 预编译过程与合码目标分支对比,检测修改的模块,将这些模块按照依赖关系依次发布成 aar ,并在工程中修改依赖为新版本的 aar, 这一步保证了每次代码合入完成后,主分支上的依赖都是全 aar 依赖的。 ### 收益 通过上述改造,将源码模块切换成 aar 依赖后,clean build 耗时从 7,8 分钟降低至 4,5 分钟,收益接近 50%,效果显著。 ## 增量 java/kotlin 编译 ### 背景 在非 clean build 的情况下,更改 java/kotlin 代码虽然会做增量编译,但是为了绝对的正确性,gradle 会根据一些列依赖关系计算,选择需要重新编译的代码,这个计算粒度比较粗,稍微改动一个类的代码,就可能导致大量代码重新执行 apt, 编译等流程。 由于 gradle 作为通用框架,其设计的基本原则是绝对的正确,因此很容易导致增量编译失效,在实际开发中,为了快速编译展示结果,可以在编译正确性和编译速度上做一个折中的方案: 1. 禁用原始的 javac/kotlinCompile 等 task, 自行实现代码增量修改判断,只编译修改的代码。 2. 动态禁用 kapt 相关的 task, 降低 kapt,kaptGenerateStub 等 task 的耗时。 以上方案(下文全部简称为 fastbuild) 虽然在涉及常量修改,方法签名变更方面 存在一定的问题(常量内联等),但是能换来增量编译从 2 分多降低至 20~30s,极大的提升编译效率,且有问题的场景并不常见,因此整体上该方案是利大于弊的。 # 编译耗时恶化 通过上文介绍的几个优化方案和其他优化方式,在 18 年时,今日头条 Android 项目的整体编译速度(clean build 4~5min, fast 增量编译 20~30s)在同量级的大型工程中来说是比较快的 ,然而后期随着业务发展的需求,编译脚本添加了很多新的逻辑: 1. kotlin 大规模使用,kapt 新增了很多注解处理逻辑。 2. 引入对 java8 语法的支持 , java8 语法的 desugar(脱糖)操作增加了编译耗时。 3. 大量的字节码插桩需求,添加了许多 transform ,大幅度提升了增量编译耗时。 这些逻辑的引入,使得增量编译耗时恶化到 2 分 30s,即使采用 fastbuild,改动一行代码编译也需要 1 分 30s 之多,开发体验非常差。而下文将着重描述最近一段时间对上述问题的优化过程。 # 近期优化方案 ## app 壳模块 kapt 优化 ### 背景 今日头条工程经过多次模块化,组件化重构后, app 模块(NewsArticle)的大部分代码都已经迁移到子模块(上文已经介绍过子模块可以采用 aar 化用于编译速度优化,app 模块只剩下一个壳而已。 但是从 build profile 数据(执行 gradle 命令时添加 --profile 参数会在编译完成后输出相关 task 耗时的统计文件) 中发现到一个异常 case:明明只有 2 个类的 app 模块 kapt(annotationProcessor 注解处理) 相关耗时近 1 分钟。 ![](http://www.icode9.com/i/li/?n=2&i=images/20210706/1625582860651400.jpg) 通过进一步观察,虽然 app 模块拆分后只有 2 个简单类的代码,但是却用了 6 种 kapt 库, 且实际生效的只是其中 ServiceImpl 一个注解 (内部 ServiceManager 框架,用于指示生产 Proxy 类,对模块之间代码调用进行解耦)。如此一顿操作猛如虎,每次编译却只生成固定的两个 Proxy 类,与 53s 的高耗时相比,投入产出比极低。 ### 优化方案 把固定生成的 Proxy 类从 generate 目录移动到 src 目录,然后禁止 app 模块中 kapt 相关 task ,并添加相关管控方案(如下图: 检测到不合理情况后立刻抛出异常),防止其他人添加新增的 kapt 库。 ![](http://www.icode9.com/i/li/?n=2&i=images/20210706/1625582860882124.jpg) ### 收益 1. 在 mac clean build 中平均有 40s 收益 2. 在 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 ,对项目中所有子模块进行了一遍扫描: 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 掉,在尝试使用注解时会因获取不到引用(如下图所示),第一时间发现到依赖被移除的问题。 ![](http://www.icode9.com/i/li/?n=2&i=images/20210706/1625582861459936.jpg) 另一方面在编译出现错误时,对应 gradle 插件会自动解析找不到的符号,如果发现该符号是被隔离优化的注解,会提示将 library-api 替换成 library,尽可能降低优化方案对业务的负面影响。 ### 收益 1. mac 全源码场景中有 58s 左右的加速收益。 2. ci 机器上由于 cpu 核数更多 ,task 并发性能更好,只有 10s 左右的收益。 ## 总结 **【Android 详细知识点思维脑图(技能树)】** > ![image](http://www.icode9.com/i/li/?n=2&i=images/20210706/1625582861629090.jpg) 其实Android开发的知识点就那么多,面试问来问去还是那么点东西。所以面试没有其他的诀窍,只看你对这些知识点准备的充分程度。so,出去面试时先看看自己复习到了哪个阶段就好。 虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,**现在高级工程师还是比较缺少的**,很多高级职位给的薪资真的特别高(钱多也不一定能找到合适的),所以努力让自己成为高级工程师才是最重要的。 > 这里附上上述的面试题相关的几十套字节跳动,京东,小米,腾讯、头条、阿里、美团等公司19年的面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。 由于篇幅有限,这里以图片的形式给大家展示一小部分。 ![](http://www.icode9.com/i/li/?n=2&i=images/20210706/1625582861344254.jpg) 详细整理在腾讯文档:**[Android架构视频+BAT面试专题PDF+学习笔记](https://docs.qq.com/doc/DSkNLaERkbnFoS0ZF)?** 网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。
上一篇:Android程序员怎么优雅迈过30K 这道坎?你值得拥有


下一篇:Java面试资料集合,月薪30K