Android 增量构建的科技与狠活

图片来自:medium.com/mindorks/im…

本文作者:jungle

前言

关于 Android 使用,尤其是大型使用而言,构建耗时是令人头疼的一件事。动辄几分钟甚至十几分钟的时刻更是令大部分开发人员苦不堪言。而在实践开发过程中面对最多的便是本地的增量编译,尽管官方对增量编译有做处理,但在详细项目,尤其是中大型项目中,作用其实都不太抱负。

布景

现在网易云音乐及旗下 look 直播,心遇,musapp 先后采取了公共模块 aar 化,使用最新 agp 版别等措施,但全体构建耗时依然很久,增量构建一般在 2-5 min 左右。因为自己当时首要是担任开发 mus 的事务,因而结合现在 mus 的实践构建状况对增量构建做了一些优化作业。

耗时排查

结合 mus 构建的详细状况来看,现在构建耗时的大头首要会集在一些 TransformdexMerge ( agp 版别 4.2.1 )。

关于 Transform 而言,首要是一些例如隐私扫描,自动化埋点等东西耗时严峻,一般增量时这些 Transform 的耗时就到达数分钟。

别的 dexMeger 使命也是增量构建时的大头,mus 增量 dexMerge 耗时约为 35-40s ,云音乐 dexMerge 增量构建耗时约 90-100s 。

优化方向

关于大型项目而言,最耗时的根本便是 Transform 了,这些 Transform 一般分为以下两类:

  1. 功能型 Transform,移除只会影响自己的功能部分,不影响构建产品和项目运转。例如:埋点校验,隐私扫描。
  2. 强依靠型 Transform ,移除影响编译或项目正常运转。这部分一般是在 apt 中采集一些信息,然后在 Transform 履行时生成 class ,在运转时调用履行。

功能型 Transform 能够通过编译开关和 debug/release 判别,防止在开发时调用履行。关于强依靠的 Transform 能够通过字节开源的 byteX 之类的东西将 Transform 流程拍平,对增量和全量编译都有作用。但是 byteX 的侵入性较大,需求将现有的 Transform 改成字节供给的 Transform 的子类。这儿咱们选用一种修正构建输入产品的轻量级计划来完结 Transform 增量构建的优化。

一起关于 dex 相关操作耗时的点,能够结合 dexMerge 的实践流程做增量优化,保证只有最小粒度的改动点会触发 dexmerge 操作。

Trasnform 增量构建

尽管 mus 现在依靠的大部分 TransformisIncremental 装备回来 true ,但是实践的 io 和插桩很少有做增量逻辑的。

在增量构建时,大部分 class 在第一次构建时现现已过各 Transform 的处理,被插桩修正后移动到对应的下一级 Transform 目录了,增量时这部分现已处理过的产品其实没有必要再在各 Transform 之间履行插桩和 io 了。

现在大部分 Transform 的写法都是如下写法:

input.jarInputs.each { JarInput jarInput ->
    ile destFile = transformInvocation.getOutputProvider().getContentLocation(destName , jarInput.contentTypes, jarInput.scopes, Format.JAR)
    FileUtils.copyFile(srcFile, destFile)
}
input.directoryInputs.each { DirectoryInput directoryInput ->
    File destFile = transformInvocation.getOutputProvider().getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
    ...
    FileUtils.copyDirectory(directoryInput.file, destFile)
}

这儿在增量构建时应该做的是只对产生改动的产品做插桩和 copy 的操作:

// 伪代码如下:
// jar 增量处理
if(!isIncremental) return
if (Status.ADDED ==jarInput.status || Status.CHANGED==jarInput.status){
    File destFile = transformInvocation.getOutputProvider().getContentLocation(destName , jarInput.contentTypes, jarInput.scopes, Format.JAR)
    FileUtils.copyFile(srcFile, destFile)
}
// class 增量处理
val dest = outputProvider!!.getContentLocation(
        directoryInput.name, directoryInput.contentTypes,
        directoryInput.scopes, Format.DIRECTORY
)
if(Status.ADDED ==dirInput.status || Status.CHANGED==dirInput.status){
    dirInput.changedFiles.forEach{
        // 插桩逻辑
        ...
        // 只移动增量改动插桩后的class文件到对应目录下
        copyFileToTarger(it,dest)
    }
}

