Android编译速度优化

2/11/2023 优化

# 背景

Android工程编译本身是自带缓存的,而且是支持增量编译的,当只修改某个类时并不会触发全量编译,再次运行的速度会很快。

但有几种情况会导致增量编译失效,第一种情况是每次从代码仓库更新代码后,第二种情况是修改了底层基础库的相关内容后,还有其他情况如资源文件发生变更或依赖版本变更等。此时即使修改内容很少也会触发连锁效应,构建系统会查找与该变动相关的内容并重新编译,如果关联的内容较多编译时长也将变的不可控。

其实在开发过程中大部分情况都只在某个模块中修改,而其他模块即使有关联只要隔离模块所使用的的接口不变,也不需要重新编译。

习惯性的还是先到官方指南上查找相应的解决方式。

# 官方指南

编译优化在Android Developer官方已经给出了相关的建议优化构建速度 (opens new window),指南中提到了多项优化措施,包括

  • 指定特定的资源类型
  • maven仓库排列顺序
  • build脚本使用静态值和静态版本号
  • 代码模块化并开启并行编译
  • Gradle配置阶段优化
  • 图片资源转换
  • 增加JVM堆内存并配置JVM并行垃圾回收器
  • 使用非传递非常量R类
  • 停用Jetifier标记

其中图片资源转换与使用非传递非常量R类两项由于项目原因无法实施,其他均按照指南进行了修改,效果最明显的就是开启了并行编译。 但编译总时长仅仅缩短了四分之一左右,从原有的十多分钟减少至近十分钟,而且这仅仅是一个包体积仅为四十多兆的项目。

继续通过官方文档介绍的性能排查分析指南 plugins-breakdown 从Build Analyzer窗口中查找对构建时长影响最大的任务,发现项目中比较耗时的还是源代码转字节码的相关任务,所以当代码量逐渐增加时,如果不通过其他方式继续优化,仍然解决不了当前的痛点。

# 大厂方案研读

继续找找一些大厂的优化方案,其中总结比较全面且指导性较强的为今日头条 Android '秒' 级编译速度优化 (opens new window),文章从技术到管理提到了很多点,其中“模块aar化”收益接近50%,而kapt与transform相关优化因为项目用的少对我们来说作用不大,dexBuilder优化收益也很明显。

综上对我们来说做好预编译提前准备好aar就基本可以满足要求,而dexBuilder优化由于难度较高且与编译工具本身的缓存命中机制有些类似暂时不再继续深入,所有采用aar化+单模块系统编译的方案也可以做到修改少量代码时秒级运行。

# 选择aar本地预编译方案

在方案确定后,还需要思考脚本的侵入性问题,即在不修改原有模块脚本的前提下实现编译时自动监测库模块是否有变更,有变更或首次编译时先打包库模块并提交至工程内的仓库中(使用仓库可以更容易的管理模块间的依赖关系),而没有变更的模块直接使用仓库缓存。

按照此思路确定转换规则 转换规则

最终任务将形成如下依赖关系 任务依赖

# 1. 收集需要缓存的模块

为了简化收集流程且增强使用意识,不使用外部json或xml文件配置的形式来进行配置,高度依赖settings.gradle来配置,这里为了让打包服务器始终保持原始依赖增加过滤条件,使预编译方案只在debug时生效。

gradle.ext {
    preBuild = true //总开关

    preProjectPaths = new HashSet<String>() // 收集需要缓存的工程

    // debug打包时才生效
    preProjectEnable = preBuild && gradle.getStartParameter().taskNames.find {
        it.toLowerCase().contains("debug")
    } != null
}

/**
 * 创建新的方法用于收集
 */
void includeCached(String... proj) {
    gradle.ext.preProjectPaths.addAll(proj)
    include(proj)
}

include ':app:mainA'
includeCached ':feature:libraryA'

# 2. 根据规则计算缓存模块的当前版本号

先设想一下,什么情况下需要重新打aar包,源代码修改肯定需要,资源修改也需要,那索性以最后一次修改文件的时间作为版本号,当然是所有文件中最新的那个文件,另外需要过滤掉一些打包时自动生成的文件。由于aar最后是上传到本地maven仓库,所以aar对应的依赖关系我们也交给maven去管理,那么子依赖的版本更新后就需要重新上传,且原来的旧版本需要清理掉之后新版本才能生效。当然版本的管理不一定是放在当前模块中的,一般都是统一管理的,为了不和统一管理的文件产生强关联,我们直接计算每个依赖的hash值并用异或操作将它们连接,保证只要版本有变化最后生成的hash值也变化也就能保证版本号发生变化而重新打包上传。


