前言

总结一些日常开发中非常有用的 gradle 脚本、自定义功能实现。

实现

以下实现基于 AGP 8.0.2 版本 ,AGP 的 API 隔三岔五就会迎来一波破坏性的变更,导致脚本和插件无法使用,因此这里需要关注一下版本。

输出打包后 apk 文件路径及 apk 大小。

Android Studio 最新版本 Run 之后,每次输出的 apk 并没有在这 app/build/outputs 文件夹下(不知道 Android 官方是出于什么考虑要更改这个路径),而是移动到了 build\intermediates\apk\{flavor}\debug\ 目录下。为了方便后续快速找到每次运行完成的 apk ,可以在每次打包后输出 apk 文件路径及大小,从而可以关注一下日常开发过程中自己 的 apk 体积大概是一个什么样的范围。

static def getFileHumanSize(length) {
    def oneMB = 1024f * 1024f
    def size = String.valueOf((length / oneMB))
    def value = new BigDecimal(size)
    return value.setScale(2, BigDecimal.ROUND_HALF_UP)
}
/**
 * 打包完成后输出 apk 大小*/
android {
    applicationVariants.all { variant ->
        variant.assembleProvider.configure() {
            it.doLast {
                variant.outputs.forEach {
                    logger.error("apk fileName ==> ${it.outputFile.name}")
                    logger.error("apk filePath ==> ${it.outputFile}")
                    logger.error("apk fileSize ==> ${it.outputFile.length()} , ${getFileHumanSize(it.outputFile.length())} MB")
                }
            }
        }
    }
}
apk fileName ==> app-huawei-global-debug.apk
apk filePath ==> D:\workspace\MinApp\app\build\intermediates\apk\huaweiGlobal\debug\app-huawei-global-debug.apk
apk fileSize ==> 11987818 , 11.43 MB

可以看到 apk 的路径在 build/intermediates 目录下。当然,我们可以通过下面的方法修改这个路径,定义成我们习惯的路径。

gradle 自定义功能的模块化

日常开发中,会有很多关于 build.gradle 的修改和更新。日积月累,build.gradle 的内容越来越多,代码几乎要爆炸了。其实,可以用模块化的思路将每一个小功能单独抽取出来,这样不仅可以减少 build.gradle 的规模,同时小功能可以更加容易的复用。

比如上面定义的输出打包后 apk 文件路径及 apk 大小的功能,我们就可以把他定义在 report_apk_size_after_package.gradle 这样一个文件中,然后在要使用的 build.gradle 中导入即可。

比如我们要在 app module 中使用这个功能,那么就可以直接在其 build.gradle 文件中按照相对路径引入即可。

gradle 实用技巧

apply from: file("../custom-gradle/report_apk_size_after_package.gradle") // 打包完成后输出 apk 大小

修改 release 包的输出路径及文件名

输出 apk 后改名的需求,应该已经很普遍了。在最终输出的 apk 文件中,我们可以追加一些和代码相关的信息,方便通过 apk 文件名迅速确定一些内容。

