有赞 Android 编译进阶之路——全量编译提效方案

时间:2022-07-23
本文章向大家介绍有赞 Android 编译进阶之路——全量编译提效方案,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

作者:Silas

团队:零售移动

前言:有赞移动技术沙龙刚过去不久,相信很多同学对《有赞Android秒级编译优化实践》的分享还记忆犹新,分享中提到了全量编译提效与增量编译提效两种方案。本期我为大家详细介绍下基于 EnjoyDependence 的全量编译提效方案。

一、项目背景

经过多年的发展,有赞零售 Android 项目代码已经达到 45W+ 的规模(phone&pad),其中 kotlin 代码占比33%左右,在如此大规模的代码量下,编译逐步成为我们项目加速的桎梏(PC配置:MacBook Pro i5-8G;时间:全量15min+),严重影响了我们的开发效率,阻碍我们的发展。为了彻底解决编译慢这一业内难题,我们今年下半年基于已有的组件化工程,展开了编译提效的项目,EnjoyDependence 就诞生于这个阶段。

二、编译提速目标

  1. 全量编译从 15min+ 降至 3min 内
  2. 低侵入性,尽量不改造工程结构,保证工程稳定
  3. 方案稳定可靠,不能影响业务同学的开发效率
  4. 易于扩展,可以灵活对接各种已有系统
  5. 方便管理,尽可能保证低廉的学习理解成本,方便大家上手

三、全量编译提效核心——EnjoyDependence

简介:狭义上 EnjoyDependence 是集依赖管理、构建发布、编译耗时统计等功能的 Gradle 插件。广义上指代完成全量编译优化的各种组成:EnjoyDependence Gradle 插件、接入中间件、自动化脚本、EnjoyManager AS 管理插件等。如不特殊指明,EnjoyDependence 仅指代 EnjoyDependence Gradle 插件。

3.1 EnjoyDependence 特点

为了达成编译提效的目标,EnjoyDependence 经过多次优化迭代后具备了如下的特点,奠定了编译提效战役胜利的基础。

3.2 EnjoyDependence 实现原理

这一小节涉及到一些 gradle 基础知识,如有不了解的同学可以通过《Android Gradle 权威指南》和《Gradle For Android 中文版》来加深对原理的认识。

架构图

这里给大家提供一张 EnjoyDependence 的架构图,方便大家从整体到局部,由浅入深的理解 EnjoyDependence 的原理。

接下来的章节,我们从底层剖析 EnjoyDependence 的实现原理,主要包括:aar 发布、依赖管理、自动发布等内容。

aar发布

由于我们的工程是典型的组件化架构,这也是我们此次编译提效的大前提。独立的模块划分使我们可以方便地针对单模块实现编译、测试、发布等常规任务。发布是整个全量编译提效方案的基础,只有稳定可靠的aar发布才能保证全量aar构建的可靠。

正如大家平常使用 gradle 脚本发布 aar 到 maven 一样,我们的发布也是基于 Maven Plugin 来完成的。不同的是,我们为了对发布的核心流程:pom.xml 文件生成、构件收集更有掌控力,同时兼容多种flavor,我们没有采用现成的 maven 发布,而是 hook 了 maven 发布流程,在其中嵌入了我们自己的逻辑。

project.plugins.apply(MavenPublishPlugin)
        project.pluginManager.withPlugin('com.android.library', newAction<AppliedPlugin>() {
@Override
void execute(AppliedPlugin appliedPlugin) {
                addSoftwareComponents(project)
}
})
privatevoid addSoftwareComponents(Project project) {
...
        android.libraryVariants.all { v ->
...
            project.components.add(newAndroidVariantLibrary(objectFactory, configurations, attributesFactory, publishConfig))
}
...
}

通过上述方法,我们将我们的发布逻辑和已有逻辑进行关联,从而增加一些差异化实现,方便我们扩展。其中 AndroidVariantLibrary 是我们实现 maven 发布的核心类,主要负责 pom.xml 文件生成、构件收集等功能,其类图如下:

UML 图中我已标出几个核心点,主要包括:构件收集(getArtifacts)、依赖收集(getDependencies)、过滤规则收集(getGlobalExcludes)等功能。其中依赖、过滤规则等内容最终会体现在 pom.xml 文件中。熟识maven的同学应该对 pom.xml 文件不太陌生,它是 maven 依赖管理的核心文件,是 android dependencies 中各种依赖方式的基础。

<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.youzan.mobile</groupId>
<artifactId>liba</artifactId>
<version>1.0.0.15-SNAPSHOT</version>
<packaging>aar</packaging>
<dependencies>
<dependency>
<groupId>androidx.appcompat</groupId>
<artifactId>appcompat</artifactId>
<version>1.1.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>

上述文件是一个示例库 liba 的 pom.xml 文件,通过它我们可以非常方便看到我们此次发布的 liba 的相关信息:groupId、artifactId、version等大家常见的GAV,同时我们也可以看到这个 liba 的依赖情况,其中有一个关键的节点 runtime,它指明了 liba 对 androidx.appcompat:appcompat:1.1.0 的依赖是个运行期依赖。这样讲大家可能比较疑惑,但是当我告诉你经常用到的 implementation 其实就是个运行期依赖,你是不是会恍然大悟。

