现代Android技术探索 -- 将gradle迁移到kotlin版本

2,724 阅读9分钟

相关链接:

nowinandroid项目链接

nowinandroid作为Google官方的app,在github上其实是有开源的,而且这个项目一直在维护,对于Android一些新兴的技术,都会作为更新的重点,从nowinandroid中也会学到很多现代Android开发的一些先进思想,对于我们的app架构升级会提供一些新的思路。

所以【现代Android技术探索】将会作为新开的一个专题,主要针对nowinandroid中一些核心技术,例如组件化模块化架构、gradle、lint、单元测试等等进行讲述,因为整个项目我也没有完全看完,只能从整体到局部讲起,最后再深挖细节。

1 nowinandroid架构设计

从下图看,nowinandroid的整体架构设计采用的是模块化的设计思想。

image.png

app相当于一个壳工程;core-x模块则是一些基础的模块,像network(网络相关)、ui(组件库)、database(数据库)等,为app提供基础能力;而feature-x模块则是具体的业务实现层,app将会直接依赖这些业务模块。

所以整体的架构还是非常清晰的,标准的模块化的架构设计。

image.png

既然谈到了模块化,我们看下nowinandroid是如何完成依赖管理的,相信会有对我们开发有用的东西。

2 nowinandroid版本管理

在nowinandroid中,gradle脚本都是kotlin编写,而不是传统的groovy语法,其实groovy编写gradle脚本一直都有一个痛点就是代码提示不好,而使用kotlin更符合Android开发者的习惯,因此慢慢地kotlin会成为gradle脚本开发的主流语言,在Google开发者文档中,也已经将kotlin作为官方开发语言了。但是使用kotlin编写脚本在编译时没有groovy的速度快,性能上差点,但是也是在编译时,并不影响运行时速度。

2.1 kotlin编写脚本

其实,如果我们熟悉了gradle脚本的结构,那么在转成kotlin之后,只需要关注几个点就可以快速的上手。当我们创建一个新项目之后,会自动生成根project和app对应的gradle脚本,默认是groovy语言。

plugins {
    id 'com.android.application' version '7.2.1' apply false
    id 'com.android.library' version '7.2.1' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.10' apply false
    id 'org.jetbrains.kotlin.jvm' version '1.7.10' apply false
}

task clean(type: Delete) {
    delete rootProject.buildDir
}
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