def getCommit() {
    def stdout = new ByteArrayOutputStream()
    exec {
        commandLine "git"
        args "rev-parse", "--short", "HEAD"
        standardOutput = stdout
    }
    return stdout.toString().trim()
}
def getBranch() {
    def stdout = new ByteArrayOutputStream()
    exec {
        commandLine "git"
        args "rev-parse", "--abbrev-ref", "HEAD"
        standardOutput = stdout
    }
    return stdout.toString().trim()
}
def gitLastCommitAuthorName() {
    return "git log -1 --pretty=format:'%an'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}
def gitLastCommitAuthorEmail() {
    return "git log -1 --pretty=format:'%ae'".execute(null, rootDir).text.trim().replaceAll("\'", "")
}
android {
    def i = 0
    applicationVariants.all { variant ->       
        if (variant.assembleProvider.name.contains("Debug")) {
            // 只对 release 包生效
            return
        }
        // 打包完成后复制到的目录
        def outputFileDir = "${rootDir.absolutePath}/build/${variant.buildType.name}/${variant.versionName}"
        //确定输出文件名
        def today = new Date()
        def path = ((project.name != "app") ? project.name : rootProject.name.replace(" ", "")) + "_" + variant.flavorName + "_" + variant.buildType.name + "_" + variant.versionName + "_" + today.format('yyyy_MM_dd_HH_mm') + "_" + getBranch() + "_" + getCommit() + "_" + gitLastCommitAuthorName() + ".apk"
        println("path is $path")
        variant.outputs.forEach {
            it.outputFileName = path
        }
        // 打包完成后做的一些事,复制apk到指定文件夹
        variant.assembleProvider.configure() {
            it.doLast {
                File out = new File(outputFileDir)
                copy {
                    variant.outputs.forEach { file ->
                        copy {
                            from file.outputFile
                            into out
                        }
                    }
                }
            }
        }
    }
}

打 release 包后的日志

let me do something after assembleHuaweiGlobalRelease
apk fileName ==> MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk filePath ==> D:\workspace\MinApp\app\build\outputs\apk\huaweiGlobal\release\MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk
apk fileSize ==> 4959230 , 4.73 MB

通过上面的日志,可以看到 MiniApp_huaweiGlobal_release_1.0.0_2306292226_2023_06_29_22_26_master_b0c6937_rookie.apk 包含了 ProjectName、flavor、debug/release、打包时间、分支、commitId 即最后一个 commitor 邮箱这些信息。通过这样的信息,可以更加方便快速的定位问题和解决问题。

妙用 flavor 实现不同的功能

使用 flavor 可以定制代码的不同功能及组合。不用把所有内容一锅乱炖似的放在一起搞。比如 MiniApp 随着演示代码的增多,已经逐渐丧失了 Mini 的定位,Apk 大小已经来到了 22 MB之多。究其原因,就是把所有代码验证和功能都放在一起导致的,音视频、compose、C++ 代码全都混在一起。部分功能不常用,但是每次为了验证一部分小功能,却要连带编译这些所有功能,同时打出的 apk 包体积也变大了,从编译到安装,无形中浪费了很多时间。

因此,可以通过 flavor 将一些不常用的功能,定义到不同的 flavor 中,真正需要的时候,编译相应 flavor 的包即可。

首先我们可以从 type 维度定义两个 flavor

    flavorDimensions "channel", "type"
    productFlavors {
        xiaomi {
            dimension "channel"
        }
        oppo {
            dimension "channel"
        }
        huawei {
            dimension "channel"
        }
        global {
            dimension "type"
        }
        local {
            dimension "type"
        }
    }

在 type 维度,我们可以认为 global 是功能完整的 flavor,而 local 是部分功能缺失的 flavor 。那么具体缺失哪些功能呢?这就要从实际情况出发了,比如产品定义,代码架构及模块组合之类的。回到 Mini App 中,我们使用不同 flavor 的目标就是通过减少非常用功能模块,获得一个体积相对较小的 apk. 因此,可以做如下配置。

    if (source_code.toBoolean()) {
        globalImplementation project(path: ':thirdlib')
    } else {
        globalImplementation 'com.engineer.third:thirdlib:1.0.0'
    }
    globalImplementation project(path: ':compose')
    globalImplementation 'com.github.CarGuo.GSYVideoPlayer:GSYVideoPlayer:v8.1.8-release-jitpack'

如上我们只在 global 这个 flavor 依赖 thirdlib, compose, GSYVideoPlayer 这些组件。这样 local flavor 就不会引入这些组件,那么就会带来一个问题,local flavor 编译的时候没有这些组件的类,会出现找不到类的情况。

gradle 实用技巧

对于这种情况,我们可以在项目 src 和 main 同级的目录下,创建 local 文件夹,然后在其内部按照具体 Class 文件的路径创建相应的类即可。

package com.engineer.compose.ui
import com.engineer.BasePlaceHolderActivity
/**
 * Created on 2022/7/31.
 * @author rookie
 */
class MainComposeActivity : BasePlaceHolderActivity()

package com.engineer.third
import com.engineer.BasePlaceHolderActivity
/**
 * Created on 2022/7/31.
 * @author rookie
 */
class CppActivity : BasePlaceHolderActivity()
package com.engineer
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.engineer.android.mini.ext.toast
/**
 * Created on 2022/8/1.
 * @author rookie
 */
open class BasePlaceHolderActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        "please use global flavor ".toast()
        finish()
    }
}

gradle 实用技巧

这里的思路其实很简单,类似于 leanCanary ,就是在不需要这个功能的 flavor 提供空实现,保证编译可以正常通过即可。缺什么类,建按照类的完整路径创建相应的 Class 文件,这样既保证了编译可以通过,同时在不需要次功能的 flavor 又减少无冗余的代码。

flavor 扩展

其实顺着上面的思路,基于不同 flavor 我们可以做更多的事情。基于 Java 虚拟机的类加载机制限制,相同的类只能有一个,因此我们无法做的事情是,通过 flavor 创建同名的类,去覆盖或重写其他 flavor 的逻辑,这种在编译阶段(其实是在创建同名类的阶段)就会报错。

但是,有些功能是可以被覆盖和定制的。比如包名、App 的名称、icon 之类。这些配置可以通过 AndroidManifest.xml/gradle 进行配置。可以在 local 目录下创建这个 flavor 特有的一些资源文件,这样就可以实现基于 flavor 的产品功能定制了。