由于我们基于 Module aar(各种业务 module 构建后的产物)的编译优化仅涉及到 api & implementation 两种依赖方式,所以 AndroidVariantLibrary 类仅提供了这两种方式的 Usages,用来实现自定义发布,主要包括 pom.xml 生成、构件收集2个过程,限于篇幅限制具体实现细节就不在这里赘述了。

为了方便发布,我们根据 flavor、buildType 创建了不同的发布Task供业务同学调用。具体实现依托于 MavenPublish:

project.publishing {
                    repositories {
                        maven {
                            credentials {
                                username publishExt.userName // 仓库发布用户名
                                password publishExt.password // 仓库发布用户密码
}
                            url urlPath // 仓库地址
}
}
                    publications {
def android = project.extensions.getByType(LibraryExtension)
                        android.libraryVariants.all { variant ->
if(variant.name.capitalize().endsWith("Debug")) {
"maven${variant.name.capitalize()}Aar"(MavenPublication) {
from project.components.findByName("android${variant.name.capitalize()}")
                                    groupId publishExt.groupId
                                    artifactId tempArtifactId
                                    version defaultVersion
}
} elseif(variant.name.capitalize().endsWith("Release")) {
"maven${variant.name.capitalize()}Aar"(MavenPublication) {
from project.components.findByName("android${variant.name.capitalize()}")
                                    groupId publishExt.groupId
                                    artifactId tempArtifactId
                                    version defaultVersion
}
}
}
}
}

至此,介绍了 EnjoyDependence 插件强大的发布能力,它接管了 pom.xml 文件的生成、构件的收集、任务的创建等核心流程,为我们自定义发布任务提供了极大的便利,也为我们解决各类依赖传递问题提供了帮助。

3.3 依赖管理

成功发布之后,本地or远端已经有了我们 Module 的构件(aar 形式的产物),我们如何正确使用这些产物来加快我们的编译速度是我们接下来的重点。

在 Android 依赖中,我们经常见到 implementation project(path: ':modules:libcommon')用于实现对本工程 Module 的依赖。相信很多同学都见过 implementation "com.youzan.mobile:libcommon:1.0.0.15-SNAPSHOT"这种方式的依赖,用于实现对于一个三方、二方库的依赖。

既然我们有现成的方式可以实现对构件的直接依赖,我们就可以利用同样的方法实现对某个Module依赖方式的控制,比如:

if(needSourceBuild) {
        implementation project(path: ':modules:lib_common')
} else{
        implementation "com.youzan.mobile:lib_common:1.0.0.15-SNAPSHOT"
}

通过上述方式我们就可以实现源码和构件(aar)依赖的切换,通过这种方式我们可以达到免编译某个Module的目的,从而节省编译时间,达到编译提效的目的。这种方式可能是最省时的实现方式,但它不是最优解,它满足不了低侵入性,尽量不改造工程主程,保证工程稳定这个目标,所以我们需要另辟蹊径。

为了实现高内聚、低耦合、可扩展、低侵入的目标,我们基于如下模型实现了相对优雅的依赖管理。

如上模型,我们基于 Plugin 实现了依赖管理的功能,主要包括:

  • dynamicDependency域对象创建
NamedDomainObjectContainer<DependenceResolveExt> dependencyResolveContainer = targetProject.container(DependenceResolveExt.class)
        targetProject.extensions.add("dynamicDependency", dependencyResolveContainer)
  • 依赖解析
targetProject.configurations.all { Configuration configuration ->
                if (configuration.dependencies.size() == 0) {
                    return
                }
                configuration.dependencies.all { dependency ->
                    if (dependency instanceof DefaultProjectDependency) {
                        def projectName = dependency.dependencyProject.name
                        def dependencyResolveExt = dependencyResolveContainer.find {
                            it.name == projectName
                        }
                        if (dependencyResolveExt != null && !dependencyResolveExt.debuggable) {
                            resolveExtMap.put(dependency.dependencyProject, dependencyResolveExt)
                        }
                    }
                }
                println("targetProjectName:" + targetProject.getName() + "; resolveExtMap Size:" + resolveExtMap.size())
            }
  • 依赖替换
targetProject.configurations.all { Configuration configuration ->
                if (!configuration.getName().contains("Test") && !configuration.getName().contains("test")) {
                    resolutionStrategy {
                        dependencySubstitution {
                            resolveExtMap.each { key, value ->
                                def defaultFlavor = value.flavor
                                if (targetProject.hasProperty("flavor") && targetProject.flavor != "unspecified") {
                                    defaultFlavor = targetProject.flavor
                                }
                                if (defaultFlavor != "" && defaultFlavor != null) {
                                    substitute project("${key.path}") with module("${value.groupId}:${getArtifactName(key, value.artifactId + "-" + defaultFlavor)}:${value.version}")
                                } else {
                                    substitute project("${key.path}") with module("${value.groupId}:${getArtifactName(key, value.artifactId)}:${value.version}")
                                }
                            }
                        }
                    }
                }
            }