当然因为一些历史原因,有些 Transform 的代码或许都找不到,无法改造,因而为了兼容一切状况,这边简略对 Transform 的输入产品做了简略的 hook 替换操作。

一般完结一个 Transform 都是新建一个类完结 Trasnformtransform 方法,在 transform 方法里履行详细操作,而 Trasnform 产品的入参正是在 com.android.build.api.transform.TransformInvocation#getInputs 的方法里:

public interface TransformInvocation {
    Context getContext();
    /**
     * Returns the inputs/outputs of the transform.
     * @return the inputs/outputs of the transform.
     */
    @NonNull
    Collection<TransformInput> getInputs();
  	...
}

通过 hookTransformInvocation#getInputs 回来的 JarInputDirectoryInput ,将 JarInputsDirectory 中未产生改动的产品移除。

Android 增量构建的科技与狠活

通过上述优化后本来耗时几十秒到几分钟的 Transform 根本都能被压缩到1-2 s以内。

DexMerge 增量优化

事实上 agp 版别更新十分频频,关于不同版别,dex 耗时不同。关于 3.x 的版别 dex 相关 task 首要耗时会集在dexBuilder上,而4.x的版别首要耗时则会集在dexMerger,因为现在 mus 等事务都使用 4.2 及以上版别的 agp ,研讨发现 4.x 的版别实践上对 dexBuilder 有做了增量的处理,全体耗时不多,因而首要对4.2及以上版别 dexMerger 耗时做优化。

顾名思义,dexMerge 实践上是对现已打出的 dex 进行合并,将多个dex 或许 jar 组成一个较大的 dex 的流程。依照正常状况,dex 数量越多,使用的启动速度越慢,因而关于大型项目,dexMerge 也是必不可少的一步。

dexMerge 流程

dexMerger 是有分桶操作的,桶的数量一般不额外装备使用默认值 16,一般桶的分配逻辑是依照包名来的,也便是说同一包名下的 class 会被分配到同一个桶里。

fun getBucketNumber(relativePath: String, numberOfBuckets: Int): Int {
    ...
    val packagePath = File(relativePath).parent
    return if (packagePath.isNullOrEmpty()) {
        0
    } else {
        when (numberOfBuckets) {
            1 -> 0
            else -> {
                // 同一包名下class被分到同一个bucket里
                val normalizedPackagePath = File(packagePath).invariantSeparatorsPath
                return abs(normalizedPackagePath.hashCode()) % (numberOfBuckets - 1) + 1
            }
        }
    }
}
public val File.invariantSeparatorsPath: String
    get() = if (File.separatorChar != '/') path.replace(File.separatorChar, '/') else path

实践的构建产品如下:

Android 增量构建的科技与狠活

增量构建时,agp 会依照以下规矩来履行 dexMerge 使命:

  1. 假如有 jar 文件状况产生改动或许被移除了,即对应状况 CHANGED 或许 REMOVE ,这种状况一切的桶都要从头走 dexMerge 流程,一般默认的 bucket 数量是 16 个,也便是当构建时有一个jar文件产生改动时,一切的输入产品悉数都会参加 dexMeger 流程。(尽管 d8 命令行东西对增量dexMeger 自身有必定优化,增量速度对比全量会有必定加快,但关于大型项目而言全体还是很慢。)

Android 增量构建的科技与狠活

  1. 假如是只有新增的 jar 或许 dex 产生改动的Directory,那么会依据对应的包名获取到对应的桶的数组,只对找到的桶的数组进行增量的打包,这也便是咱们说的 dexMerge 自身的增量操作。

Android 增量构建的科技与狠活