/**
 * 找到最新的版本号
 */
String getLastVersion(Project proj, Set<String> preProjectPaths) {
    String lastVersion = Constants.FORMAT.format(getLastModifyTime(proj.getProjectDir(), 0))
    int hash = getProjectDependenciesHash(proj, preProjectPaths)
    return "$lastVersion.$hash"
}

/**
 * 根据文件夹内所有文件找到最新的修改事件
 */
long getLastModifyTime(File dir, int level) {
    // 因为project目录包含有build目录,当这些目录变动是为我们不关心
    if ("build" == dir.name || ".gradle" == dir.name || ".cxx" == dir.name) {
        return 0
    }
    ...
    return lastModifyTime
}

/**
 * 根据依赖的版本生成hash值
 */
int generateHashFromConfiguration(Project proj, int hash, configurationName, Set<String> preProjectPaths) {
    proj.configurations.findByName(configurationName)?.dependencies?.each {
        String version = it.version
        if (it instanceof ProjectDependency) {
            Project dependencyProj = it.dependencyProject
            // 不用计算子依赖转换后的版本号,如果用计算后的版本号,会导致每次底层库修改后,
            // 所有它上层的依赖都需要重新打包,其实只需要它的上一层重新打包就可以了
            if (preProjectPaths.contains(dependencyProj.path)) {
                version = "+"
            }
        }
        String dependencyUri = "${it.group}:${it.name}:${version}"
        hash = hash ^ dependencyUri.hashCode()
    }
    return hash
}

# 3. 为变更的缓存模块配置maven-publish

接下来就是版本号的比对与推送本地maven仓库前的准备工作了,原先的版本号是以文件的形式存在MavenRepository中的,只要判断新版本的文件是否存在就可以了。

// gradle 7.0以上不允许 Cannot run Project.afterEvaluate(Action) when the project is already evaluated.
// publishProj.apply plugin: 'maven-publish'
publishProj.publishing {
    publications {
        maven(MavenPublication) {
            setGroupId(groupId)
            setArtifactId(artifactId)
            setVersion(version)
            artifact(publishProj.tasks.getByName("bundleReleaseAar"))
            // 需要发布的aar总是会包含一些其他的依赖,把这部分依赖也加入到pom中,需要注意的是
            // 本地发布的aar版本为了保证多次嵌套依赖出现版本不一致的问题,version统一采用+
            pom.withXml {
                def dependenciesNode = new groovy.util.Node(null, 'dependencies')
                appendPomDependency(dependenciesNode, publishProj, "implementation", "api", "runtimeOnly")
                asNode().append(dependenciesNode)
            }
        }
    }
    repositories {
        maven {
            setUrl("${project.rootDir}\\$Constants.REPOSITORY")
        }
    }
}

# 4. 将变更的缓存模块的publishing任务建立前后关联关系

发布任务虽然已经有了,但并未关联到构建任务树上,我们先将发布任务间的依赖关系理清楚,将它们前后关联后。

// 为所有的待发布的project建立前后关系
Configuration configuration = proj.configurations.findByName(name)
for (Dependency dependency : configuration.dependencies) {
    if (dependency instanceof ProjectDependency) {
        Project subProj = dependency.getDependencyProject()
        dataHolder.dependencyProjects.add(subProj)

        def subpreProject = dataHolder.preProjectMap[subProj]
        if (subpreProject == null) {
            // 不需要缓存的project暂存,后面将他们那加入到前置任务对应的project依赖中
            preProject.appendDependencies.add(subProj)
        }
        if (subpreProject != null && subpreProject.publishingTask != null) {
            //AppProject===PreProjectA===ProjectB===ProjectC===PreProjectD
            //上面这种情况下PreProjectA与ProjectB关系以及ProjectB与projectC的关系已经存在,但
            //ProjectC与PreProjectD的关系还未建立,ProjectC由于在PreProjectA的依赖节点上,
            //所有它不会被设置为依赖buildAar,我们只需要为它设置PreProjectD的publishingTask依赖就可以了
            if (!dataHolder.preProjectMap.containsKey(proj)) {
                proj.getTasksByName("preBuild", false).forEach {
                    it.dependsOn(subpreProject.publishingTask)
                }
            }

            //已经处理过的PreProject不再进行处理
            Set<PreProject> hasSetProjectS = dataHolder.hasSetCache[preProject]
            if (hasSetProjectS == null || !hasSetProjectS.contains(subpreProject)) {
                preProject.proj.getTasksByName("preBuild", false).forEach {
                    it.dependsOn(subpreProject.publishingTask)
                }
                ...
                hasSetProjectS.add(subpreProject)

                setDependsOn(level + 1, subpreProject, subProj, name, dataHolder)
                preProject.appendDependencies.addAll(subpreProject.appendDependencies)
            } else {
                setDependsOn(level + 1, subpreProject, subProj, name, dataHolder)
            }
        } else {
            setDependsOn(level, preProject, subProj, name, dataHolder)
        }
    }
}

