面试官: Handler中有Loop死循环,为什么没有阻塞主线程,原理是什么
心理分析:该问题很难被考到,但是如果一旦问到,100%会回答不上来。开发者很难注意到一个主线程的四循环居然没有阻塞住主线程
**求职者:**应该从 主线程的消息循环机制 与Linux的循环异步等待作用讲起。最后将handle引起的内存泄漏,内存泄漏一定是一个加分项
前言
Android的消息机制主要是指Handler的运行机制,对于大家来说Handler已经是轻车熟路了,可是真的掌握了Handler?本文主要通过几个问题围绕着Handler展开深入并拓展的了解。
站在巨人的肩膀上会看的更远。大家有兴趣的也可以到Gityuan的博客上多了解了解,全部都是干货。而且他写的东西比较权威,毕竟也是小米系统工程师的骨干成员。
背景介绍
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 ,整体的优化效果如下:
历史优化方案
由于 18 年左右客户端基础技术相关同学已经对今日头条 Android 工程做了许多 gradle 相关的优化,且这些优化是近期优化的基础,因此先挑选几个具有代表性的方案进行介绍,作为下文的背景同步。
maven 代理优化 sync 时间
背景
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 左右的收益。
写在最后
本次我的分享也接近尾声了,感谢你们在百忙中花上一下午来这里聆听我的宣讲,希望在接下来的日子,我们共同成长,一起进步!!!
最后放上一个大概的Android学习方向及思路(详细的内容太多了~),提供给大家:
对于程序员来说,要学习的知识内容、技术有太多太多,这里就先放上一部分,其他的内容有机会在后面的文章向大家呈现出来,不过我自己所有的学习资料都整理成了一个文档,一直在不断学习,如今整理的资料不知不觉居然已经有将近80G了,在这里作为读者福利免费分享给大家,希望能帮助到大家,也节省大家在网上搜索资料的时间来学习,也可以分享动态给身边好友一起学习!
资料获取传送门:点击免费获取Android架构设计
群内有许多技术大牛,有任何问题,欢迎广大网友一起来交流,群内还不定期免费分享高阶Android学习视频资料和面试资料包~
为什么某些人会一直比你优秀,是因为他本身就很优秀还一直在持续努力变得更优秀,而你是不是还在满足于现状内心在窃喜!希望读到这的您能点个小赞和关注下我,以后还会更新技术干货,谢谢您的支持!
Android架构师之路很漫长,一起共勉吧!
如果你觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言,一定会认真查询,修正不足,谢谢。
优秀还一直在持续努力变得更优秀,而你是不是还在满足于现状内心在窃喜!希望读到这的您能点个小赞和关注下我,以后还会更新技术干货,谢谢您的支持!**
Android架构师之路很漫长,一起共勉吧!
如果你觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言,一定会认真查询,修正不足,谢谢。