各位老铁们好,相信很多人对今日头条视频网站源码分享都不是特别的了解,因此呢,今天就来为大家分享下关于今日头条视频网站源码分享以及今日头条真实网页源码的问题知识,还望可以帮助大家,解决大家的一些困惑,下面一起来看看吧!
背景介绍
Android项目一般使用gradle作为构建打包工具,而其执行速度慢也一直为人所诟病,对于今日头条Android项目这种千万行级别的大型工程来说,全量编译一次的时间可能高达六七分钟,在某些需要快速验证功能的场景,改动一行代码的增量编译甚至也需要等两三分钟,这般龟速严重影响了开发体验与效率,因此针对gradle编译构建耗时进行优化显得尤为重要。
在今日头条Android项目上,编译构建速度的优化和恶化一直在交替执行,18年时由于模块化拆分等影响,cleanbuild一次的耗时达到了顶峰7分30s左右,相关同学通过模块aar化,maven代理加速,以及增量java编译等优化手段,将cleanbuild耗时优化到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等仓库),代理仓库创建好后,在NegativeCache配置项中关闭其cache开关:如果查找时没有找到某版本依赖库时会缓存失败结果,一段时间内不会重新去maven仓库查找对应依赖库,即使maven仓库中已经有该版本的依赖库,查找时仍然返回失败的结果。建立仓库组,将所有仓库归放到一个统一的仓库组里,依赖查找时只需要去这个组仓库中查找,这样能大大降低多次发起网络请求遍历仓库的耗时。
模块aar化
背景
今日头条项目进行了多次组件化和模块化的重构,分拆出了200多个子模块,这些子模块如果全都include进项目,那么在cleanbuild的时候,所有子模块的代码需要重新编译,而对于大多数开发人员来说,基本上只关心自己负责的少数几个模块,根本不需要改动其他模块的代码,这些其他project的配置和编译时间就成为了不必要的代价。
优化方案
对于以上子模块过多的解决方案是:将所有模块发布成aar,在项目中全部默认通过maven依赖这些编译好的组件,而在需要修改某个模块时,通过配置项将该模块的依赖形式改为源码依赖,做到在编译时只编译改动的模块。但是这样做会导致模块渐渐的又全部变为源码依赖的形式,除非规定每次修改完对应模块后,开发人员自己手动将模块发布成aar,并改回依赖形式。这种严重依赖开发人员自觉,并且在模块数量多依赖关系复杂的时候会显得异常繁琐,因此为了开发阶段的便利,设计了一整套更完整细致的方案:
开发时,从主分支拉取的代码一定是全aar依赖的,除了app模块没有任何子模块是源码引入。需要修改对应模块时,通过修改local.properties里的INCLUDES参数指定源码引入的模块。开发完成后,push代码至远端,触发代码合并流程后,在ci预编译过程与合码目标分支对比,检测修改的模块,将这些模块按照依赖关系依次发布成aar,并在工程中修改依赖为新版本的aar,这一步保证了每次代码合入完成后,主分支上的依赖都是全aar依赖的。
收益
通过上述改造,将源码模块切换成aar依赖后,cleanbuild耗时从7,8分钟降低至4,5分钟,收益接近50%,效果显著。
增量java/kotlin编译
背景
在非cleanbuild的情况下,更改java/kotlin代码虽然会做增量编译,但是为了绝对的正确性,gradle会根据一些列依赖关系计算,选择需要重新编译的代码,这个计算粒度比较粗,稍微改动一个类的代码,就可能导致大量代码重新执行apt,编译等流程。
由于gradle作为通用框架,其设计的基本原则是绝对的正确,因此很容易导致增量编译失效,在实际开发中,为了快速编译展示结果,可以在编译正确性和编译速度上做一个折中的方案:
禁用原始的javac/kotlinCompile等task,自行实现代码增量修改判断,只编译修改的代码。动态禁用kapt相关的task,降低kapt,kaptGenerateStub等task的耗时。
以上方案(下文全部简称为fastbuild)虽然在涉及常量修改,方法签名变更方面存在一定的问题(常量内联等),但是能换来增量编译从2分多降低至20~30s,极大的提升编译效率,且有问题的场景并不常见,因此整体上该方案是利大于弊的。
编译耗时恶化
通过上文介绍的几个优化方案和其他优化方式,在18年时,今日头条Android项目的整体编译速度(cleanbuild4~5min,fast增量编译20~30s)在同量级的大型工程中来说是比较快的,然而后期随着业务发展的需求,编译脚本添加了很多新的逻辑:
kotlin大规模使用,kapt新增了很多注解处理逻辑。引入对java8语法的支持,java8语法的desugar(脱糖)操作增加了编译耗时。大量的字节码插桩需求,添加了许多transform,大幅度提升了增量编译耗时。
这些逻辑的引入,使得增量编译耗时恶化到2分30s,即使采用fastbuild,改动一行代码编译也需要1分30s之多,开发体验非常差。而下文将着重描述最近一段时间对上述问题的优化过程。
近期优化方案
app壳模块kapt优化
背景
今日头条工程经过多次模块化,组件化重构后,app模块(NewsArticle)的大部分代码都已经迁移到子模块(上文已经介绍过子模块可以采用aar化用于编译速度优化,app模块只剩下一个壳而已。
但是从buildprofile数据(执行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库。
收益
在maccleanbuild中平均有40s收益在cicleanbuild中平均有20s收益
kapt隔离优化
背景
通过上文介绍在app模块发现的异常的kaptcase,进而发现在工程中为了方便,定义了一个library.gradle,该文件的作用是定义项目中通用的Androiddsl配置和共有的基础依赖,因此项目中所有子模块均apply了这个文件,但是这个文件陆陆续续的被不同的业务添加新的kapt注解处理库,在全源码编译时,所有子模块都得执行library模块中定义的全部6个kapt,即使该模块没有任何注解相关的处理也不例外。
而上述情况的问题在于:相比纯java模块的注解处理,kotlin代码需要先通过kaptGenerateStub将kt文件转换成为java,让apt处理程序能够统一的面向java做注解扫描和处理。但是上面讲到其实有很多模块是根本不会有任何实际kapt处理过程的,却白白的做了一次kt转java的操作,源码引入的模块越多,这种无意义的耗时累加起来也非常可观。
为了能够弄清楚到底有哪些子模块真正用到了kapt,哪些没用到可以禁用掉kapt相关task,对项目中所有子模块进行了一遍扫描:
获取kaptconfiguration的所有依赖,可以得到kapt依赖库的jar包,利用asm获取所有annotation.遍历所有subproject的sourceset下所有.java,.kt源文件,解析import信息,看是否有步骤1中解析的annotationpackagetask完成后遍历所有subproject所有generate/apt,generate/kapt目录下生成的java文件
使用上述方案,通过全源码打包最终扫描出来大概是70+模块不会进行任何kapt的实际输出,且将这些不会进行输出的kapt,kaptGenerateStub的task耗时累加起来较高217s(由于task并发执行所以实际总时长可能要少一些).
获取到不实际生成kapt内容的模块后,开始对这些模块进行细粒度的拆分,让它们从applylibrary.gradle改为没有kapt相关的library-api.gradle,该文件除了禁用kapt外,与library逻辑一致。
但是这样做算是在背后偷偷做了些更改,很可能后续新来的同学不知道有这种优化手段,可能新增了注解后却没有任何输出且找不到原因,而优化效果最好是尽量少给业务同学带来困扰。为了避免这种情况,便对这些library-api模块依赖的注解做隔离优化,即:把这些模块依赖的注解库全部自动exclude掉,在尝试使用注解时会因获取不到引用(如下图所示),第一时间发现到依赖被移除的问题。
另一方面在编译出现错误时,对应gradle插件会自动解析找不到的符号,如果发现该符号是被隔离优化的注解,会提示将library-api替换成library,尽可能降低优化方案对业务的负面影响。
收益
mac全源码场景中有58s左右的加速收益。ci机器上由于cpu核数更多,task并发性能更好,只有10s左右的收益。
transform优化
背景
transform作为Androidgradleplugin提供给开发者的API,用于在apk构建过程中,对class字节码,resources等文件内容进行插桩修改,例如官方的dex,proguard等功能均由此api实现。
对于今日头条这种大型工程来说,有很多诸如性能插桩、自动埋点插桩等相关需求,因此基于此api开发了大量transform,用于实现特定功能,但是这些transform基本上都是不支持增量编译的,即使只改动了一行代码,这些transform都会遍历所有class文件,解析字节码中的方法字段信息,关键是这类transform数量有十几个,将这些遍历耗时乘以10累加之后,增量编译耗时自然居高不下。
根据分析,其中性能插桩等相关transform做的一些面向线上的插桩方案是完全可以只在release打包时打开的,因此可以直接在debug编译时禁用这些功能,用于提升开发期间的编译速度。而剩下的9个transform特征比较相似,可能在一些插桩细节上有所不同,它们大致的处理逻辑为:
在各个模块中使用aptprocessor收集模块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优化
背景
在Androiddebug编译过程中,最主要的耗时在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编译的数值,做到最佳优化,设计了一套hookdex编译流程的方法(该方法理论上可以hookAndroidgradleplugin任意类:大致就是hookclassLoader,提前用asm修改D8DexArchiveBuilder中的convert方法
通过对D8DexArchiveBuilder的hook,统计到优化前改动一行代码会连带着24968个类重新执行dex编译,增量效果非常差。
优化方案
既然jar输入相比于目录输入来说增量编译效果非常差,那么可以想到hookTransformInvocation中的input方法,动态将project的jar类型输入(JarInput)映射为一个目录输入(DirectoryInput),那么子模块修改对应代码时,只重新编译目录中被修改的class为dex(而不是原来的整个jar内所有class重新执行dex编译),整体dex重新编译的数量将大幅度减少。实现具体方案如下:
自动发现源码依赖的子模块project,配置经常需要变更的注入类所在的SDKjarhookTransformInvocation的input将上面步骤中的JarInput映射为DirectoryInput每次hookinput前检查与上一次需要优化的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流程,但是如果是其他sdkjar包,则可能就会给dexBuilder增量带来毁灭性的打击。上述问题的解决方法:
把每次都会修改的Stub注入类和其他接口类分离,放在不同jar包。(需要改造业务,比较麻烦)动态把这个sdkjar输入转换成目录输入。(上文介绍的方法,也与上面jar转目录的初衷相符,只不过是漏掉了这个case,但是却意外证明了:除了包含业务代码多的project的jar输入需要转换为目录外,sdkjar同样有必要)
修复后修改一行代码重新执行dex的数量为10,其中9个是每次transform会修改的stub类,1个是实际修改类。做到了真正的改多少类,执行多次dex编译。
收益
assemebleDebug的增量编译中从原来(上文transform优化后)的35s~40s是降低至均值17s,在fastbuild中效果最明显(屏蔽了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在计算javactask的key时会错乱导致缓存无法命中。
解决方案
但是注意到AbstractProcessor的核心方法process的两个参数都是接口,因此想到可以代理原来的RoundEnvironment接口,将其getElementXx的方法经过固定排序后返回,使得apt注解元素的顺序能够固定下来。
由于篇幅影响,其他影响缓存命中相关的case略(主要是一些涉及到文件绝对路径,classPath相关的问题)
收益
由于大多开发场景是引入多少模块就修改多少模块内容,很难获得命中缓存,收益很小主要是全源码场景能稳定获得一些编译加速,基本上在22~99s左右。
编译耗时防恶化管控
在今日头条这种大型工程中,有很多业务部门参与开发,仅Android工程开发人员就有几百人且人员变动频繁,因此内部任何一项优化工作必然是得搭配上一些管控措施的,否则一边优化一边恶化,空浪费人力。
为此制定了一些管控方案,首先是debug阶段的新增transform管控,设置为白名单形式,如果在开发阶段新增了transform直接终止编译流程,通过说明文档告知管控的规则,当然,管控的目的是尽可能减少一些不必要的不合理的编译问题,并不是与业务团队作对,如果某一个操作拖慢了整体的编译耗时,但是在app性能/稳定性方面有更大收益,且无法在编译期做更多的优化,仍然是允许添加的,只不过是得提前把这个问题暴露出来而已,能更快的找出更多的解决思路,比如引导使用byteX等transform收敛方案。
另一方面的是合码流程方面的阻塞:今日头条为了保障app的性能稳定性,在合码流程上设置了许多自动化的卡点:如包大小检测,插件依赖变更检查,so变更检查,启动性能检测等,检测到对应问题(如包大小增加异常)会阻塞合码流程。为了管控编译速度,使其不至于恶化的太快,也加上了对应的基于task级别的管控,当某一个task耗时异常波动,或者新增全新类型的task时,能够自动的发现问题,通过机器人将相关人员拉到mr讨论群中,尽量在合码完成前能发现问题。
总结
为了持续稳定的保持较快的编译速度,可能需要做到以下几点:
项目需要有良好的工程结构,对业务模块进行适当粒度的拆分,做好aar/源码的切换不仅能节省javac/kotlinCompile的耗时,也是其他优化方案的基础。工程配置要有区分度,不要所有子模块都用同样的配置,比如根本不会用到kapt功能的模块就别打开kapttask了。transform若无必要,无须新加,或者按级别划分,如今日头条在debug,devMode,release不同的构建级别用到的transform数量是不一致的,尽量让绝大多数人能获得相对最快的编译速度体验,而不会被用不到的功能拖慢速度。一定要新增的transform可以先多用现有的增量方案,如byteX以及本文提供的类信息注入框架,尽量把不要的文件io合并。很多高耗时的官方task(dexBuilder)都是有直接或间接的办法提升其效率的,并且如果除了耗时之外有其他的衡量手段,如本文提到的重新dex率,通过量化数据可以快速的发现问题,进而找到耗时的罪魁祸首。与app性能优化等工作类似,编译速度优化既需要持续进行,也需要一定的问题发现手段,尽量避免问题出现很长一段时间后再去查找原因(那时候可能业务依赖程度会非常高,难以修改)。
更多分享
背景介绍
Android项目一般使用gradle作为构建打包工具,而其执行速度慢也一直为人所诟病,对于今日头条Android项目这种千万行级别的大型工程来说,全量编译一次的时间可能高达六七分钟,在某些需要快速验证功能的场景,改动一行代码的增量编译甚至也需要等两三分钟,这般龟速严重影响了开发体验与效率,因此针对gradle编译构建耗时进行优化显得尤为重要。
在今日头条Android项目上,编译构建速度的优化和恶化一直在交替执行,18年时由于模块化拆分等影响,cleanbuild一次的耗时达到了顶峰7分30s左右,相关同学通过模块aar化,maven代理加速,以及增量java编译等优化手段,将cleanbuild耗时优化到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等仓库),代理仓库创建好后,在NegativeCache配置项中关闭其cache开关:如果查找时没有找到某版本依赖库时会缓存失败结果,一段时间内不会重新去maven仓库查找对应依赖库,即使maven仓库中已经有该版本的依赖库,查找时仍然返回失败的结果。建立仓库组,将所有仓库归放到一个统一的仓库组里,依赖查找时只需要去这个组仓库中查找,这样能大大降低多次发起网络请求遍历仓库的耗时。
模块aar化
背景
今日头条项目进行了多次组件化和模块化的重构,分拆出了200多个子模块,这些子模块如果全都include进项目,那么在cleanbuild的时候,所有子模块的代码需要重新编译,而对于大多数开发人员来说,基本上只关心自己负责的少数几个模块,根本不需要改动其他模块的代码,这些其他project的配置和编译时间就成为了不必要的代价。
优化方案
对于以上子模块过多的解决方案是:将所有模块发布成aar,在项目中全部默认通过maven依赖这些编译好的组件,而在需要修改某个模块时,通过配置项将该模块的依赖形式改为源码依赖,做到在编译时只编译改动的模块。但是这样做会导致模块渐渐的又全部变为源码依赖的形式,除非规定每次修改完对应模块后,开发人员自己手动将模块发布成aar,并改回依赖形式。这种严重依赖开发人员自觉,并且在模块数量多依赖关系复杂的时候会显得异常繁琐,因此为了开发阶段的便利,设计了一整套更完整细致的方案:
开发时,从主分支拉取的代码一定是全aar依赖的,除了app模块没有任何子模块是源码引入。需要修改对应模块时,通过修改local.properties里的INCLUDES参数指定源码引入的模块。开发完成后,push代码至远端,触发代码合并流程后,在ci预编译过程与合码目标分支对比,检测修改的模块,将这些模块按照依赖关系依次发布成aar,并在工程中修改依赖为新版本的aar,这一步保证了每次代码合入完成后,主分支上的依赖都是全aar依赖的。
收益
通过上述改造,将源码模块切换成aar依赖后,cleanbuild耗时从7,8分钟降低至4,5分钟,收益接近50%,效果显著。
增量java/kotlin编译
背景
在非cleanbuild的情况下,更改java/kotlin代码虽然会做增量编译,但是为了绝对的正确性,gradle会根据一些列依赖关系计算,选择需要重新编译的代码,这个计算粒度比较粗,稍微改动一个类的代码,就可能导致大量代码重新执行apt,编译等流程。
由于gradle作为通用框架,其设计的基本原则是绝对的正确,因此很容易导致增量编译失效,在实际开发中,为了快速编译展示结果,可以在编译正确性和编译速度上做一个折中的方案:
禁用原始的javac/kotlinCompile等task,自行实现代码增量修改判断,只编译修改的代码。动态禁用kapt相关的task,降低kapt,kaptGenerateStub等task的耗时。
以上方案(下文全部简称为fastbuild)虽然在涉及常量修改,方法签名变更方面存在一定的问题(常量内联等),但是能换来增量编译从2分多降低至20~30s,极大的提升编译效率,且有问题的场景并不常见,因此整体上该方案是利大于弊的。
编译耗时恶化
通过上文介绍的几个优化方案和其他优化方式,在18年时,今日头条Android项目的整体编译速度(cleanbuild4~5min,fast增量编译20~30s)在同量级的大型工程中来说是比较快的,然而后期随着业务发展的需求,编译脚本添加了很多新的逻辑:
kotlin大规模使用,kapt新增了很多注解处理逻辑。引入对java8语法的支持,java8语法的desugar(脱糖)操作增加了编译耗时。大量的字节码插桩需求,添加了许多transform,大幅度提升了增量编译耗时。
这些逻辑的引入,使得增量编译耗时恶化到2分30s,即使采用fastbuild,改动一行代码编译也需要1分30s之多,开发体验非常差。而下文将着重描述最近一段时间对上述问题的优化过程。
近期优化方案
app壳模块kapt优化
背景
今日头条工程经过多次模块化,组件化重构后,app模块(NewsArticle)的大部分代码都已经迁移到子模块(上文已经介绍过子模块可以采用aar化用于编译速度优化,app模块只剩下一个壳而已。
但是从buildprofile数据(执行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库。
收益
在maccleanbuild中平均有40s收益在cicleanbuild中平均有20s收益
kapt隔离优化
背景
通过上文介绍在app模块发现的异常的kaptcase,进而发现在工程中为了方便,定义了一个library.gradle,该文件的作用是定义项目中通用的Androiddsl配置和共有的基础依赖,因此项目中所有子模块均apply了这个文件,但是这个文件陆陆续续的被不同的业务添加新的kapt注解处理库,在全源码编译时,所有子模块都得执行library模块中定义的全部6个kapt,即使该模块没有任何注解相关的处理也不例外。
而上述情况的问题在于:相比纯java模块的注解处理,kotlin代码需要先通过kaptGenerateStub将kt文件转换成为java,让apt处理程序能够统一的面向java做注解扫描和处理。但是上面讲到其实有很多模块是根本不会有任何实际kapt处理过程的,却白白的做了一次kt转java的操作,源码引入的模块越多,这种无意义的耗时累加起来也非常可观。
为了能够弄清楚到底有哪些子模块真正用到了kapt,哪些没用到可以禁用掉kapt相关task,对项目中所有子模块进行了一遍扫描:
获取kaptconfiguration的所有依赖,可以得到kapt依赖库的jar包,利用asm获取所有annotation.遍历所有subproject的sourceset下所有.java,.kt源文件,解析import信息,看是否有步骤1中解析的annotationpackagetask完成后遍历所有subproject所有generate/apt,generate/kapt目录下生成的java文件
使用上述方案,通过全源码打包最终扫描出来大概是70+模块不会进行任何kapt的实际输出,且将这些不会进行输出的kapt,kaptGenerateStub的task耗时累加起来较高217s(由于task并发执行所以实际总时长可能要少一些).
获取到不实际生成kapt内容的模块后,开始对这些模块进行细粒度的拆分,让它们从applylibrary.gradle改为没有kapt相关的library-api.gradle,该文件除了禁用kapt外,与library逻辑一致。
但是这样做算是在背后偷偷做了些更改,很可能后续新来的同学不知道有这种优化手段,可能新增了注解后却没有任何输出且找不到原因,而优化效果最好是尽量少给业务同学带来困扰。为了避免这种情况,便对这些library-api模块依赖的注解做隔离优化,即:把这些模块依赖的注解库全部自动exclude掉,在尝试使用注解时会因获取不到引用(如下图所示),第一时间发现到依赖被移除的问题。
另一方面在编译出现错误时,对应gradle插件会自动解析找不到的符号,如果发现该符号是被隔离优化的注解,会提示将library-api替换成library,尽可能降低优化方案对业务的负面影响。
收益
mac全源码场景中有58s左右的加速收益。ci机器上由于cpu核数更多,task并发性能更好,只有10s左右的收益。
transform优化
背景
transform作为Androidgradleplugin提供给开发者的API,用于在apk构建过程中,对class字节码,resources等文件内容进行插桩修改,例如官方的dex,proguard等功能均由此api实现。
对于今日头条这种大型工程来说,有很多诸如性能插桩、自动埋点插桩等相关需求,因此基于此api开发了大量transform,用于实现特定功能,但是这些transform基本上都是不支持增量编译的,即使只改动了一行代码,这些transform都会遍历所有class文件,解析字节码中的方法字段信息,关键是这类transform数量有十几个,将这些遍历耗时乘以10累加之后,增量编译耗时自然居高不下。
根据分析,其中性能插桩等相关transform做的一些面向线上的插桩方案是完全可以只在release打包时打开的,因此可以直接在debug编译时禁用这些功能,用于提升开发期间的编译速度。而剩下的9个transform特征比较相似,可能在一些插桩细节上有所不同,它们大致的处理逻辑为:
在各个模块中使用aptprocessor收集模块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优化
背景
在Androiddebug编译过程中,最主要的耗时在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编译的数值,做到最佳优化,设计了一套hookdex编译流程的方法(该方法理论上可以hookAndroidgradleplugin任意类:大致就是hookclassLoader,提前用asm修改D8DexArchiveBuilder中的convert方法
通过对D8DexArchiveBuilder的hook,统计到优化前改动一行代码会连带着24968个类重新执行dex编译,增量效果非常差。
优化方案
既然jar输入相比于目录输入来说增量编译效果非常差,那么可以想到hookTransformInvocation中的input方法,动态将project的jar类型输入(JarInput)映射为一个目录输入(DirectoryInput),那么子模块修改对应代码时,只重新编译目录中被修改的class为dex(而不是原来的整个jar内所有class重新执行dex编译),整体dex重新编译的数量将大幅度减少。实现具体方案如下:
自动发现源码依赖的子模块project,配置经常需要变更的注入类所在的SDKjarhookTransformInvocation的input将上面步骤中的JarInput映射为DirectoryInput每次hookinput前检查与上一次需要优化的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流程,但是如果是其他sdkjar包,则可能就会给dexBuilder增量带来毁灭性的打击。上述问题的解决方法:
把每次都会修改的Stub注入类和其他接口类分离,放在不同jar包。(需要改造业务,比较麻烦)动态把这个sdkjar输入转换成目录输入。(上文介绍的方法,也与上面jar转目录的初衷相符,只不过是漏掉了这个case,但是却意外证明了:除了包含业务代码多的project的jar输入需要转换为目录外,sdkjar同样有必要)
修复后修改一行代码重新执行dex的数量为10,其中9个是每次transform会修改的stub类,1个是实际修改类。做到了真正的改多少类,执行多次dex编译。
收益
assemebleDebug的增量编译中从原来(上文transform优化后)的35s~40s是降低至均值17s,在fastbuild中效果最明显(屏蔽了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在计算javactask的key时会错乱导致缓存无法命中。
解决方案
但是注意到AbstractProcessor的核心方法process的两个参数都是接口,因此想到可以代理原来的RoundEnvironment接口,将其getElementXx的方法经过固定排序后返回,使得apt注解元素的顺序能够固定下来。
由于篇幅影响,其他影响缓存命中相关的case略(主要是一些涉及到文件绝对路径,classPath相关的问题)
收益
由于大多开发场景是引入多少模块就修改多少模块内容,很难获得命中缓存,收益很小主要是全源码场景能稳定获得一些编译加速,基本上在22~99s左右。
编译耗时防恶化管控
在今日头条这种大型工程中,有很多业务部门参与开发,仅Android工程开发人员就有几百人且人员变动频繁,因此内部任何一项优化工作必然是得搭配上一些管控措施的,否则一边优化一边恶化,空浪费人力。
为此制定了一些管控方案,首先是debug阶段的新增transform管控,设置为白名单形式,如果在开发阶段新增了transform直接终止编译流程,通过说明文档告知管控的规则,当然,管控的目的是尽可能减少一些不必要的不合理的编译问题,并不是与业务团队作对,如果某一个操作拖慢了整体的编译耗时,但是在app性能/稳定性方面有更大收益,且无法在编译期做更多的优化,仍然是允许添加的,只不过是得提前把这个问题暴露出来而已,能更快的找出更多的解决思路,比如引导使用byteX等transform收敛方案。
另一方面的是合码流程方面的阻塞:今日头条为了保障app的性能稳定性,在合码流程上设置了许多自动化的卡点:如包大小检测,插件依赖变更检查,so变更检查,启动性能检测等,检测到对应问题(如包大小增加异常)会阻塞合码流程。为了管控编译速度,使其不至于恶化的太快,也加上了对应的基于task级别的管控,当某一个task耗时异常波动,或者新增全新类型的task时,能够自动的发现问题,通过机器人将相关人员拉到mr讨论群中,尽量在合码完成前能发现问题。
总结
为了持续稳定的保持较快的编译速度,可能需要做到以下几点:
项目需要有良好的工程结构,对业务模块进行适当粒度的拆分,做好aar/源码的切换不仅能节省javac/kotlinCompile的耗时,也是其他优化方案的基础。工程配置要有区分度,不要所有子模块都用同样的配置,比如根本不会用到kapt功能的模块就别打开kapttask了。transform若无必要,无须新加,或者按级别划分,如今日头条在debug,devMode,release不同的构建级别用到的transform数量是不一致的,尽量让绝大多数人能获得相对最快的编译速度体验,而不会被用不到的功能拖慢速度。一定要新增的transform可以先多用现有的增量方案,如byteX以及本文提供的类信息注入框架,尽量把不要的文件io合并。很多高耗时的官方task(dexBuilder)都是有直接或间接的办法提升其效率的,并且如果除了耗时之外有其他的衡量手段,如本文提到的重新dex率,通过量化数据可以快速的发现问题,进而找到耗时的罪魁祸首。与app性能优化等工作类似,编译速度优化既需要持续进行,也需要一定的问题发现手段,尽量避免问题出现很长一段时间后再去查找原因(那时候可能业务依赖程度会非常高,难以修改)。
更多分享
字节跳动分布式表格存储系统的演进
字节跳动自研强一致在线KV&表格存储实践-上篇
字节跳动自研强一致在线KV&表格存储实践-下篇
字节跳动在Go网络库上的实践
字节跳动-GIP-Android平台架构团队
字节跳动-GIP-Android平台架构团队以服务今日头条产品为主,同时协助公司其他产品,在产品性能、稳定性等用户体验,研发流程,编译优化,架构方向上不断优化和深入探索,以满足产品快速迭代的同时,保持较高的用户体验。我们长期招聘Android平台架构方向的同学,在北京,深圳均有相关岗位,想深入交流或者需要部门内推、投递简历的可以联系邮箱
欢迎关注字节跳动技术团队
今日头条视频网站源码分享和今日头条真实网页源码的问题分享结束啦,以上的文章解决了您的问题吗?欢迎您下次再来哦!