# 5. 将其他模块build任务与buildAar任务建立前后关联关系

接着找到未被依赖的发布任务并将它们关联到buildAar任务的依赖上,buildAar任务将作为一个关键的节点告诉我们所有的发布任务已执行完成。

task buildAar() {
    doFirst {
        Constants.log("aar build done")
    }
}

// 找出所有的有向无环图的根节点,并将这些根节点关联到buildAar任务上
buildAar.dependsOn(preProject.publishingTask)

# 6. 将其他模块build任务与buildAar任务建立前后关联关系

由于其他不需要缓存的工程可能直接或间接的依赖了转换后的本地依赖,如com.aopmeta.local:moduleF:1.0,如果在发布任务还未完成前就去下载肯定会失败,所以还需要将其他正常编译的任务放置在buildAar任务之后。

// 未关联缓存project的其他project统一放到buildAar任务之后
if (!dataHolder.dependencyProjects.contains(eachProj)) {
    for (Task preBuildTask : eachProj.getTasksByName("preBuild", false)) {
        preBuildTask.dependsOn(buildAar)
    }
}

# 7. 动态替换所有dependencies依赖

这时候就可以将所有与需要缓存的工程有依赖关系的部分进行动态的替换了 implementation project(:moduleE) ----> implementation com.aopmeta.local:moduleE:1.0

def configuration = eachProj.configurations.findByName(configurationName)
def iterator = configuration.dependencies.iterator()
def addList = new HashSet<>()
while (iterator.hasNext()) {
    def dependency = iterator.next()
    if (dependency instanceof ProjectDependency) {
        def preProject = preProjectMap[dependency.getDependencyProject()]
        if (preProject != null) {
            // 主动移除原先的project依赖
            iterator.remove()
            addList.add(preProject)
        }
    }
}

if (addList.size() > 0) {
    eachProj.dependencies {
        for (def preProject : addList) {
            // 将依赖替换成url
            invokeMethod(configurationName, preProject.uri)
        }
    }
}

# 8. 找到所有任务图谱外的特殊任务并设置它们的依赖

根据上面的步骤操作下来按照我们对任务的理解应该就已经算是完成准备工作了,但实际操作时发现打包时还没有完成buildAar gradle就开始下载工作了,这边一边调试一边找,找了好久才发现有些任务是不在任务树上的,但它们也会触发依赖的下载工作,所有我们还需要找到它们并让它们在buildAar任务执行后再进行。

// 一些特殊的任务gradle没有将它们放置在task的依赖图中,需要主动找到它们,并为设置依赖,否则它们会提前
// 触发依赖关系的下载工作,此时本地的aar还未完成发布,会出错
gradle.taskGraph.whenReady { graph ->
    def nodeMapping = graph.executionPlan.nodeMapping
    def publishingTaskNodeList = new ArrayList<Node>()
    for (def preProject : preProjectMap.values()) {
        def checkTaskNode = nodeMapping.find {node -> node.hasProperty("task") && node.task == preProject.publishingTask}
        if (checkTaskNode != null) {
            publishingTaskNodeList.add(checkTaskNode)
        }
    }
    Constants.log("all publish nodes: $publishingTaskNodeList\n")
    if (!publishingTaskNodeList.isEmpty()) {
        for (def node : nodeMapping) {
            if (node instanceof ActionNode) {
                Constants.log("add dependency for actionNode: $node\n")
                for (Node publishingTaskNode : publishingTaskNodeList) {
                    // 设置依赖
                    node.addDependencySuccessor(publishingTaskNode)
                }
                node.forceAllDependenciesCompleteUpdate()
            }
        }
    }
}

# 合理打印日志以便问题排查

当然这么复杂的转换过程以及依赖关系,我们还需要通过清晰的日志输出来直观的感受整个过程,以及排查潜在的问题

[pre-build]==> preProjects Dependencies: 
+-- player  
+-- files  
+-- bridge  
    +-- tools  api