回来对应bucket id 数组的代码如下:

private fun getImpactedBuckets(
    fileChanges: SerializableFileChanges,
    numberOfBuckets: Int
): Set<Int> {
    val hasModifiedRemovedJars =
        (fileChanges.modifiedFiles + fileChanges.removedFiles)
            .find { isJarFile(it.file) } != null
    if (hasModifiedRemovedJars) {
      	// 1. 假如有CHANGED或许REMOVE状况的jar,则回来悉数bucket数组。
        return (0 until numberOfBuckets).toSet()
    }
  	// 2. 假如是新增jar,或许是directory中class产生改动,回来计算到的bucket数组。
    val addedJars = (fileChanges.addedFiles).map { it.file }.filter { isJarFile(it) }
    val relativePathsOfDexFilesInAddedJars =
        addedJars.flatMap { getSortedRelativePathsInJar(it, isDexFile) }
    val relativePathsOfChangedDexFilesInDirs =
        fileChanges.fileChanges.map { it.normalizedPath }.filter { isDexFile(it) }
    return (relativePathsOfDexFilesInAddedJars + relativePathsOfChangedDexFilesInDirs)
            .map { getBucketNumber(it, numberOfBuckets) }.toSet()
}

这种增量操作适用的是大部分代码包括在壳工程中且不会频频改动底层库的事务,不知道是不是因为国外包括 google 官方自身项目开发形式便是这样。关于大部分国内的项目,只需你做了组件化,甚至没做事务组件化但是有多个子模块类型的项目,只需有涉及到子模块的改动,一切的产品都要悉数从头参加 dexMerge

关于 mus ,云音乐等组件化工程,一般构建时只有壳工程是以文件夹的形式作为输入产品在后续的 Transformdex 相关流程里流通,而子模块一般是以 jar 的形式参加构建,而咱们实践开发中根本便是对各事务模块的改动,对应上述第一种状况,一切的桶悉数会从头走的 dexMerger,而第二种状况只有改动壳工程代码或许新增依靠或许模块之类的才会命中,这种状况偏少能够不用考虑。

针对上述问题解决方法首要有两种:

  1. 将一切的 jar 拆解为文件夹,这样只有改动模块对应的分桶收效,但是这种问题在于哪怕只改动了一个模块中的两个类,因为 bucket 是依照包名固定分在同一个桶里,非相同包名则依据包名随机分桶,很或许也会连带着其他的 bucket 一起进行 dexMerger ,尽管能够恰当扩大分桶的数量,但是同样的,也没法彻底躲避这种问题。

  2. 仅针对产生改动的输入产品进行从头的 dexMerger,将新生成的 merge 后的 dex 打进 apk 或许移到设备中保证运转时增量改动的这部分代码能够被履行。

为了保证最小化单元的 dex 参加后续的 dexMerge 流程,咱们选用第二种方式作为 dexMerge 增量构建的计划。

增量构建产品的 dexMerge

通过 hook dexMerge 的要害流程,咱们能够获取到产生改动的 jar 文件和包括 dex 的文件夹,然后把 dexMerge 输入产品由本来的悉数产品修正为咱们 hook 之后的产品:

咱们将一切产生改动的 dex 文件汇总移动到暂时的文件目录内,然后将方针文件夹作为一个输入产品即可,关于产生改动的 jar,咱们也将其加到输入的产品里,然后持续走本来的 dexMerge 流程。

打出来的增量 dex 产品如下:

Android 增量构建的科技与狠活

一起咱们需求改动增量 dexMerge 的输出目录,因为 dexMerger 正常运转时,在有代码修正的状况,一切的 bucket 都会被新的产品覆盖,哪怕新的产品是空文件夹。假如不更改文件目录就会覆盖掉之前全量打出的一切的 dex ,导致终究的 apk 包仅包括这次增量的 dex 从而无法正常运转。