android {
    compileSdk 32

    defaultConfig {
        applicationId "com.lay.layzproject"
        minSdk 21
        targetSdk 32
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.5.1'
    implementation 'com.google.android.material:material:1.7.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.4'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
    implementation project(':datastore')
    implementation project(':handler')
}

那么在迁移到kotlin之后,记住首先从根project开始,将gradle脚本改为.kts后缀的文件,对于‘ ’修饰的成员变量,全部变成(" ")

plugins {
    id("com.android.application") version ("7.2.1") apply false
    id("com.android.library") version ("7.2.1") apply false
    id("org.jetbrains.kotlin.android") version ("1.7.10") apply false
    id("org.jetbrains.kotlin.jvm") version ("1.7.10") apply false
}

tasks.register("clean", Delete::class.java) {
    delete(rootProject.buildDir)
}

对于task的创建,采用TaskContainer的register方法创建。

像groovy中比较有特色的闭包,在kotlin中与lambda表达式基本是一致的,这部分基本上可以不用改变,需要改变的就是闭包内的方法处理,这里在写的时候,会有代码提示。

例如在修改app模块下的gradle脚本时,android闭包下对应的就是BaseAppModuleExtension对象,

/**
 * Configures the [android][com.android.build.gradle.internal.dsl.BaseAppModuleExtension] extension.
 */
fun org.gradle.api.Project.`android`(configure: Action<com.android.build.gradle.internal.dsl.BaseAppModuleExtension>): Unit =
    (this as org.gradle.api.plugins.ExtensionAware).extensions.configure("android", configure)

在这个对象中,存在defaultConfig、buildTypes等常见的配置参数,所以在Kotlin中,仿照groovy进行配置即可。

open class BaseAppModuleExtension(
    dslServices: DslServices,
    bootClasspathConfig: BootClasspathConfig,
    buildOutputs: NamedDomainObjectContainer<BaseVariantOutput>,
    sourceSetManager: SourceSetManager,
    extraModelInfo: ExtraModelInfo,
    private val publicExtensionImpl: ApplicationExtensionImpl
) : AppExtension(
    dslServices,
    bootClasspathConfig,
    buildOutputs,
    sourceSetManager,
    extraModelInfo,
    true
), InternalApplicationExtension by publicExtensionImpl {

    // Overrides to make the parameterized types match, due to BaseExtension being part of
    // the previous public API and not wanting to paramerterize that.
    override val buildTypes: NamedDomainObjectContainer<BuildType>
        get() = publicExtensionImpl.buildTypes as NamedDomainObjectContainer<BuildType>
    override val defaultConfig: DefaultConfig
        get() = publicExtensionImpl.defaultConfig as DefaultConfig
    override val productFlavors: NamedDomainObjectContainer<ProductFlavor>
        get() = publicExtensionImpl.productFlavors as NamedDomainObjectContainer<ProductFlavor>
    override val sourceSets: NamedDomainObjectContainer<AndroidSourceSet>
        get() = publicExtensionImpl.sourceSets

    override val viewBinding: ViewBindingOptions =
        dslServices.newInstance(
            ViewBindingOptionsImpl::class.java,
            publicExtensionImpl.buildFeatures,
            dslServices
        )

    override val composeOptions: ComposeOptions = publicExtensionImpl.composeOptions

    override val bundle: BundleOptions = publicExtensionImpl.bundle as BundleOptions

    override val flavorDimensionList: MutableList<String>
        get() = flavorDimensions

    override val buildToolsRevision: Revision
        get() = Revision.parseRevision(buildToolsVersion, Revision.Precision.MICRO)

    override val libraryRequests: MutableCollection<LibraryRequest>
        get() = publicExtensionImpl.libraryRequests
}

2.1.1 android节点

修改成kotlin语法如下:

android{
    //BaseAppModuleExtension
    compileSdk = 32
    defaultConfig {
        //ApplicationDefaultConfig
        applicationId = "com.lay.layzproject"
        minSdk = 21
        targetSdk = 32
        versionCode = 1
        versionName = "1.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes{
        release {
            //ApplicationBuildType
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"),"proguard-rules.pro")
        }
    }
    compileOptions{
        sourceCompatibility = org.gradle.api.JavaVersion.VERSION_1_8
        targetCompatibility = org.gradle.api.JavaVersion.VERSION_1_8
    }
    kotlinOptions{
        jvmTarget = "1.8"
    }
}

2.1.2 dependencies节点

修改成kotlin语法如下:

dependencies {
    implementation("androidx.core:core-ktx:1.7.0")
    implementation ("androidx.appcompat:appcompat:1.5.1")
    implementation ("com.google.android.material:material:1.7.0")
    implementation ("androidx.constraintlayout:constraintlayout:2.1.4")
    testImplementation ("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.1.4")
    androidTestImplementation ("androidx.test.espresso:espresso-core:3.5.0")
    implementation (project (":datastore"))
    implementation (project (":handler"))
}

这里也只是简单的将初始化工程中的gradle脚本修改成kotlin,实际项目开发中也不会跑偏,大致也就是上述的这些场景。

2.2 version catalog版本管理

像在组件化或者模块化的架构设计中,因为模块众多,所有的模块可能会有一些共同的依赖配置项,我们需要做到的就是三方库的版本保持一致,一般都是创建一个config.gradle,在其中创建一些扩展字段用于其他模块复用。

ext {
    libs = [
            "coreKtx": "androidx.core:core-ktx:1.7.0"
    ]
}

但是在kotlin脚本中,这些全部失效了,无法通过定义ext的方式进行扩展,但是有对应的catalog可以帮助我们实现这个能力。

什么是catalog,其实就是就是一个版本目录,对于Android开发人员来说,主要工作就是往表中配置三方库,但是需要注意格式。

第一步:在gradle文件夹下,创建libs.versions.toml文件

在toml文件中,需要声明两个标签,[versions]代表三方库的版本,[libraries]代表三方库的信息,像group、name等,version.ref就是引用了在[versions]中定义的版本号。

[versions]
androidxCore = "1.7.0"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }

