快看漫画组件化-发布插件

随着快看业务的下沉组件越来越多,每个人都可能面临组件创建、配置、发布阶段,组件化的基础能力愈发重要,我们希望能有一个通用的发布插件来提升我们组件的开发效率和解决我们在组件化中可能面临到的一些问题。针对目前我们快看遇到组件化中遇到的问题,我们的发布插件提供了下面几个能力:

  • 方便快捷的maven上传配置
  • 组件发布的前置校验
  • API模块导出
  • 一键支持发布内网或者外网

下面我们针对这四个能力分别详细介绍下。

优化maven发布配置效率

目前组件的发布配置都是使用maven,如果需要每个组件单独配置maven的话,对于每一个组件来说都是一个不小的工作量。

maven发布配置

先来看常规定义一个Maven发布的流程。

//1. 引入maven插件
plugins {
    id 'java'
    id 'maven'
}
//2. 定义Configuation
configurations {
    testArchives
}
//3. 定义收集产物
artifacts {
    testArchives file: file('libs/xxxx.aar')
}
//4. 定义发布任务
uploadTestArchives {
    repositories {
        mavenDeployer {
            repository(url: uri(uploadRepo)) {
                authentication(userName: localProps.getProperty("emailName"),
                        password: localProps.getProperty("emailPass"))
            }
            pom.project {
                groupId GROUP_ID
                version rootProject.xxxRVersion
                artifactId "xxxxx"
            }
        }
    }
}
复制代码

当我们在gradle文件添加了上面的maven配置之后,相当于我们定义了一个名为uploadTestArchives的上传任务。

maven-publish发布配置

然而,在升级Android build plugin升级到了4.2.0之后,就会发现maven已经被废弃了,不能够继续使用了。 查看gradle官方的API,maven被替换为maven-publish了。 接下来简单介绍下maven-publish。整体的发布流程与之前的maven基本一致。

//1.发布插件引入
plugins {
    id 'java'
    id 'maven-publish'
}

task sourceJar(type: Jar) {
  archiveClassifier = "sources"
}

publishing {
  //2.定义要发布的对象
  publications {
    maven(MavenPublication) {
      from components.java
      artifacts = ["my-custom-jar.jar", sourceJar]
    }
  }
  //3.定义要发布的地址
   repositories {
        maven {
            def releasesRepoUrl = layout.buildDirectory.dir('repos/releases')
            def snapshotsRepoUrl = layout.buildDirectory.dir('repos/snapshots')
            url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl
        }
    }
}
复制代码

发布插件的发布配置优化

先看看目前快看的各个组件是如何定义maven的发布任务

apply plugin: 'plugin.kuaikan.complier'
compileConfig {
    upload {
        version = "3.4.release.0"
        artifactId = "arch"
    }
}
复制代码

这样做的好处是既可以节省maven较为繁琐的配置,也省去了maven-publish、maven的熟悉成本。可以通过gradle插件来优化这个繁琐的配置。如果没有了解过gradle插件的,可以先了解下gradle插件的大概实现流程。官方api传送门:doc.yonyoucloud.com/doc/wiki/pr… 整体的实现可以分为下面几步:

  1. 自定义一个gradle插件
  2. 定义发布参数配置Extension
  3. 在gradle project生命周期中注册action,解析配置参数,配置到maven必要的参数上。

以下忽略非关键流程代码


   @Override
    void proceedAfterEvaluate(Project project) {
        
        CompilerExtension compilerExtension = project.compileConfig
        if (compilerExtension == null) {
            return
        }
        UploadExtension extension = compilerExtension.upload
        if (!extension.enable) {
            return
        }
        ...
         project.publishing.publications({ publications ->
            publications.create("kk", MavenPublication.class, { MavenPublication publication ->
                publication.groupId = getGroupName(project)
                publication.artifactId = extension.artifactId
                publication.version = extension.version
                if (project.extensions.findByName("android") == null) {
                    publication.from(project.components.java)
                } else {
                    publication.from(project.components.release)
                    if (extension.source) {
                        artifact project.tasks.getByName("sourceJar")
                    }
                }

            })
        })
        project.publishing.repositories { artifactRepositories ->
            artifactRepositories.maven { mavenArtifactRepository ->
                  //此处url、username、password会区分内外网
                  mavenArtifactRepository.url = getRemoteKKUrl(project)
                    mavenArtifactRepository.credentials {
                        credentials ->
                            credentials.username = getRemoteKKUserName(project)
                            credentials.password = getRemoteKKUserPass(project)
                    }

            }
        }
    }
    