一起因为每次增量构建改动的产品都不同,因而对每次构建产品的输出目录做了递加,同样是保证前次增量的产品不要被本次覆盖掉,这儿每次的产品都对后续构建流程有作用,详细会在后续内容中阐明。

当然,新的目录详细放在哪里,也跟咱们挑选的计划有联系。

热更新计划

因为有了增量的 dex,咱们很容易联想到热更新的计划,行将增量构建出的 dex 推送到手机 sd 卡上,然后在运转时去动态加载。这种状况下增量 mergedex 产品放在哪个目录下都能够,因为对后续构建流程现已没有什么太大影响了,影响的首要是运转时 dex 的加载逻辑。

1. 增量 dex 暂时产品

上述尽管有了增量的构建产品,但是为了运转时方便排序仍然会每次把当次编译新增的 dex 移动到暂时目录 pulledMergeDex 文件夹中。

Android 增量构建的科技与狠活

然后通过 adb 每次批量整理设备中暂时的 dex ,再将悉数 pulledMergeDex 目录下的 dex 推送到设备中,这样做的意图是为了保证设备中 dex 的准确性,防止因为某次构建残留的 dex 产品运转影响现有的代码逻辑。

Android 增量构建的科技与狠活

2. 运转时动态加载 dex

因为 dex 的加载是依照 PathList 加载 dexElements 数组的顺序早年往后加载的,因而只需依照 dex 的热更计划,在运转时反射替换 PathClassLoader 中的 dexElements 数组,将之前推送到手机目录中的数组,依照倒序先摆放好,然后再插入在 dexElements 数组最前面即可,这儿热更新的详细原理不再阐述。

接入项目中实测发现有些代码改动会不收效(首要是 ApplicationApplication 直接引用到的 class),详细原因应该是 Android N 对热补丁的影响,本地在 AndroidMainfest 文件中加了 safemode=true,但在实践设备运转还是无效,不知道是不是现在设备的版别不支撑了。别的一种可行的方式便是类似 tinker 的解决计划对 Application 进行改造,然后通过别的的 ClassLoader 加载后续的 class 了。

Dex 重排计划

除了在运转时加载 dex,咱们也能够测验在编译时将增量的 dex 打包到 apk 中。

gradle 中对应的 task 都有对应的构建缓存,假如咱们增量的 dex 放置在一个随机目录中,后续的 task 例如 packageassemble 等检测输入产品没有改动的状况下,是会直接走增量构建缓存的,也就不会再履行了。而咱们期望咱们增量的 dex 被打进 apk 中,后续的 packagetask 必须要被履行。

这种状况下,构建产品的目录就比较有讲究了,咱们能够取个巧,在之前 dexMeger 全量产品输出的目录下,添加一个 incremental 文件夹,专门做增量产品的 dexMeger,同样的每次增量的产品在该文件目录下依照 index 递加,这样保证每次增量 dexMerge 的产品没有抵触。

Android 增量构建的科技与狠活

打包到 apk 中的 dex 同样也是会依照 dex 的摆放顺序加载履行,因而咱们需求将新增的 dex 在编译时就摆放在 apk 的最前面。 apkdex 的排序是在 package 使命中去履行的,因而咱们需求测验去 hook package 的要害途径,将咱们新增的 dex 排在 Apkdex 数组最前面。

Android Package 流程 hook

Android package 担任将之前打包流程中的一切产品汇总打包到终究对外输出的 apk 产品里,dex 自然也不例外。Android package 会结合产品的改动对 apk 中产生改动的文件做更改,将 apk 中对比 CHANGED REMOVED 的文件删去,然后将构建产品中 ADDEDCHANGED 的产品从头添加到 apk 中去。