+-- login  
    +-- network  implementation
        +-- bridge  implementation (*)
    +-- bridge  implementation (*)
+-- message  
+-- user  
+-- chat  
    +-- network  implementation (*)
    +-- bridge  implementation (*)
+-- network   (*)
+-- tools   (*)
+-- rtc  
[pre-build]==> preProjects [player , files , bridge , login , message , user , chat , network , tools , rtc ]
[pre-build]==> repalced dependencies 
- :app:kind implementation
	+-- :core:network -> com.aopmeta.local:network:230221.223508.1080606243
	+-- :feature:login -> com.aopmeta.local:login:230221.224417.1290457810
	+-- :feature:chat -> com.aopmeta.local:chat:230221.224417.1516173395
	+-- :plugin:player -> com.aopmeta.local:player:230221.224417.1611087619
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771
- :core:bridge api
	+-- :core:tools -> com.aopmeta.local:tools:230221.224511.634340686
- :core:network implementation
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771
- :feature:chat implementation
	+-- :core:network -> com.aopmeta.local:network:230221.223508.1080606243
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771
- :feature:login implementation
	+-- :core:network -> com.aopmeta.local:network:230221.223508.1080606243
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771

[pre-build]==> task depends 
	task ':core:bridge:preBuild' ------<dependsOn> task ':core:tools:publishingTask'
	task ':feature:login:preBuild' ------<dependsOn> task ':core:network:publishingTask'
	task ':core:network:preBuild' ------<dependsOn> task ':core:bridge:publishingTask'
	task ':feature:login:preBuild' ------<dependsOn> task ':core:bridge:publishingTask'
	task ':feature:chat:preBuild' ------<dependsOn> task ':core:network:publishingTask'
	task ':feature:chat:preBuild' ------<dependsOn> task ':core:bridge:publishingTask'
	task ':buildAar' ------<dependsOn> task ':plugin:player:publishingTask'
	task ':buildAar' ------<dependsOn> task ':feature:files:publishingTask'
	task ':buildAar' ------<dependsOn> task ':feature:login:publishingTask'
	task ':buildAar' ------<dependsOn> task ':data:message:publishingTask'
	task ':buildAar' ------<dependsOn> task ':data:user:publishingTask'
	task ':buildAar' ------<dependsOn> task ':feature:chat:publishingTask'
	task ':buildAar' ------<dependsOn> task ':feature:rtc:publishingTask'
	task ':app:kind:preBuild' ------<dependsOn> task ':buildAar'

[pre-build]==> publish nodes: [:plugin:player:publishingTask, :feature:files:publishingTask, :core:bridge:publishingTask, :feature:login:publishingTask, :data:message:publishingTask, :data:user:publishingTask, :feature:chat:publishingTask, :core:network:publishingTask, :core:tools:publishingTask, :feature:rtc:checkPublishTask]

...
[pre-build]==> ---------------------------aar build finished-----------------------
[pre-build]==> preProjects Dependencies: 
+-- chat  Cached 
+-- files  Cached 
+-- rtc  Cached 
+-- bridge  Cached 
+-- message  Cached 
+-- network  Cached 
+-- player  Cached 
+-- login  Cached 
+-- tools  Cached 
+-- user  Cached 
[pre-build]==> preProjects [chat  Cached, files  Cached, rtc  Cached, bridge  Cached, message  Cached, network  Cached, player  Cached, login  Cached, tools  Cached, user  Cached]
[pre-build]==> repalced dependencies 
- :app:kind implementation
	+-- :core:network -> com.aopmeta.local:network:230221.223508.1080606243
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771
	+-- :feature:chat -> com.aopmeta.local:chat:230221.224417.1516173395
	+-- :plugin:player -> com.aopmeta.local:player:230221.224417.1611087619
	+-- :feature:login -> com.aopmeta.local:login:230221.224417.1290457810
- :core:bridge api
	+-- :core:tools -> com.aopmeta.local:tools:230221.224511.634340686
- :core:network implementation
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771
- :feature:chat implementation
	+-- :core:network -> com.aopmeta.local:network:230221.223508.1080606243
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771
- :feature:login implementation
	+-- :core:network -> com.aopmeta.local:network:230221.223508.1080606243
	+-- :core:bridge -> com.aopmeta.local:bridge:230221.224417.500923771

[pre-build]==> task depends 
	task ':app:kind:preBuild' ------<dependsOn> task ':buildAar'

[pre-build]==> publish nodes: []

> Task :buildAar
[pre-build]==> ---------------------------aar build finished-----------------------