复制代码

组件发布的前置校验

随着组件化的推进,几乎所有的基础组件及一些业务组件都开始采用maven的方式发布到远端仓库,这样的方式提高了各组件的复用性,但相比于之前所有模块都在一个工程里面,也带来了新的问题。几个显而易见的问题就有:

  • 每个模块修改后,必须发布新的版本,才能在依赖模块中打包验证功能。

在出现一些bug需要对模块进行改动才能做验证时,需要发布新版本,验证成本很高,且发布的版本实际是测试版本,风险很高

  • 一个模块多分支并行开发,单独发布版本,可能导致发布的版本功能缺失

如果模块改动非常频繁,则要求对应的依赖模块也频繁更新,对依赖方来说成本会比较高

为了解决以上这些问题,必须对模块分支和版本策略制定一个严格的规范,并且尽可能通过编译工具能进行违规检测,避免出现以上问题。

分支策略

发布分支有以下两个:

  • release_master: 所有的正式版本只能在这个分支上发布
  • 开发分支: 可以随意发布dev版本

release_master会有严格的权限控制,只有各业务线owner具有merge权限,其他同学可以发起merge请求。开发同学基于以上两个分支拉开发分支,改动完成后发布到本地进行验证。

版本策略

正式版和灰度版采用两个不同的版本命名方法:

  • 正式版: major.minor.release.fix_version
  • 开发版: major.minor.5_dev_branch_name.fix_version

其中: major: 主版本号,通常是在有非常大功能升级上才会升级major minor: 次版本号,普通的功能升级,通常是增加新接口,或者改动的功能明显会影响到依赖方或需要让依赖方配合修改的改动 release: 代表正式版 5_dev_branch_name:代表开发版。

之所以前面需要添加一个数字,最主要的原因是希望同版本下,能够使用dev版本的依赖。比如在3.0.dev.0和3.0.release.0,在默认情况下,gradle会选择3.0.release.0.

版本发布策略
  1. release版本只能在release_master分支发布

  2. 必须rebase到最新的代码

  3. 工作目录必须是clean的, 也就是代码必须合入远端分支后,才能发布代码的检测

  4. release版本的库不允许依赖dev版本的库

  5. 发布release版本的库只能在release_master分支进行。

发布插件检测

上面的在只有规范的时候,并没有办法避免所有问题,所以把这些规范都做到自动化的检测中是非常有必要的。我们在发布i叫插件中,针对我们的组件版本号管理进行了自动化检测

     if (isUpload2ServerTask) {
            def checkTask = project.tasks.findByName('preBuild')
            if (checkTask == null) {
                checkTask = project.tasks.findByName('jar')
            }
            if (checkTask != null) {
                checkTask.doFirst {
                    checkDependencies(project, extension.version)
                    checkWorkingDirectory()
                    checkBranchAndVersionForPublish(project, extension.version)
                    checkUploadUser(project)
                }
            }
        }
复制代码
  • 在执行发布插件的最开始阶段,检查当前的发布版本类型和本地分支是否匹配。如果发布的版本号包含release字符串,那么当前的本地分支就必须是release_master,否则就会直接报错
  • 通过git命令,来检测当前本地是否改动未提交的代码,是否有未push的commit,远端是否更新的提交,如果存在,那么直接报错
  • 如果当前发布的是release版本的库,那么需要获取出当前project的所有依赖,检测是否包含dev组件,如果包含了dev版本的依赖,那么直接报错

组件一键发布内外网