public void updateFiles() throws IOException {
    // Calculate packagedFileUpdates
    List<PackagedFileUpdate> packagedFileUpdates = new ArrayList<>();
  	// dex 文件的改动
    packagedFileUpdates.addAll(mDexRenamer.update(mChangedDexFiles));
		...
    deleteFiles(packagedFileUpdates);
		...
    addFiles(packagedFileUpdates);
}
private void deleteFiles(@NonNull Collection<PackagedFileUpdate> updates) throws IOException {
  			// 当时 CHANGED REMOVED 状况的文件 先移除apk
        Predicate<PackagedFileUpdate> deletePredicate =
                mApkCreatorType == ApkCreatorType.APK_FLINGER
                        ? (p) -> p.getStatus() == REMOVED || p.getStatus() == CHANGED
                        : (p) -> p.getStatus() == REMOVED;
				...
        for (String deletedPath : deletedPaths) {
            getApkCreator().deleteFile(deletedPath);
        }
    }
private void addFiles(@NonNull Collection<PackagedFileUpdate> updates) throws IOException {
  			// NEW CHANGED 状况的文件 添加进apk
        Predicate<PackagedFileUpdate> isNewOrChanged =
                pfu -> pfu.getStatus() == FileStatus.NEW || pfu.getStatus() == CHANGED;
				...
        for (File arch : archives) {
            getApkCreator().writeZip(arch, pathNameMap::get, name -> !names.contains(name));
        }
    }

文件联系则通过 DexIncrementalRenameManager 来保护,DexIncrementalRenameManager 每次会先去 dex-renamer-state.txt 去加载当时的 dex mapping 联系,结合改动的 dex 去对 apk 中文件做更改,一起每次排序完结后会将新的 dex mapping 更新在 dex-renamer-state.txt 文件中。

Android 增量构建的科技与狠活

咱们这边参考本来的 mapping 文件,在每次编译时,将构建产品中的 dex 途径和该 dex 对应 apk 中的实践 dexpath classesX.dex 相关起来做好 mapping ,然后存在单独记录的dex_mapping文件里。

Android 增量构建的科技与狠活

每次增量编译有新 mergedex 时,先将增量的 dex 依照 classes.dexclasses2.dex… 的顺序摆放,然后将 dex-mapping 中的构建产品和 apkdex 途径的联系加载到内存中,依照原有的顺序摆放在增量的 dex 后面,最后通过 hook package 流程将改动的内容同步更新到 apk 文件中。

全体流程如下图:

Android 增量构建的科技与狠活

apk 更新完结后,将最新的的 dexapkdex 途径的 mapping 联系从头写到 dex_mapping 文件记录最新的的 dexapk path 的联系。为了防止每次 dex 悉数参加重排,能够在 classes.dexclassesN.dex 中预留必定数量的空位,防止每次一切 dex 重排。

实测 package 会有部分耗时添加,全体应该在 1s 以内,mus 全体 dexMerge 耗时由 35-40 s 缩减到3 s 左右。

现在该增量构建组件两种计划都支撑,能够依据开关装备,要注意的点是热更的计划或许涉及到Application的改造。

优化作用

通过上述计划的优化,实测在 mus 中抱负状况下更改子模块中一行最简略的 kotlin 类中的一行代码 task 总耗时(不包括 configure )最快约 10s,实践开发状况来看根本在 20-40s 之间。这部分耗时首要是实践开发改动的 class 和模块会多一些,一起包括了configure 的耗时,这部分时刻现在是无法防止的。一起也包括 class 编译和 kapttask 一起的耗时,也会遭到设备的 cpu ,实时内存等影响。

Android 增量构建的科技与狠活

以上数据基于个人电脑,2.3 GHz 四核 Intel Core i7,32 GB 3733 MHz LPDDR4X,不同设备跑出的数据会有部分差异,但全体优化作用还是很明显的。

总结

结合上述的优化计划,增量构建速度全体在一个比较低的水平,当然例如kotlin编译,kapt,增量的判别等还有进一步的优化空间,期待后续和其他 task 的进一步优化完结时持续分享。

本文发布自网易云音乐技能团队,文章未经授权禁止任何形式的转载。咱们终年招收各类技能岗位,假如你预备换作业,又恰好喜爱云音乐,那就参加咱们 grp.music-fe(at)corp.netease.com!