Android编译提速黑科技—Wade Plugin
共 3374字,需浏览 7分钟
·
2021-12-17 12:56
作者:潘涛
随着得物App业务高速发展,Android项目的代码量与组件数量迅速增加,项目编译时长也明显升高。今年初增量编译平均耗时接近3.9分钟,严重影响了开发效率,也促使我们探索各种措施缩短编译时间提升开发效率。
背景
四月初通过一系列常规优化,如改造增量注解处理器、 增量Transform、组件化、工程化、优化项目配置等等,耗时缩短到2.3分钟。六月,Wade Plugin 第一版上线,进一步缩短到1.3分钟。八月, Wade第二个大版本上线,最终将增编耗时降低到了0.8分钟。本文主要介绍Wade Plugin的技术原理和实现思路。
简介
Wade Plugin是得物Android自研的Gradle插件,用于提升编译速度。常规优化手段用尽以后, 项目的增编耗时仍需2.3分钟, 其中DexArchiveBuilder、MergeProjectDex、 MergeLibDex、MergeExtDex占了1.7分钟。不难看出,如果要进一步降低耗时, 应该挖掘DexBuild和DexMerge的优化空间。
Wade Plugin通过Hook Android原生的编译流程, 将原生的DexBuildTask替换为WadeDexBuild, 原生的DexMergeTask替换为WadeDexMerge。原生DexBuild平均耗时60秒, WadeDexBuild只需12秒; 原生DexMerge平均耗时42秒, WadeDexMerge只需2秒。
WadeDexBuild
调用Dx或D8工具完成Class到Dex的转换这一过程称为DexConvert, 它占了DexBuild大部分耗时。原生DexBuild以Jar和Class为粒度执行DexConvert。得物工程中平均1个Jar包含200+个.class, 相当于增量时每改动一个类会触发200个类执行DexConvert。
理想情况是只有改动的Class参与DexConvert.
优化方案
在DexConvert执行前, 解压缩Jar, 以.class为粒度执行DexConvert。并且只有其中变更的.class参与, 未变更的.class执行结果复用上次编译缓存。具体有四种变更类型对应的缓存复用策略: 对于新增的.class, 需要参与DexConvert;改动的.class参与DexConvert;移除的.class不参与DexConvert;未改变的也不参与。
其中, 移除.class的情况要特殊处理. 例如Demo.class移除后, 除了相应删除产物Demo.dex, 还要寻找它的内部类的产物Demo$1.dex, Demo$2.dex等。
主要实现
输入(Task Inputs)
首先要根据Consumer Transform决定参与WadeDexBuild的.class文件路径, 消费Transform Inputs的Transform即Consumer Transform。当接入了一个Consumer Transform, 它的输出路径参与编译; 没有Consumer Transform时, Java Compile、Kotlin Compile的输出路径参与编译; 如有多个Consumer Transforms, 取最后一个Transform的输出路径作为DexBuild输入。
增量编译触发条件
触发条件决定了本次编译是否走增量逻辑, 以及上次编译的缓存是否可用。WadeDexBuild的增量条件包括五大类共28条(AGP3和AGP4略有不同):
Gradle配置
AndroidJarClasspath
DesugaringClasspathClasses
ErrorFormatMode
MinSdkVersion
Dexer
UseGradleWorkers
InBufferSize
Debuggable
Java8LangSupportType
ProjectVariant
NumberOfBuckets
DxNoOptimizeFlagPresent
Wade配置
WadeExtension.scope
WadeExtension.duplicateClass
WadeExtension.dexBucketSize
WadeExtension.jarBucketSize
Wade缓存
ProjectWorkspaceDir
SubProjectWorkspaceDir
ExternalLibWorkspaceDir
MixedScopeWorkspaceDir
输入文件
ProjectClasses
SubProjectClasses
ExternalLibClasses
MixedScopeClasses
产物文件
ProjectOutputDex
SubProjectOutputDex
ExternalLibOutputDex
MixedScopeOutputDex
Dex Convert
WadeDexBuild关键步骤是将原生Dex Convert由Jar为粒度转换为Class为粒度执行。首先解压缩Jar, 解压后的.class写入缓存目录, 再将参与上次编译的Class与参与本次编译的Class文件逐个对比, 只有新增和变更的Class参与Dex Convert, 移除和未改变的直接删除或沿用对应缓存。
性能优化
Dex Convert粒度由Jar转换为Class后耗时明显降低。但项目中共有423个Jar, 解压后83000+个Class, 导致Dex Convert前解压缩和文件对比两个步骤非常耗时。对这两步的优化主要有三方面。
ForkJoinPool
用ForkJoinPool替代传统的ExecutorService做并发, 因为它的Work Steeling算法特别适合小文件, 任务数特别多的场景, 能够最大化利用CPU空闲时间。
mmap
文件对比是I/O密集型任务, 普通文件流的读写速度较慢。Wade Plugin所有I/O操作都用mmap实现, 包括读、写、拷贝等。文件流替换为mmap对整体速度提升有很明显的效果。
CRC-32代替MD5
对比两文件是否相同的常规做法是先比较文件长度, 再校验文件MD5是否一致。由于Class数量太多, 计算MD5的耗时非常可观。用CRC-32算法计算文件Hash, 作为Checksum来代替MD5能减少文件对比的时间。
CRC-32计算的Checksum可靠性不如MD5, 理论上会有Hash碰撞, 导致修改Class修改后被误判为未修改, 接着使用缓存而非最新文件参与编译, 反映到产物APK上意味着这次修改无效。但是实际发生概率极低, 整体来看值得牺牲理论上的正确性来保证每次编译的效率。
优化效果
优化后解压缩、写缓存平均耗时5700ms, 文件对比耗时得益于CRC-32算法只需10ms, DexBuild整体耗时从原生的60秒降低到12秒。
WadeDexMerge
优化方案
主要实现
如图,慢指针指向上次编译的文件数组, 快指针指向本次编译的文件数组, 对比两个指针的文件, 如果相同则快指针指向下一个文件, 直到找到不同, 此时慢指针指向下一个文件, 再开始下一轮对比。伪代码如下:
long fast = 0
long slow = 0
while (slow < prev.size()) {
long temp = fast
while (temp < curr.size()) {
if (prev[slow] == curr[temp]) {
break
}
temp++
}
if (temp != curr.size()) {
fast = temp
boolean isModified = isModified(prev[slow], curr[fast], reuseScope)
if (isModified) { //found difference
fileChanges.add(new DefaultFileChange(prev[slow], ChangeType.MODIFIED))
}
} else {//not found
fileChanges.add(new DefaultFileChange(prev[slow], ChangeType.REMOVED))
}
slow++
}
桶总数和桶内文件数(Bucket Size)直接影响到增量效果。理论上, 分桶越多越好, 如果有100个Bucket, 相当于增量只需1/100的全量Merge时间。但Bucket越多意味着APK内.dex越多, 又会影响到包体积、安装时间和首次启动耗时。经过多次试验, Bucket总数在50~100个时综合效果最好, Merge耗时降低明显, 副作用也不大。目前得物工程中共有66个Bucket, 其中Jar类型23个, Dex类型43个。
高可用
在高可用建设方面, 主要通过数据统计、建立编译情况监控、编译指标周报及时获取大盘情况和发现问题; 兼容不同AGP和Gradle版本以提高插件的兼容性; 持续监控编译异常并迭代修复问题提高稳定性。
七大指标
七个指标反映团队的编译总体情况:
增量编译耗时
平均编译耗时
全量编译耗时
增量编译耗时50分位值
增量占比
编译成功率
人均编译总时长
指标的计算依赖埋点数据上报, 埋点中部分字段的值较难获取。例如本次编译的JavaCompileTask是否为增量, 需通过对AGP和Gradle插桩实现, 有三处Hook点可以切入。
Wade早期版本使用方案一, 实际使用发现Hook Gradle的类兼容性较差。目前使用方案二, Hook AGP的com.android.build.gradle.tasks.JavaCompileCreationAction
类, 注入WadeJavaCompile
类代替原生的org.gradle.api.tasks.compile.JavaCompile
类。WadeJavacCompile
是JavaCompile
的包装类, 重写compile()
取到Javac的增量标识inputs.isIncremental
. 伪代码如下:
public class WadeJavaCompile extends JavaCompile {
...
private static File mFile;
protected void compile(IncrementalTaskInputs inputs) {
...
boolean isIncremental = inputs.isIncremental();
try {
FileUtils.writeStringToFile(mFile, "isIncremental:" + isIncremental + "\n", true);
} catch (IOException e) {
...
}
super.compile(inputs);
}
...
}
对AGP原生类的Hook过程大致可分为3步, 获取Gradle的VisitableURLClassLoader
, 用ASM或Javassist编辑目标类的字节码, 反射调用ClassLoader.defineClass()
加载编辑后的字节码。
VisitableURLClassLoader
。因此, Wade插件接入要求在Root Project中apply wade plugin
, 以确保Hook代码能在App Project的apply android plugin
之前执行。兼容性
稳定性
实际使用过程中遇到了各种疑难杂症, 这里列出前10个常见异常。
java.io.IOException: The input doesn't contain any classes. Did you specify the proper '-injars' options?
java.io.FileNotFoundException: /Users/panes/app/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar (No such file or directory)
Caused by: com.android.tools.r8.utils.b: Error:YeezyCompleteListener.class, Type com.xxx is defined multiple times
Caused by: org.gradle.api.UncheckedIOException:java.util.zip.ZipException: error in opening zip file
Caused by: com.android.tools.r8.utils.b: Error: Class content provided for type descriptor xxx.r actually defines class com.xxx.R
A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade
com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: Type com.xxx.R is defined multiple times
base.apk code is missing
Archive is not readable : /Users/panes/android/app/build/intermediates/mixed_scope_dex_archive/developerDebug/out/c6795cc73f81ff9c1c0b5d0adb06b1b4161c540cbf761ba11415aae4856b11b4_4.jar
Could not determine dependencies of app:wadeInputChangesInspect
经过近30个版本的迭代, 这些问题都已解决。最近版本v2.6.4上线至今经历6800次编译, 异常次数4次。
基准测试
Benchmark跑分显示, 10次增量编译(只改动一行代码)的平均耗时14.4秒, 10次无量编译(代码不变)平均耗时6.2秒。跑分时清理后台任务、关闭了其他占用资源的进程, 但实际编译环境比理想环境复杂得多, 基准测试只用于验证理论是否有效。
总结
Wade Plugin开发过程中困难重重, 重写Android原生的编译流程做到既大幅提升速度又保证稳定可靠并非易事。其中还有更多细节未介绍到, 如增编时识别热点代码、复用文件变更计算结果、Hook PackageTask做Apk内文件兜底防止出包异常。同时也期待后续版本能有更多提升。
• 耗时2年,Android进阶三部曲第三部《Android进阶指北》出版!
• 『BATcoder』做了多年安卓还没编译过源码?一个视频带你玩转!
• 重生!进阶三部曲第一部《Android进阶之光》第2版 出版!
BATcoder技术群,让一部分人先进大厂
大家好,我是刘望舒,腾讯最具价值专家TVP,著有三本业内知名畅销书,连续四年蝉联电子工业出版社年度优秀作者,百度百科收录的资深技术专家。
想要加入 BATcoder技术群,公号回复
BAT
即可。
为了防止失联,欢迎关注我的小号
微信改了推送机制,真爱请星标本公号👇