某一天,同事A和B同时找上我,两个人都需要做一个对外分发的SDK,表示希望使用目前APP的基础组件做作为通用能力。这个是合理的也是应该的,不管是提供给公司内的APP使用还是给对外分发的SDK使用,平台可以保证所有的基础能力多端公用。但是,目前我们的组件都是发布在公司的内网的,对外分发的sdk如果以远程依赖的方式并没有方法依赖到。 对于这个App组件内外网通用的场景,当时思考的解决方式有下面几个: ####基于对外SDK需要的基础组件,简单的重新开发一套,发布到外网Maven。 这个方案在一开始就已经被pass了,最主要的原因是开发和维护成本,目前没有人力单独去开发和维护给对外SDK提供了基础组件。

使用fat aar技术,进行aar合并,将基础组件在打包中合并到对外的sdk中

使用合并aar的技术,将我们的所有基础组件打到对外的SDK中。简单一看似乎没有问题,但是在深入的调研和思考之后,其实后续可能出现的问题也有很多。因为我们使用了业界常用的Okhttp、Fresco等其他库,一旦将这些库以源码的方式打到对外分发的SDK中,一旦宿主工程也依赖了同样的网络库和图片库等,就会编译不通过,出现类重复问题。一旦出现这个问题,就需要宿主工程动态的移除与我们类重复依赖项。这一点后续SDK维护问题会产生比较大的成本,所以也被我们pass了。
复制代码

对于需要使用的组件,全量进行重新发布到外网

对于快看目前的组件数目是比较大的,大大小小的基础组件有一百多个,整体的发布成本是可以接受,但是组件后续是需要维护和升级的。如果同时维护组件发布内网和外网,就需要在各个不同的代码仓库中增加不同的maven地址,针对于内网、外网提供不同的依赖项,并且版本号需要针对内网和外网分别维护,成本很高。 如果后续所有只维护外网的组件,这个方案也不现实,因为我们希望只能获取到我们想提供的组件,并且我们想针对与对外的SDK、组件是混淆、不带源码的,内网用的组件是不混淆、带源码的。
复制代码

对于上面的3种不同的方案,我们还是选择了第三种。但是第三种方案也有比较明显的弊端,后续发布和内外网依赖配置和维护的成本比较高。因此,我们针对第三种方案开发了一套发布插件,把所有复杂的切换逻辑都内聚在了发布插件内部,以提高整个使用和接入成本。

支持一键内外网组件发布

同一个组件要支持内外网发布,本质上需要把同一个组件发布到内网仓库和外网仓库。假如我们发布出去内网和外网的组件的artifactId和groupId一样,当一个工程同时配置了内网和外网的repository,我们就没有办法保证最终会gradle获取到是内网还是外网的依赖。所以还需要保证artifactId和groupId有一个参数不一样才行。我们选择的是内外网的artifactId不一样,所有发布外网的组件的artifactId都会比发布到内网的组件的artifactId在后缀上拼接一个"-sdk"。 比如下面的发布定义:

apply plugin: 'plugin.kuaikan.complier'
compileConfig {
    upload {
        version = "3.4.release.0"
        artifactId = "arch"
    }
}
复制代码

如果发布到内网,那内网的组件名称就是"arch"。 如果选择发布到外网,那外网的组件名称就是"arch-sdk"。

自定义Configuration

假如使用了gradle提供的implementation或者api去进行依赖,就必须针对内网组件和外网组件都提供一套implementation或者api配置。 比如下面这样:

if (upload2Sdk) {
        implementation 'com.kuaikan.client.library:kv-sdk:3.0.release.0'
        implementation 'com.kuaikan.client.library:net-sdk:3.0.release.0'
        implementation 'com.kuaikan.client.library:base-sdk:3.0.release.0'
    } else {
        implementation 'com.kuaikan.client.library:kv:3.0.release.0'
        implementation 'com.kuaikan.client.library:net:3.0.release.0'
        implementation 'com.kuaikan.client.library:base:3.0.release.0'
    }
复制代码

整个内外网发布的配置比较繁琐,而且阅读起来不好阅读,后续修改也容易出错。为了解决这个问题,我们自定义了gradle的Configuration。

