关于android:Android-增量构建的科技与狠活

3次阅读

共计 9438 个字符,预计需要花费 24 分钟才能阅读完成。

本文作者:jungle

形容

最近生存中大家遇到的科技与狠活较多,当 android 的构建用上科技与狠活会不会倒沫子呢,让咱们刮目相待。

前言

对于 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 中未产生扭转的产物移除。

通过上述优化后原来耗时几十秒到几分钟的 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

理论的构建产物如下:

增量构建时,agp 会依照以下规定来执行 dexMerge 工作:

  1. 如果有 jar 文件状态产生变更或者被移除了,即对应状态 CHANGED 或者 REMOVE , 这种状况所有的桶都要从新走 dexMerge 流程,通常默认的 bucket 数量是 16 个,也就是当构建时有一个 jar 文件产生变更时,所有的输出产物全副都会参加 dexMeger 流程。(尽管 d8 命令行工具对增量dexMeger 自身有肯定优化,增量速度比照全量会有肯定放慢,但对于大型项目而言总体还是很慢。)
  1. 如果是只有新增的 jar 或者 dex 产生扭转的Directory,那么会依据对应的包名获取到对应的桶的数组,只对找到的桶的数组进行增量的打包,这也就是咱们说的 dexMerge 自身的增量操作。

返回对应 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 产物如下:

同时咱们须要变更增量 dexMerge 的输入目录,因为 dexMerger 失常运行时,在有代码批改的状况,所有的 bucket 都会被新的产物笼罩,哪怕新的产物是空文件夹。如果不更改文件目录就会笼罩掉之前全量打出的所有的 dex,导致最终的 apk 包仅蕴含这次增量的 dex 从而无奈失常运行。

同时因为每次增量构建变动的产物都不同,因而对每次构建产物的输入目录做了递增,同样是确保上次增量的产物不要被本次笼罩掉,这里每次的产物都对后续构建流程有作用,具体会在后续内容中阐明。

当然,新的目录具体放在哪里,也跟咱们抉择的计划有关系。

热更新计划

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

1. 增量 dex 长期产物

上述尽管有了增量的构建产物,然而为了运行时不便排序依然会每次把当次编译新增的 dex 挪动到长期目录 pulledMergeDex 文件夹中。

而后通过 adb 每次批量清理设施中长期的 dex,再将全副 pulledMergeDex 目录下的 dex 推送到设施中,这样做的目标是为了确保设施中 dex 的准确性,防止因为某次构建残留的 dex 产物运行影响现有的代码逻辑。

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 的产物没有抵触。

打包到 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 文件中。

咱们这边参考原来的 mapping 文件,在每次编译时,将构建产物中的 dex 门路和该 dex 对应 apk 中的理论 dexpath classesX.dex 关联起来做好 mapping,而后存在独自记录的 dex_mapping 文件里。

每次增量编译有新 mergedex 时,先将增量的 dex 依照 classes.dexclasses2.dex… 的顺序排列,而后将 dex-mapping 中的构建产物和 apkdex 门路的关系加载到内存中,依照原有的顺序排列在增量的 dex 前面,最初通过 hook package 流程将变动的内容同步更新到 apk 文件中。

整体流程如下图:

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,实时内存等影响。

以上数据基于个人电脑,2.3 GHz 四核 Intel Core i7,32 GB 3733 MHz LPDDR4X,不同设施跑出的数据会有局部差别,但整体优化成果还是很显著的。

总结

联合上述的优化计划,增量构建速度整体在一个比拟低的程度,当然例如 kotlin 编译,kapt,增量的判断等还有进一步的优化空间,期待后续和其余 task 的进一步优化实现时持续分享。

> 本文公布自网易云音乐技术团队,文章未经受权禁止任何模式的转载。咱们长年招收各类技术岗位,如果你筹备换工作,又恰好喜爱云音乐,那就退出咱们 grp.music-fe(at)corp.netease.com!
正文完
 0