第二步:在setting.gradle文件中声明允许使用version catalog

Using dependency catalogs requires the activation of the matching feature preview

在toml文件中配置完成之后,编译发现报上面的错误,原因就是如果要使用versions catalog,就需要配置开关。

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    //开关
    enableFeaturePreview("VERSION_CATALOGS")
    repositories {
        google()
        mavenCentral()
    }
}

如果开启了VERSION_CATALOGS这个开关,那么系统会默认生成一个libs文件对应libs.versions.toml文件。

implementation(libs.androidx.core.ktx)

那么在每个模块使用的时候,就可以直接拿到在toml文件中定义的三方库,从源码中看,就是根据定义的libraries的key获取的。

public Provider<MinimalExternalModuleDependency> getKtx() { return create("androidx.core.ktx"); }

其实这样使用的效果其实跟groovy中定义的扩展类似,而且使用versions catalog会更加规范,在nowinandroid中,就是这样定义的,如下:

implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.startup)
implementation(libs.androidx.work.ktx)
implementation(libs.hilt.ext.work)

3 nowinandroid依赖管理

在第一节的架构图中,我们看到业务模块一定要依赖基础的core模块,否则无法完成网络请求、数据持久化等操作,但是从feature-author模块的gradle中发现,dependencies中只依赖了datetime一个三方库,那么跟其他模块的依赖是如何完成的呢?

plugins {
    id("nowinandroid.android.library")
    id("nowinandroid.android.feature")
    id("nowinandroid.android.library.compose")
    id("nowinandroid.android.library.jacoco")
    id("dagger.hilt.android.plugin")
    id("nowinandroid.spotless")
}

dependencies {
    implementation(libs.kotlinx.datetime)
}

3.1 includeBuild替代buildSrc

其实从plugins中不难看出,既然没有直接通过implementation的方式直接引用,那么应该就是采用了gradle插件的形式,在编译时配置项目依赖。

在之前关于gradle插件的编写,都是创建buildSrc文件夹来完成的,其实buildSrc有一个最大的问题就是:在buildSrc中做微小的改动就会导致整个项目的全量编译,随着项目的增大,就会变得越来越慢。

因此在nowinandroid中,并没有使用传统的buildSrc,而是采用了includeBuild,使用这种方式可以将任意一个项目变为插件工程,而且实现的效果与buildSrc一致,但编译速度比buildSrc快好几个等级。

所以接下来就是使用includeBuild的方式:

(1)创建一个Java或者Kotlin library工程,并在settings.gradle中引入插件工程

pluginManagement {
    //引入插件工程
    includeBuild("build-logic")
    repositories {
        gradlePluginPortal()
        google()
        mavenCentral()
    }
}

(2)配置插件工程gradle依赖,与之前类似

plugins{
    `kotlin-dsl`
}

java{
    sourceCompatibility = org.gradle.api.JavaVersion.VERSION_1_8
    targetCompatibility = org.gradle.api.JavaVersion.VERSION_1_8
}

repositories{
    google()
    mavenCentral()
}

dependencies{
    compileOnly("com.android.tools.build:gradle:7.2.2")
    compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.0")
}

(3)自定义插件

class AppDepsPlugin : Plugin<Project>{

    override fun apply(target: Project) {
        print("AppDepsPlugin --- ${target.name}")
    }
}

在以往的buildSrc开发中,因为全局只有一个工程,因此会直接编译后配置到classpath下,然后需要创建一个META-INF文件夹,声明插件的名称,在Module中引入插件,但这种方式就比较鸡肋了,需要一种动态注册的方式,而在nowinandroid中,我们并没有看到一堆META-INF文件,而是采用下面这种方式。

(4)插件注册

只要有任何插件的创建,都可以在gradlePlugin # plugins中进行注册。

gradlePlugin{
    plugins { 
        register("androidAppModuleConfig"){
            id = "app_deps_config"
            implementationClass = "com.tal.build_logic.plugin.AppDepsPlugin"
        }
    }
}

看下原始的插件声明文件,是不是和上面的声明很类似:

implementation-class=com.lay.asm.ASMPlugin

但是我们这里是通过动态注册的方式完成,对应的id就是可以在每个模块下的声明的插件id,implementationClass对应的就是插件的全类名


Type-safe dependency accessors is an incubating feature.
> Task :build-logic:compileKotlin
> Task :build-logic:compileJava NO-SOURCE
> Task :build-logic:pluginDescriptors UP-TO-DATE
> Task :build-logic:processResources UP-TO-DATE
> Task :build-logic:classes UP-TO-DATE
> Task :build-logic:inspectClassesForKotlinIC
> Task :build-logic:jar

> Configure project :app
AppDepsPlugin --- app

这样我们的插件就生效了。

3.2 自定义插件完成依赖配置

class AppDepsPlugin : Plugin<Project>{

    override fun apply(target: Project) {
        println("AppDepsPlugin --- ${target.name}")
        with(target){
            //从上到下
            with(pluginManager){
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
            }
            //获取全局版本配置
            val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs")
            //配置依赖
            dependencies {
                add("implementation",libs.findDependency("androidx-core-ktx").get())
                add("implementation",project(":datastore"))
                add("implementation",project(":handler"))
            }
        }
    }
}

这里我简单介绍一下,其实在使用插件进行依赖配置时,对于Android开发人员是非常便捷的,因为完全可以根据gradle脚本中的配置顺序进行处理,因为有kotlin-dsl插件,所以像dependencies这种可以直接进行配置。

如果像android这种节点,可以通过扩展函数来查找对应的类进行配置。

extensions.configure(BaseAppModuleExtension::class.java){
    defaultConfig {
        targetSdk = 21
    }
}

因为我们之前在toml文件中做了全局的版本管理,所以在进行依赖配置时,可以通过extensions来获取libs文件,以便通过findLibrary或者findDependency来获取。

class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
                apply("org.jetbrains.kotlin.kapt")
            }
            extensions.configure<LibraryExtension> {
                defaultConfig {
                    testInstrumentationRunner =
                        "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
                }
            }

            val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

            dependencies {
                add("implementation", project(":core-model"))
                add("implementation", project(":core-ui"))
                add("implementation", project(":core-designsystem"))
                add("implementation", project(":core-data"))
                add("implementation", project(":core-common"))
                add("implementation", project(":core-navigation"))

                add("testImplementation", project(":core-testing"))
                add("androidTestImplementation", project(":core-testing"))

                add("implementation", libs.findLibrary("coil.kt").get())
                add("implementation", libs.findLibrary("coil.kt.compose").get())

                add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
                add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
                add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())

                add("implementation", libs.findLibrary("kotlinx.coroutines.android").get())

                add("implementation", libs.findLibrary("hilt.android").get())
                add("kapt", libs.findLibrary("hilt.compiler").get())

                // TODO : Remove this dependency once we upgrade to Android Studio Dolphin b/228889042
                // These dependencies are currently necessary to render Compose previews
                add(
                    "debugImplementation",
                    libs.findLibrary("androidx.customview.poolingcontainer").get()
                )
            }
        }
    }
}

通过这种方式就可以实现全局的依赖配置,仅需要通过plugin id即可,而相较于之前完全通过gradle脚本来配置,这种配置显然更加灵活,同时也满足了开闭原则,如果需要新的配置,只需要更换plugin id即可

app模块插件配置:

plugins {
    id("app_deps_config")
}

如果感兴趣的伙伴,可以看之前的一篇文章,关于组件化优化的 ->

Android Gradle的神奇之处 ---- 组件化优化

当然拿到nowinandroid工程之后,首先关注的就是整体的架构,以及模块之间的配置,由此引申到现在主流的Android开发用到的技术,当然如果我们的项目中gradle文件太多,也不要一次性地把gradle文件全部替换成.kts文件,因为两者是完全兼容的,一点一点地改,防止一片爆红。

最近刚开通了微信公众号,各位伙伴可以搜索【layz4Android】,或者扫码关注,每周不定时更新,也有惊喜红包🧧哦,也可以后台留言感兴趣的专题,给各位伙伴们产出文章。

qrcode_for_gh_948a648a034f_344.jpg