project.configurations {
            //发布时自动选择是sdk依赖还是内网依赖:
            automaticApi
            //发布时自动选择是sdk依赖还是内网依赖:Implementation
            automaticImpl
            //只有发布sdk会使用这个依赖:API
            sdkApiOnly
            //只有发布快看内网会选择这个依赖:API
            kkApiOnly
            //只有发布快看内网会选择这个依赖:Implementation
            kkImplOnly
            //只有发布Sdk会选择这个依赖:Implementation
            sdkImplOnly
            //强制使用默认依赖
            normalApi
            normalImpl
        }
复制代码
  • automaticImpl/automaticApi:根据发布task,自动选择内网依赖/外网以来参与编译发布
  • sdkApiOnly/sdkApiOnly:只有发布任务是选择发布外网,才会参与编译发布
  • kkApiOnly/kkImplOnly:只有发布任务是选择发布内网,才会参与编译发布
  • normalApi/normalImpl:不管发布任务,都会参与编译发布

我们先看看使用了自定义configuration之后的gradle编写效果。

automaticImpl 'com.kuaikan.client.library:kv:3.0.release.0'
automaticImpl 'com.kuaikan.client.library:net:3.0.release.0'
automaticImpl 'com.kuaikan.client.library:base:3.0.release.0'
复制代码

如果不熟悉configuration定义的,可以先看看官方文档:docs.gradle.org/current/use…

在我们的场景下,所有的configuration都是比较类似的,我们可以看看automaticImpl这个configuration的处理。

        project.configurations
                .automaticImpl
                .getAllDependencies()
                .forEach {dependency ->
                    def isSdkOutTask = KKCompilePluginUtil.isSdkUploadTask(project)
                    if (isSdkOutTask) {
                        implementation dependency.group + ':' + dependency.name + "-sdk" + ':' + dependency.version
                    } else {
                        implementation dependency.group + ':' + dependency.name + ':' + dependency.version
                    }
                   
                }
复制代码

简单来说,automaticImpl主要流程分为下面几步: 1.获取当前project的automaticImpl的所有依赖项 2.如果当前发布类型是外网,把automaticImpl替换为implementation,组件名称后面拼接-sdk 3.如果当前发布类型是内网,仅把automaticImpl替换为implementation,其他依赖配置不变。

在看看另外一个gradle的配置场景,我们的图片库组件,我们内网使用的是基于Fresco二次开发的组件,但是在对外发布SDK的场景上,这个组件会导致整个对外SDK的包体积增大过多,所以希望的图片库的底层能有Glide的实现。

sdkApiOnly 'com.kuaikan.client.library:image-glide:' + rootProject.libImageGlideVersion
automaticApi 'com.kuaikan.client.library:image-api:' + rootProject.libImageApiVersion
kkApiOnly 'com.kuaikan.client.library:image-fresco:' + rootProject.libImageFrescoVersion
复制代码

这样,图片库发布内网时,使用fresco实现。 发布外网时,使用的glide的实现。

内外网发布任务

基于前面的maven定义,在支持了SDK任务发布之后,我们仅需要在上传参数的地方新增一个参数enableSdk = true就可以支持发布到外网仓库上了。

apply plugin: 'plugin.kuaikan.complier'
compileConfig {
    upload {
        enableSdk = true
        version = "3.4.release.0"
        artifactId = "arch"
    }
}
复制代码

我们可以来看加不加这个配置的gradleTask显示。 不支持发布外网的task任务 支持发布外网的task任务

只需要在maven-publish的配置地方,新增enableSdk的配置。

 void proceedAfterEvaluate(Project project) {
   .....
    defineKkUploadTask(project, extension, artifactName)
        if (extension.enableSdk) {
            defineSdkUploadTask(project, extension, artifactName)
        }
   ....
   }
   
   private void defineSdkUploadTask(Project project, UploadExtension extension, String artifactName) {
        project.publishing.publications({ publications ->
            publications.create("sdk", MavenPublication.class, { MavenPublication publication ->
                publication.groupId = getGroupName(project)
                publication.artifactId = artifactName + "-sdk"
                publication.version = extension.version
                if (project.extensions.findByName("android") == null) {
                    publication.from(project.components.java)
                } else {
                    publication.from(project.components.release)
                }
            
            })
        })
   }