比如最简单的修改 applicationId

        global {
            dimension "type"
        }
        local {
            dimension "type"
            applicationId "com.engineer.android.mini.x"
        }

这样,local 和 global 就有了各自不同的 applicationId, 这两种不同 flavor 的包就可以安装在同一台设备了。当然,现在这两个包的 label 和 icon 都是一样的,完全看不出区别。这里就可以利用 flavor 各自的文件夹,来定制各类资源和命名了。

flavor 过滤

不同维度的 flavor 会导致最终的 variant 变多。比如定义 product、channel、type 这些几个 dimension 的之后,后续新增的 flavor 会以乘积的形式增长,但是有些 flavor 又是我们不需要的,这个时候我们就可以过滤掉某些不需要的 flavor 。

比如以上面定义的 channel,type 这两个维度为例,在这两个维度下分别又扩展了 xiaomi/opop/huawei,global/local 这些 flavor 。按照规则会有 2x3x2=12 种 flavor,但实际情况可能不需要这么多,为了减少编译的压力,提升代码的可维护性,我们可以对 flavor 进行过滤。

    variantFilter { variant ->
        println "variant is ${variant.flavors*.name}"
        def dimens = variant.flavors*.name
        def type = dimens[1]
        def channel = dimens[0]
        switch (type) {
            case "global":
                if (channel == "xiaomi") {
                    setIgnore(true)
                }
                break
            case "local":
                if (channel == "oppo") {
                    setIgnore(true)
                }
                break
        }
    }

这样我们就成功的过滤掉了 xiaomiGlobal 和 oppoLocal 的 flavor ,一下子就去掉了 4 个 flavor 。

基于现有 task 定制任务

再回顾一下上面的 修改 release 包的输出路径及文件名 的代码实现,我们是在打包完成之后进行了 apk 文件的重命名。

        // 打包完成后做的一些事,复制apk到指定文件夹
        variant.assembleProvider.configure() {
            it.doLast {
                File out = new File(outputFileDir)
                copy {
                    variant.outputs.forEach { file ->
                        copy {
                            from file.outputFile
                            into out
                        }
                    }
                }
            }
        }

这里的 doLast 就是说,无论是否需要,每次都会在 assemble 这个 task 完成之后做一件事。这样在某些情况下显得非常的不灵活,尤其是当 doLast 闭包中要做的事情非常繁重的时候。这里的 copy 操作显然是比较轻量的,但是换做是其他操作,比如 apk 安全加固等操作,并不是每次必然需要的操作。这种情况下,就需要我们换一种方式去实现相应的逻辑了。

我们就以加固为例,一般情况下,我们需要对各个版本的 release 包进行加固。因此,我们可以基于现有的 assembleXXXRelease 这个 task 展开。

android {
    applicationVariants.all { variant ->
        if (variant.assemble.name.contains("Debug")) {
            // 只对 release 包生效
            return
        }
        def taskPrefix = "jiagu"
        def groupName = "jiagu"
        def assembleTask = variant.assembleProvider.name
        def taskName = assembleTask.replace("assemble", taskPrefix)
        tasks.create(taskName) {
            it.group groupName
            it.dependsOn assembleTask
            variant.assembleProvider.configure() {
                it.doLast {
                    logger.error("let me do something after $assembleTask")
                }
            }
        }
    }
}

添加上面的代码后,再执行一下 gradle sync ,我们就可以看到新添加的 jiagu 这个 group 和其中的 task 了。

gradle 实用技巧

这里使用创建 task 的一种方式,使用 createdependsOn ,动态创建 task,并指定其依赖的 task 。

这样当我们执行 ./gradlew jiaguHuaweiLocalRelease 时就可以看到结果了。

> Task :app:assembleHuaweiLocalRelease
let me do something after assembleHuaweiLocalRelease

> Task :app:jiaguHuaweiLocalRelease
BUILD SUCCESSFUL in 56s
82 actionable tasks: 12 executed, 70 up-to-date

可以看到我们自定义的 task 已经生效了,会在 assembleXXXRelease 这个 task 完成之后执行。

关于 gradle 的使用,可以说是孰能生巧,只要逐渐熟悉了 groovy 的语法和 Java 语法之间的差异,那么就可以逐渐摸索出更多有意思的用法了。

本文源码可以参考 Github MiniApp

小结

可以看到基于 gradle 构建流程,我们仅仅通过编写一些脚本,可以做的事情还是很多的。但是由于 groovy 语法过于灵活,不像 Java 那样有语法提示,因此尝试一些新的语法时难免不知所措。面对这种情况,去看他的源码就好。通过源码,我们就可以知道某个类有哪些属性,有哪些方法。