有赞Android组件化编译提效方案

前言:有赞移动技术沙龙刚过去不久,相信很多同学对题为《有赞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插件。

EnjoyDependence特点

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

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', new Action<AppliedPlugin>() {
            @Override
            void execute(AppliedPlugin appliedPlugin) {
                addSoftwareComponents(project)
            }
        })
private void addSoftwareComponents(Project project) {  
      ...
        android.libraryVariants.all { v ->
           ...
            project.components.add(new AndroidVariantLibrary(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
                                }
                            } else if (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文件的生成、构件的收集、任务的创建等核心流程,为我们自定义发布任务提供了极大的便利,也为我们解决各类依赖传递问题提供了帮助。

依赖管理

成功发布之后,本地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后并没有结束迭代,而是逐步完善基础设施满足各种业务需要。

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算法实现了最近五个分支的配置保留功能,极大的降低了分支切换的配置成本。最后,我们也可以通过这个面板看到增量编译的痕迹(版本号离散分布)。

QA

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

  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也是我们在编译提效中的一大利器,希望大家持续关注。

欢迎关注我们的公众号