复制代码
自定义发布任务名称

maven-publish默认会生成多个task。

  1. generatePomFileForPubNamePublication:生成Pom文件
  2. publishPubNamePublicationToRepoNameRepository:发布组件到远端仓库
  3. publishPubNamePublicationToMavenLocal:发布组件到本地maven中,默认地址是本地的m2文件夹
  4. publish:发布组件、pom文件等所有任务到远端仓库中
  5. publishToMavenLocal:发布组件、pom文件等所有任务到本地maven中

一旦我们同时支持了内网和外网发布之后,就会出现10个task,并且有些task是我们不需要的,所以我们可以自定义发布task。

project.tasks.create(name: TASK_KK_TO_LOCAL, dependsOn: ["publishKkPublicationToMavenLocal"], group: "upload")
project.tasks.create(name: TASK_KK_TO_SERVER, dependsOn: ["publishKkPublicationToMavenRepository"], group: "upload")
project.tasks.create(name: TASK_SDK_TO_LOCAL, dependsOn: ["publishSdkPublicationToMavenLocal"], group: "upload")
project.tasks.create(name: TASK_SDK_TO_SERVER, dependsOn: ["publishSdkPublicationToMavenRepository"], group: "upload")
复制代码

这样,我们就会在upload分组里面看到4个发布task。

  1. uploadSdk2Local: 发布外网组件到本地仓库上
  2. uploadSdk2Server: 发布外网组件到远端仓库上
  3. uploadKK2Local:发布内网组件到本地仓库上
  4. uploadKK2Server:发布内网组件到远端仓库上

支持API导出

随着模块化的推进,各个模块都采用maven的形式发布,各个模块是独立编译的,而模块之间复杂的依赖关系,很容易导致模块依赖存在版本兼容性问题。举个例子, 存在两个模块, LibA, LibB, 以及主工程Main, 且存在如下依赖关系:

其中LibB与Main都依赖于LibA:

1.LibB基于LibA1.0版本编译
2.Main基于LibA2.0编译
复制代码

最终gradle依赖冲突解决后,打包到Apk中都LibA版本是2.0版本,由于LibB依赖于1.0版本,这就要求LibA的2.0版本必须兼容1.0。然而版本升级过程中,是非常容易打破版本兼容性的,例如一下例子来自与App中接入的神策3.2.4,和3.2.11版本:

//3.2.4
public class SensorDataAPI {
    public void trackTimerStart()
}
 
 
//3.2.11
public class SensorDataAPI {
    public String trackTimerStart()
}
复制代码

这个方法第二个版本只是返回值增加了String,看起来是兼容第一个版本的,但实际上在调用方的字节码层面是不兼容的,分别为:

//3.2.4
invoke-virtual LSensorDataAPI->trackTimerStart()V
 
 
//3.2.11
invoke-virtual LSensorDataAPI->trackTimerStart()Ljava/lang/String;
复制代码

上面的例子是版本不兼容的一种非常常见的问题,日常开发中会面临很多类似的版本兼容问题,因此,需要有一种机制来避免版本兼容问题。

Framework管理导出接口的思路,对于每一个发布到maven的模块,会根据一个api导出文档,生成两个maven模块,例如模块LibA, 会生成: LibA: 包含所有的资源和类文件 LibA-Export: 导出模块,只包含声明中在api导出文档中的类,方法,成员变量。 使用自定义的依赖kkApiOnly或者kkImplOnly,如果该库存在api导出文档,将会产生两个依赖声明,如:

kkImplOnly 'com.kuaikan.client.library:router-api:1.1.release.+'
 
 
转换为:
compileOnly 'com.kuaikan.client.library:router-api-export:1.1.release.+'
runtimeOnly 'com.kuaikan.client.library:router-api:1.1.release.+'
复制代码

通过这种方式,就能让依赖方仅能访问模块导出的接口,由此带来如下优点:

  1. 能很大程度减少模块接口改变导致的兼容问题。
  2. 未导出接口可以任意修改,不用考虑兼容性问题,可以很灵活的修改
  3. 在只提供一个模块的基础上,能够最大程度的进行解耦,使用者只能看到导出的api库。