完成以上步骤后,我们基本的依赖管理能力已经具备,剩下的就是业务工程中的接入。接入方式也很简单:

dependencies {
    implementation project(path: ':modules:lib_common')
}

原有逻辑不变,只需要增加一个 dynamic.gradle 脚本完成依赖管理的对接:

dynamicDependency {
    lib_common {
        //如果是true,则使用本地模块作为依赖参与编译,否则使用下面的配置获取远程的构件作为依赖参与编译
        debuggable =  isSourceBuild("lib_common")
//        flavor = "pad"
        groupId = "com.youzan.mobile"
        artifactId = "lib_common" // 默认使用模块的名称作为其值
        version = loadAARVersion("lib_common")
    }
}

到目前为止,我们已经实现了发布和依赖管理这两个核心功能,业务方可以方便的使用 EnjoyDependence 实现构件发布和依赖替换,从而实现 Android 组件化工程的编译加速。其实通过已有构件来加速编译这个方案出来已久,本生没有太多亮点,如何通过已有技术来满足自己工程所需才是王道。所以,我们在推出 EnjoyDependence 后并没有结束迭代,而是逐步完善基础设施满足各种业务需要。

3.4 aar 自动发布

为了进一步解放生产力,同时提高全量编译加速的稳定性,我们决定减少人为干预,尽量通过自动化任务实现关键步骤。

为了方便对接已有的自动化平台,EnjoyDependence 提供了批量/增量发布、版本控制、忽略规则设定、优先级设定等功能,具体功能Task如下:

EnjoyDependence 通过一系列相互关联的 Task 完成 Module 发布,单 Module 发布主要流程如下:

在单 Module 发布任务基础上,EnjoyDependence 提供了批量发布功能:

至此,EnjoyDependence 主要功能都已介绍完毕。经过一期的优化,我们的编译速度有了明显的提升,耗时问题得到改善(25个 module,3min 内编译完成)。为了达成方便管理,尽可能保证低廉的学习理解成本,方便大家上手这个目标,我们提供了 Enjoy Manager AS Plugin 来实现对 EnjoyDependence 的管理,方便大家上手,轻松开发。

三、Enjoy Manager AS Plugin

Enjoy Manager 是一个 Android Studio 插件,用于实现 EnjoyDependence 可视化管理,已在 https://plugins.jetbrains.com 发布。

通过以上面板,可以方便的实现依赖方式管理,基本不需要学习成本,上手简单,易于推广。同时,我们基于LRU算法实现了最近五个分支的配置保留功能,极大的降低了分支切换的配置成本。最后,我们也可以通过这个面板看到增量编译的痕迹(版本号离散分布)。

Q&A

在这次优化中,遇到几个比较值得分享的问题,在这里和大家分享下。

  1. 传递依赖引起的 Module 版本不一致的问题,如何解决? 在众多 Module 中难免有基础 Module(被其他 Module 依赖)、业务 Module 之分。各业务 Module 在编译期对同一基础 Module 的依赖可能是不同的,如果不做处理,这样在编译 APK 时会由于依赖传递的问题导致所需依赖不存在或者重复导入问题的出现。为了解决这个问题,我们需要清楚的理解编译期依赖和运行期依赖的区别。在编译时我们只需要保证编译通过,同时干涉 pom.xml 文件的生成,将基础模块的依赖过滤掉;在 APK 编译时由 APP 指定稳定的基础 Module 依赖,确保各业务 Module 对基础 Module 的依赖由 APP 来确定,这样就可以解决此类依赖问题。
  2. 如何实现多版本号管理,即不影响 git 提交,又可以随意指定依赖版本? 对于 EnjoyDependence 来说,业务方对具体 aar 依赖的 version 是由业务方决定的,所以通过该方式业务方可以随意指定版本号。那么为了业务同学应用方便,我们在 version.properties 中指定稳定的远端版本,在 local.properties 指定本地的自定义版本,如果两者都存在,以自定义版本为准。同时由于 local.properties是 git 忽略文件,所以它不会影响远端代码的稳定,也不会干涉其他同学的开发。
  3. 如何支持 Module 的增删? 在日常开发中难免会遇到 Module 的增删,Module 的增删会影响增量编译、Module 发布两个过程。增加 Module 后,势必需要对其进行发布,所以需要保证发布任务的创建必须灵活可靠,足以应对各种不规范Module的创建行为,保证它顺利发布,EnjoyDependence 通过查看是否存在域对象、域对象中是否包含 GroupId、AtifactId 来生成发布任务,兼容不规范 Module 的创建。Module 的删除会影响增量发布,为了避免删除后依然执行发布,我们可以将删除的 Module 加入到忽略中,从而保证其不参与发布。

结语

基于 EnjoyDependence 的全量编译提效方案一期内容分享到此就结束了,但是我们的编译优化项目并未停止,我们会持续攻坚克难,找寻最优解。下一期为大家带来的增量编译工具 Savitar 也是我们在编译提效中的一大利器,希望大家持续关注。