需要注意以下几点:

  1. 由于Android的gradle插件禁用了apk工程的compileOnly/provided, 因此在主工程中使用了kkImplOnly并不会真正应用api导出
  2. 由于主工程每次都是重新编译的,因此也不会产生版本兼容问题
  3. 模块工程中尽量不要使用gradle api方式依赖另一个模块, 因为这样会导致被依赖模块的完整模块被传递

API导出文档

规则

api导出文档中记录了模块导出的所有接口,只有在api文档中的接口会被输出到export中,同时,原则上api导出文档只能增加,不能修改,除非百分百确定依赖方都更新到最新版本。下面是一个api导出文档示例:

com.alibaba.android.arouter.facade.template.IInterceptor
    public void process(com.alibaba.android.arouter.facade.Postcard, com.alibaba.android.arouter.facade.callback.InterceptorCallback)
com.alibaba.android.arouter.facade.template.IInterceptorGroup
    public void loadInto(java.util.Map)
com.alibaba.android.arouter.facade.template.IPolicy
    public int getFlag()
com.alibaba.android.arouter.facade.template.IProvider
    public void init(android.content.Context)
com.alibaba.android.arouter.facade.template.LocalInterceptor
com.alibaba.android.arouter.launcher.ARouter
    public static final java.lang.String RAW_URI
    public static final java.lang.String PATH
    public void init(com.alibaba.android.arouter.launcher.ARouterConfig)
    public static com.alibaba.android.arouter.launcher.ARouter getInstance()
    public void inject(java.lang.Object)
    public void inject(java.lang.Object, java.lang.Class)
    public com.alibaba.android.arouter.facade.Postcard build(java.lang.String)
    public java.lang.Object navigation(java.lang.Class)
    public boolean isSupportedActivityPath(java.lang.String)
    public java.lang.Class findBizClass(java.lang.String, java.lang.String)
    public java.util.Map findAllBizClassByType(java.lang.String)
    public java.lang.Object createBizClassObject(java.lang.String, java.lang.String)
复制代码

以上配置来自于KKRouter中的部分配置,配置规则如下:

  • 每一个类以全限定名的方式,顶格开始一行
  • 方法和成员变量以4个空格开头,每一方法和成员变量一行
  • 参数只能包含类型,不能包含参数名
  • 不能包含泛型信息,只能使用raw类型声明
  • 访问属性只能是public和protected, 不能是private

在出现如下情况时,会出现编译失败:

  • 配置与代码不匹配时,例如访问属性不相等, 如public void inject 和protected void inject是不匹配的
  • 配置在代码中不存在,如删除了一个方法等

API导出实现原理

API-export库的实现

export库实现的流程大概可以分为下面几个流程:

  1. 读取api导出文档,跟最新的api导出文档做比较,确认没有对已有的规则进行修改和删除
  2. 在gradle的transform阶段,获取当前组件编译后的产物,即aar或者jar包,通过ASM的API,将各个类转化为ClassVisitor
  3. 如果当前ClassVisitor在api导出文档中有定义,那么遍历这个ClassVisitor,将所有匹配导出文档的成员、方法写入到export库中
  4. 在组件发布时,同时发布对应的导出库。

Api-export的依赖转换

project.configurations
                .kkImplOnly
                .getAllDependencies()
                .forEach { dependency ->
                    boolean hasExport = QueryArtifactProcess.hasExportArtifact(project, false, dependency)
                    project.dependencies {
                        if (hasExport) {
                            compileOnly dependency.group + ':' + (dependency.name + "-export" + ':') + dependency.version
                            runtimeOnly dependency.group + ':' + dependency.name + ':' + dependency.version
                        } else {
                            implementation dependency.group + ':' + dependency.name + ':' + dependency.version
                        }
                    }
                }
复制代码

在替换依赖前,先通过远端去查询是否有存在对应的-export库。 nexus有提供api,通过api查询对应的库在远端是否存在。如果远端存在对应的api导出库,那么就替换成compileOnly导出库,runtimeOnly实际的库。

Guess you like

Origin juejin.im/post/7062704713979920391