Android Gradle 模块化构建打包

原文链接Android -Gradle

前言

本文将简述Android-Gradle在实际项目开发过程中-打包编译过程中-所涉及到的部分知识点。
对大部分Android开发者而言,接触Gradle应该是从AndroidStudio开始,而AndroidStudio是google官方推荐的开发IDE,可见深入了解Gradle会对我们的Android软件开发产生更深远的意义。
Gradle其实是一种构建工具,能自动化的进行构建,编译,打包,签名等一系列流程。

android-gradle

构建

构建工具用于实现项目自动化,是一种可编程的工具,你可以用代码来控制构建流程最终生成可交付的软件。构建工具可以帮助你创建一个重复的、可靠的、无需手动介入的、不依赖于特定操作系统和IDE的构建。Android开发中常见构建工具有ant,maven,make,gradle等等。

ant

Ant 是由 Java 编写的构建工具,具有平台无关性,构建脚本是XML格式的(默认为bulid.xml)。Ant的构建脚本由三个基本元素组成:一个project(工程)、多个target(目标)和可用的task(任务)。
Apache Ant有以下缺点:

  1. Ant无法获取运行时的信息。
  2. XML作为构建脚本的语言,如果构建逻辑复杂,那么构建脚本就会又长又难以维护。
  3. Ant管理依赖需要配合Ivy。
  4. Ant脚本编写虽然具有灵活性,但不易于结构化理解。

maven

Maven于2004年发布,它的目标是改进开发人员在使用Ant时面临的一些问题。 继承了Ant的项目构建功能, 同样采用了XML作为构建脚本的格式(默认为pom.xml)。Maven具有依赖管理和项目管理的功能,提供了中央仓库,能帮助我们自动下载库文件。

Maven相比Ant的优点:

  1. Ant是过程式的,开发者需要显示的指定每个目标,以及完成该目标锁需要执行的任务。每一个项目,开发着都需要重新编写这一过程,这样会产生大量的重复。Maven是声明式的,项目的构建过程和过程中的各个阶段都由插件实现,开发者只需要声明项目的基本元素就可以了,这很大程度消除了重复。
  2. Ant需要配合Ivy来管理依赖,而Maven本身就提供了依赖管理。
  3. Maven 使用约定而不是配置,它为工程提供了合理的默认行为,项目会知道去哪个目录寻找源代码以及构建运行时有那些任务去执行。而Ant是使用配置且没有默认行为的。

Maven的缺点:

  1. Maven的提供了一套默认的结构和生命周期,对具体的项目工程有些可能会有不适应。
  2. Maven的定制扩展过于繁琐不易于理解。
  3. 国内连接Maven的中央仓库比较慢,一般需要连接国内的Maven镜像仓库,目前有阿里云仓储等可做备选。

make

Make编译构建,在Android的源码编译中被大量使用,其采用Makefile作为构建脚本的格式语言(默认为Android.mk),执行对应的命令,然后得到目标产物。除了make命令外,Android源码编译中还有mm,mmm等。

  1. make:不带任何参数,用于编译整个系统,编译时间比较长,除非是进行初次编译否则不建议此种做法
  2. mmm 该命令编译指定目录下的目标模块,而不编译它所依赖其他模块。非首次编译可能会依赖报错
  3. mm 同mmm 命令一样也是不编译依赖,只是该命令需先cd到编译目录,编译当前目录。
  4. mma 也是编译当前目录下的模块,但会编译其依赖项

默认编译一般都是增量变化式编译,若需重新编译 以上命令都可以用-B选项来实现。

在这顺便提下源码编译的流程

  1. source build/envsetup.sh #这个脚本用来设置android的编译环境;
  2. lunch #选择编译目标
  3. make #编译android整个系统

make编译优缺点:

  1. 方便管理编译依赖大型项目。
  2. make编译需依赖linux等一系列编译工具,跨平台搭建会比较复杂。
  3. 对于单个Android应用项目独立编译,需要有源码编译环境,不易于调试。

Gradle

gradle结合Ant和Maven等构建工具的最佳特性。它有着约定优于配置的方法、强大的依赖管理,它的构建脚本使用Groovy或Kotlin 编写,是Android的官方构建工具。gradle脚本为build.gradle格式。

Groovy

Groovy基于DSL(动态语言)。和Java一样,也运行于Java虚拟机中。这一特性也使得Groovy可以引用Java,但除此之外Groovy又具有脚本语言的特定。当执行Groovy脚本时,Groovy会先将其编译成Java类字节码,然后通过Jvm来执行这个Java类。相关关系模型如下图

groovy-jvm

Groovy-基本语言

作为动态语言,Groovy世界中的所有事物都是对象。所以,int,boolean这些Java中的基本数据类型,在Groovy代码中其实对应的是它们的包装数据类型。比如int对应为Integer,boolean对应为Boolean等。

Groovy-集合

  • List:链表,其底层对应Java中的List接口,一般用ArrayList作为真正的实现类。

    //List由[]定义,其元素可以是任何对象
    //变量存取:可以直接通过索引存取,而且不用担心索引越界。
    //如果索引超过当前链表长度,List会自动往该索引添加元素
    def arryList = [2,'string',true] 
    assert arryList[1] == 'string'
    assert arryList[5] == null //第6个元素为空
    aList[100] = 100 //设置第101个元素的值为10
    assert arryList[100] == 100
    println arryList.size  ===>结果是101
    
  • Map:键-值表,其底层对应Java中的LinkedHashMap。

    //容器变量定义
    //变量定义:Map变量由[:]定义,比如
    def aMap = ['key1':'value1','key2':true]
    //Map由[:]定义,注意其中的冒号。冒号左边是key,右边是Value。key必须是字符串,value可以是任何对象。另外,key可以用''或""包起来,也可以不用引号包起来。比如
    def aNewMap = [key1:"value",key2:true]
    
  • Range:范围,它其实是List的一种拓展。

    //Range类型的变量 由begin值+两个点+end值表示
    //左边这个aRange包含1,2,3,4,5这5个值
    def aRange = 1..5 
    //如果不想包含最后一个元素,包含1,2,3,4这4个元素
    def aRangeWithoutEnd = 1..<5  
    println aRange.from
    println aRange.to
    

Groovy-闭包

闭包,英文叫Closure,是Groovy中非常重要的一个数据类型或者说一种概念。

def closure = {//闭包是一段代码,所以需要用花括号括起来..
    Stringparam1, int param2 ->  //这个箭头很关键。箭头前面是参数定义,箭头后面是代码
    println"this is code" //这是代码,最后一句是返回值,
   //也可以使用return,和Groovy中普通函数一样
}

简而言之,Closure的定义格式是:

def xxx = {paramters -> code} //或者 def xxx = {无参数,纯code} 这种case不需要->符号

Closure使用中的注意点

  1. 省略圆括号
  2. 确定Closure的参数

更详细的接受可以参考文末的参考文档链接。

Android-Gradle

好了有了以上相关构建的基础知识,现在让我们走进今天的主角AS-Android-Gradle
在学习Android-Gradle编译流程前,有必要先梳理下APP编译打包的具体流程。

app-build编译打包流程

android-compile

APK构建过程如上图总结如下:

  1. 通过AAPT(Android Asset Packaging Tool)打包res资源文件,比如AndroidManifest.xml、xml布局文件等,并将这些xml文件编译为二进制,其中assets和raw文件夹的文件不会被编译为二进制,最终会生成R.java和resources.arsc文件。
  2. AIDL工具会将所有的aidl接口转化为对应的Java接口。
  3. 所有的Java代码,包括R.java和Java接口都会被Java编译器编译成.class文件。
  4. Dex工具会将上一步生成的.class文件、第三库和其他.class文件编译成.dex文件。
  5. 上一步编译生成的.dex文件、编译过的资源、无需编译的资源(如图片等)会被ApkBuilder工具打包成APK文件。
  6. 使用Debug Keystore或者Release Keystore对上一步生成的APK文件进行签名。
  7. 如果是对APK正式签名,还需要使用zipalign工具对APK进行对齐操作,这样应用运行时会减少内存的开销。

gradle打包流程

[图片上传失败...(image-e20fea-1577691225411)]

  1. 首先是初始化阶段。执行as项目根目录下的settings.gradle
  2. Initiliazation phase的下一个阶段是Configration阶段。
  3. Configration阶段的目标是解析每个project中的build.gradle。解析每个子 模块中的build.gradle。在这两个阶段之间,我们可以加一些定制化的Hook。这当然是通过API来添加的。
  4. Configuration阶段完了后,整个build的project以及内部的Task关系就确定了。当然,我们也可以添加一个HOOK,即当Task关系图建立好后,执行一些操作。
  5. 最后一个阶段就是执行任务了。当然,任务执行完后,我们还是可以加Hook。

project-setting.gradle 项目配置

gradle项目初始化工作,一切从这里开始。创建modlue时,as会默认在setting.gradle中include相关模块

include ':testlibrary',  ':testapplication', ':testapplication1', ':testlibrary1'
rootProject.name='AndroidGradle'

project-build.gradle 项目初始化

项目下的build.gradle,是一个整体配置,各模块编译前后的一些公共Task可在此定义

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        google()
        jcenter()
        
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.0'
        
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

再这一模块,可以自定义一些task,或修改task

比如新增maven仓储,或编译task

...
allprojects {
    repositories {
        //新增阿里云仓储
        maven{ url 'http://maven.aliyun.com/nexus/content/groups/public/'}
        google()
        jcenter()
        
    }
    //增加一些编译选项
    gradle.projectsEvaluated {
        tasks.withType(JavaCompile) {
            options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
        }
    }
}
...

比如重新修改clean Task,默认的clean的之后删除根目录下的buildDir,但对于模块下的build未删除

对于正在使用svn进行代码管理时,上传代码时,如果不删除build,svn扫描文件会很卡。

因而重新编写task后也会递归删除相关子模块下的build。

task clean(type: Delete) {
    println "\n======================================================"
    println "**********  Delete All Compile ********** "
    println "======================================================\n"

    println("**********  start delete ")
    println("delete project dir:" + rootProject.buildDir)
    rootProject.buildDir.deleteDir()

    def file = new File("")
    def dir = new File(file.getAbsolutePath())
    println(" root dir:" +dir.getAbsolutePath())

    dir.eachDirRecurse {
        dir2 ->
            dir2.eachDirMatch(~/build/) {
                directory ->
                        println("delete child dir:"+directory)
                                            directory.deleteDir()
            }
    }
    println("********** finished delete")
}

app-buid.grade 应用编译

应用模块的编译配置脚本。对比编译版本,编译工具,签名,渠道配置等都可以在此配置。但对于多模块配置而言,经常会出现版本号不统一的情况,为了解决这个问题,我们可以把版本号定义在project-gradle-ext全局变量中。

apply plugin: 'com.android.application'

android {
    compileSdkVersion 28
    buildToolsVersion "29.0.2"


    defaultConfig {
        applicationId "top.lairdli.study.testapplication"
        minSdkVersion 15
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:runner:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}

lib-build.gradle 库模块编译

apply plugin: 'com.android.library'

android {
    compileSdkVersion 28
    buildToolsVersion "29.0.2"


    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles 'consumer-rules.pro'
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation 'androidx.appcompat:appcompat:1.0.2'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test:runner:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}

auto-package 自动打包

更改编译输出路径,以及编译输出app名称,不同的gradle版本,api会有些许差异

Gradle4.10以前配置如下


  //gradle4.10以前
    android.applicationVariants.all { variant ->
        variant.outputs.all { output ->
            if (variant.buildType.name == 'release') {
                outputFileName = "${variant.applicationId}_${variant.buildType.name}_${variant.versionName}.apk"
                if (outputFileName != null && outputFileName.endsWith('.apk')) {
                    variant.getPackageApplication().outputDirectory = new File("$rootProject.projectDir/out/${project.name}/"+
                            "${variant.flavorName}")
                    variant.getPackageApplication().outputScope.apkDatas.forEach {
                        apkData -> apkData.outputFileName = outputFileName
                    }
                }
            }
        }
    }
  

Gradle4.10以后配置如下

    //gradle4.10以后
    android.applicationVariants.all { variant ->
        variant.outputs.each { output ->
            if (variant.buildType.name == 'release') {
                def outputFileName = "${variant.applicationId}." +
                        "${variant.flavorName}.${variant.buildType.name}.${variant.versionName}.apk"
                output.outputFileName = outputFileName
                def outputDir = new File("$rootProject.projectDir/out/${project.name}/" +
                        "${variant.flavorName}")
                variant.packageApplicationProvider.get().outputDirectory = new File("$outputDir")
            }
        }
    }

如果多应用工程,需要统一输出目录,可以编译完成后copy到统一的目录下,并记录版本信息

   
android.applicationVariants.all { variant ->
      //重命名+重定义输出目录
    variant.outputs.each { output ->
        if (variant.buildType.name == 'release') {
            def outputFileName = "${variant.applicationId}." +
                    "${variant.buildType.name}.${variant.versionName}.apk"
            output.outputFileName = outputFileName
            def outputDir = new File("$rootProject.projectDir/out/${project.name}/" +
                    "${variant.flavorName}")
            variant.packageApplicationProvider.get().outputDirectory = new File("$outputDir")
        }
    }
        //编译完成后,重新copy
    variant.assemble.doLast {
        variant.outputs.each { output ->
            def outputFile = output.outputFile;

            if (outputFile != null && outputFile.name.endsWith('.apk') && variant.buildType.name == 'release') {
                packageAppRelease(outputFile,variant)
            }
        }
    }
}

//重新copy 并写入文件
def packageAppRelease(outputFile, variant) {

    def releaseDir = "$rootProject.projectDir/out/release/app/$getDateYYMMDD"
    def newName = variant.applicationId + '.apk'

    copyFile("$outputFile", releaseDir
            , "$outputFile.name", newName)

    StringBuilder stringBuild = new StringBuilder()
    stringBuild.
            append("****************************************************").append('\n')
            .append("*************** https://lairdli.top ***************").append('\n')
            .append("****************************************************").append('\n')
            .append("**:Name:" + outputFile.name).append('\n')
            .append("**:ApplicationId:" + variant.applicationId).append('\n')
            .append("**:VersionCode:" + variant.versionCode).append('\n')
            .append("**:VeresionName:" + variant.versionName).append('\n')
            .append("**:LastModify:" + formatDateYYMMDDHMS(outputFile.lastModified())).append('\n')
            .append("**:Size:" + outputFile.length()).append('\n')
            .append("**:Md5:" + getFileMd5(outputFile)).append('\n')
            .append("**:Sha1:" + getFileSha1(outputFile)).append('\n')
            .append("**:Des:").append('\n')
            .append("****************************************************")
            .append('\n').append('\n').append('\n')

    println stringBuild.toString()

    def releaseModuleFileFullPath =  outputFile.getParent()+ File.separator + variant.applicationId + '.txt';
    def releasePackFileFullPath =releaseDir  +  File.separator + variant.applicationId + '.txt';

    writeFile(releaseModuleFileFullPath, stringBuild.toString(), true)
    writeFile(releasePackFileFullPath, stringBuild.toString(), false)

    return newName;
}

def writeFile(String fileName,String content,boolean appendMode){

    File file = new File(fileName)

    if(!file.exists()){
        file.createNewFile();
    }

    FileOutputStream fos = new FileOutputStream(fileName, appendMode);
    fos.write(content.getBytes("UTF-8"));
    fos.close();
}

写入配置文件内容如下

****************************************************
*************** https://lairdli.top ***************
****************************************************
**:Name:top.lairdli.study.testapplication.release.201912291716.apk
**:ApplicationId:top.lairdli.study.testapplication
**:VersionCode:2019122916
**:VeresionName:201912291716
**:LastModify:2019-12-29 05:16:54
**:Size:1439615
**:Md5:f7ec01d7b6dced6b01fd11bf4c61aae4
**:Sha1:dfd178102f4b8c2501bba6efae7ae11f58861f3a
**:Des:
****************************************************

编译生成的out目录结构如下

Laird-MacBook-Pro:AndroidGradle laird$ cd out/
Laird-MacBook-Pro:out laird$ tree
.
├── app_test
│   ├── output.json
│   ├── top.lairdli.study.testapplication1.release.201912291724.apk
│   └── top.lairdli.study.testapplication1.txt
├── app_test1
│   ├── output.json
│   ├── top.lairdli.study.testapplication1.release.201912291728.apk
│   └── top.lairdli.study.testapplication1.txt
└── release
    └── app
        └── 20191229
            ├── top.lairdli.study.testapplication1.apk
            └── top.lairdli.study.testapplication1.txt

5 directories, 8 files
Laird-MacBook-Pro:out laird$

key-store 秘钥配置

普通签名keystore生成可以通过keytool生成秘钥。

系统签名keystore生成可以通过keytool-importkeypair 生成秘钥。

由于篇幅有限,本文只展示普通秘钥生成,更多使用命令行可参考往期博文

https://lairdli.top/2019/08/13/android-command/

To update your account to use zsh, please run `chsh -s /bin/zsh`.
For more details, please visit https://support.apple.com/kb/HT208050.
Laird-MacBook-Pro:~ laird$ keytool -genkey -alias test -keypass 123456 -keyalg RSA -keysize 2048 -validity 36500 -keystore test.keystore -storepass 123456
您的名字与姓氏是什么?
  [Unknown]:  lairdli.top
您的组织单位名称是什么?
  [Unknown]:  lairdli.top
您的组织名称是什么?
  [Unknown]:  lairdli.top
您所在的城市或区域名称是什么?
  [Unknown]:  wuhan
您所在的省/市/自治区名称是什么?
  [Unknown]:  hubei
该单位的双字母国家/地区代码是什么?
  [Unknown]:  china
CN=lairdli.top, OU=lairdli.top, O=lairdli.top, L=wuhan, ST=hubei, C=china是否正确?
  [否]:  y


Warning:
JKS 密钥库使用专用格式。建议使用 "keytool -importkeystore -srckeystore test.keystore -destkeystore test.keystore -deststoretype pkcs12" 迁移到行业标准格式 PKCS12。
Laird-MacBook-Pro:~ laird$

除此之外AS-Build --->> Generate Signed APK-->Create New也可以创建秘钥。

As-app-build.gradle配置

    signingConfigs {
        release {
            keyAlias 'test'
            keyPassword '123456'
            storeFile file("${rootProject.ext.defaultKeyStoreDir}" + '/test.keystore')
            storePassword '123456'
        }

        debug {
            keyAlias 'test'
            keyPassword '123456'
            storeFile file("${rootProject.ext.defaultKeyStoreDir}" + '/test.keystore')
            storePassword '123456'
        }
    }

    buildTypes {

        debug {
            signingConfig signingConfigs.debug
        }

        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            signingConfig signingConfigs.release
        }
    }

当然,如果不希望秘钥明文被看到,也可以将秘钥密匙配置在local.properties本地键值对中。

#defined kesotre
SIGNINGCONFIGS_KEYALIAS=test
SIGNINGCONFIGS_KEYPASSWORD=123456
SIGNINGCONFIGS_STOREFILE=/config/keystore/test.keystore
SIGNINGCONFIGS_STOREPASSWORD=123456

然后在app-build.gradle中配置

    signingConfigs {
        ...
        test{
            keyAlias SIGNINGCONFIGS_KEYALIAS
            keyPassword SIGNINGCONFIGS_KEYPASSWORD
            storeFile file(SIGNINGCONFIGS_STOREFILE)
            storePassword SIGNINGCONFIGS_STOREPASSWORD
        }
    }

module-config 模块化配置

之所以提出模块化编译,是为了按我们跟方便,更解耦的进行项目的编译开发工作。一切为了更好的Dev。

AndroidStudio模块化配置,可以从以下几个方面进行配置

  • setting.gradle 模块化配置,主要配置
  • build.gradle 模块化配置
  • utils.gradle 工具类配置

为更方便的管理,建议将utils.gradle工具类相关的脚步整理为单独的文件夹

config
├── app.gradle
├── config.gradle
├── cvs.gradle
├── keystore
│   └── test.keystore
├── lib.gradle
├── libdebug.gradle
├── sh
│   ├── buildAll.sh
│   └── cleanAll.sh
└── util.gradle

setting.gradle 模块化配置

对于多模块的Android项目,或者模块路径不在同级目录下的模块,自定义setting.gradle,非常有用。

rootProject.name = 'AndroidGradleTest'

/**
 * u can disable module by adding excludexxx properties in local.properties
 * the full excludexxx like below example:
 *
 exclude_app_test=true
 exclude_app_test1=true
 exclude_lib_test=true
 exclude_lib_test1=true
 * u can copy the example ,and modify in u local.properties
 * focus!!!! local.properties should not be pushed to svn or git server.
 */

println "\n======================================================"
println "**********  Init All Module ********** "
println "**** compile gradle verison:" + gradle.gradleVersion + "  ***** "
println "======================================================\n"
def enableModuleMap = [
        app_test : true,
        app_test1: true,
        lib_test : true,
        lib_test1: true
]
println "**** read enableModuleMap from local.properties"
Properties properties = new Properties()
File propertyFile = new File(rootDir.getAbsolutePath() + "/local.properties")
if (propertyFile.exists()) {
    properties.load(propertyFile.newDataInputStream())
    enableModuleMap.each {
        entry ->
            entry.value = !Boolean.parseBoolean(properties.getProperty('exclude_' + entry.key))
            println "enableModuleMap->module  " + entry.key + " is included : " + entry.value
    }
} else {
    println "**** ${propertyFile.getAbsolutePath()} is not exists! "
}
println "**** finish enableModuleMap from local.properties "


/**
 * -----------------------application modules--------------------------
 */

if (enableModuleMap.app_test) {
    include 'app_test'
    project(':app_test').projectDir = new File('testapplication')
}

if (enableModuleMap.app_test1) {
    include 'app_test'
    project(':app_test').projectDir = new File('testapplication1')
}

/**
 * -----------------------library modules--------------------------
 */
if (enableModuleMap.lib_test) {
    include 'lib_test'
    project(':lib_test').projectDir = new File('testlibrary')
}

if (enableModuleMap.lib_test1) {
    include 'lib_test1'
    project(':lib_test1').projectDir = new File('testlibrary1')
}


当暂时不需要此模块加入工程编译时,只需在local.properties中配置要剔除的模块即可

## This file is automatically generated by Android Studio.
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
#
# This file should *NOT* be checked into Version Control Systems,
# as it contains information specific to your local configuration.
#
# Location of the SDK. This is only used by Gradle.
# For customization when using a Version Control System, please read the
# header note.
sdk.dir=/Users/laird/Soft/Android/sdk

#exclude module from project
exclude_app_test=true
#exclude_app_test1=true
exclude_lib_test=true
#exclude_lib_test1=true

build.gradle模块化配置

Project-build.gradle

项目根目录下build.gradle配置,主要配置一些脚本依赖,

// Top-level build file where you can add configuration options common to all sub-projects/modules.

apply from: rootProject.file("config/util.gradle")
apply from: rootProject.file("config/cvs.gradle")
apply from: rootProject.file("config/config.gradle")

  • util.gradle

主要配置一些工具方法类

import java.security.MessageDigest
import java.text.SimpleDateFormat

ext{
    getDateYYMMDD = this.&getDateYYMMDD
    formatDateYYMMDDHMS = this.&formatDateYYMMDDHMS
    getVersionCode = this.&getVersionCode
    getVersionName = this.&getVersionName
    copyFile = this.&copyFile
    getFileSha1 = this.&getFileSha1
    getFileMd5 = this.&getFileMd5
    writeFile = this.&writeFile
}

def getDateYYMMDD(){
    Integer.parseInt(new Date().format("yyyyMMdd"))
}

def formatDateYYMMDDHMS(time){
    Calendar calendar = Calendar.getInstance()
    calendar.setTimeInMillis(time)
    Date date = calendar.getTime()
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
    sdf.format(date)
}


def getVersionCode() {
    Integer.parseInt(new Date().format("yyyyMMddmm"))

}

def getVersionName() {

    String today = new Date().format("yyyyMMdd")
    String time =  new Date().format("HHmm")

    if(rootProject.ext.isNeedSvnVersion){
        "$today" + "$time"+".$rootProject.ext.buildSvnNum"
    }else{
        "$today" + "$time"
    }
}

def copyFile(String fromFile, String dstDir,String oldName, String newName){
    copy {
        from fromFile
        into dstDir

        if(oldName!=null && newName!=null){
            rename(oldName, newName)
        }
    }
}

def getFileSha1(file)
{
    MessageDigest md = MessageDigest.getInstance("SHA-1");
    file.eachByte 4096, {bytes, size ->
        md.update(bytes, 0, size);
    }
    return md.digest().collect {String.format "%02x", it}.join();
}

def getFileMd5(file)
{
    MessageDigest md = MessageDigest.getInstance("MD5");
    file.eachByte 4096, {bytes, size ->
        md.update(bytes, 0, size);
    }
    return md.digest().collect {String.format "%02x", it}.join();
}

def writeFile(String fileName,String content,boolean appendMode){

    File file = new File(fileName)

    if(!file.exists()){
        file.createNewFile();
    }

    FileOutputStream fos = new FileOutputStream(fileName, appendMode);
    fos.write(content.getBytes("UTF-8"));
    fos.close();
}
  • cvs.gradle

主要配置一些版本相关工具类

import org.tmatesoft.svn.core.wc.ISVNOptions
import org.tmatesoft.svn.core.wc.SVNClientManager
import org.tmatesoft.svn.core.wc.SVNRevision
import org.tmatesoft.svn.core.wc.SVNStatus
import org.tmatesoft.svn.core.wc.SVNStatusClient
import org.tmatesoft.svn.core.wc.SVNWCUtil

buildscript {
    repositories {
        maven{ url 'http://maven.aliyun.com/nexus/content/groups/public/'}
    }
    dependencies {
        classpath 'org.tmatesoft.svnkit:svnkit:1.10.1'
    }
}

ext{
    buildSvnNum = this.&buildSvnNo
    buildGitNum = this.&buildGitNo
}

def buildSvnNo() {
  
    ISVNOptions options = SVNWCUtil.createDefaultOptions(true);
    SVNClientManager clientManager = SVNClientManager.newInstance(options);
    SVNStatusClient statusClient = clientManager.getStatusClient();
    SVNStatus status = statusClient.doStatus(projectDir, false);
    SVNRevision revision = status.getRevision();
    def svnLog = revision.getNumber();
}


static def buildGitNo() {
    String revisionNumberCMD = 'git rev-parse --short HEAD'
    revisionNumberCMD.execute().getText().trim()
}
  • config.gradle

主要配置一些全局变量,版本号统一等

ext {
    //true 每个业务Module可以单独开发
    //false 每个业务Module以lib的方式运行
    //修改之后需要Sync方可生效
    isBuildModule = false
    //是否需要代码混淆
    isNeedMinify = false
    //是否需要打开git版本
    isNeedSvnVersion = false
    //是否需要打开git版本
    isNeedGitVersion = false

    defaultKeyStoreDir = rootProject.file("config/keystore")
        //模块相关的属性
    modules = [
            utilcommon_dir : rootProject.file("../xxx")   ,
            uiservice_dir : rootProject.file("../xxxx")
    ]
        //android编译相关的版本号
    androids = [
            applicationId           : "top.lairdli.app",     //应用ID
            versionCode             : getVersionCode(),      //版本号
            versionName             : getVersionName(),      //版本名称
            versionCodeDebug        : 8888888888,      //版本号
            versionNameDebug        : "DebugVersion",      //版本名称

            compileSdkVersion       : 28,
            minSdkVersion           : 15,
            buildToolsVersion       : "29.0.2",
            targetSdkVersion        : 22,
            androidSupportSdkVersion: "28.0.0",
    ]

    //第三方库版本号
    versions = [

            xmaterialVersion        : "1.0.0",
            xrunnerVersion          : "1.2.0",
            xrolesVersion           : "1.2.0",
            ...
    ]

     //依赖配置
    dependencies = [
            "x_constraint_layout"   : "androidx.constraintlayout:constraintlayout:${versions["xconstraintLayoutVersion"]}",
            "x_runner"              : "androidx.test:runner:${versions["xrunnerVersion"]}",
            "x_rules"               : "androidx.test:rules:${versions["xrulesVersion"]}",
                ...
}                       

modlue-app-build.gradle

应用模块模块化,可以将统一的配置抽离整理成app.gradle

  • app.gradle
apply plugin: 'com.android.application'

android {
    compileSdkVersion rootProject.ext.androids.compileSdkVersion
    buildToolsVersion rootProject.ext.androids.buildToolsVersion
    flavorDimensions "versionCode"

    defaultConfig {
        minSdkVersion rootProject.ext.androids.minSdkVersion
        targetSdkVersion rootProject.ext.androids.targetSdkVersion
        versionCode rootProject.ext.androids.versionCode
        versionName rootProject.ext.androids.versionName

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    
    signingConfigs {
        //篇幅有限,参考as-key-store配置
    }
    
}
    //篇幅有限,参考as-auto-package配置
android.applicationVariants.all { variant ->
        
    //gradle4.10以后自定义版本命令规则以及生成目录
    variant.outputs.each { 
        ...
    }
    //自定义版本备份路径以及版本描述
    variant.assemble.doLast {
     ...
    }
}

dependencies {
//    implementation fileTree(dir: 'libs', include: ['*.jar'])
}

然后在模块下build.gradle引用app.gradle

  • build.gradle
//apply plugin: 'com.android.application'
apply from: rootProject.file("config/app.gradle")

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation rootProject.ext.dependencies["x_appcompat"]
    implementation rootProject.ext.dependencies["x_constraint_layout"]
    testImplementation rootProject.ext.dependencies["junit"]
    androidTestImplementation rootProject.ext.dependencies["runner"]
    androidTestImplementation rootProject.ext.dependencies["espresso-core"]

}

modlue-lib-build.gradle

参考modlue-app-build.gradle配置,也可以抽离lib.gradle.

  • lib.gradle
apply plugin: 'com.android.library'

android {
    compileSdkVersion rootProject.ext.androids.compileSdkVersion
    buildToolsVersion rootProject.ext.androids.buildToolsVersion

    defaultConfig {
        minSdkVersion rootProject.ext.androids.minSdkVersion
        targetSdkVersion rootProject.ext.androids.targetSdkVersion
        versionCode rootProject.ext.androids.versionCode
        versionName rootProject.ext.androids.versionName

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles 'consumer-rules.pro'
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

android.libraryVariants.all { variant ->
    variant.assemble.doLast {
        variant.outputs.each { output ->
            def outputFile = output.outputFile;

            if (outputFile != null && outputFile.name.endsWith('.aar') && variant.buildType.name == 'release') {
                copyFileToApk(outputFile,variant)
            }
        }
    }
}

def copyFileToApk(outputFile, variant) {

    def newName = variant.applicationId + '.aar';
    def releaseDir = "$rootProject.projectDir/out/aar/app/$getDateYYMMDD"

    copyFile("$outputFile", releaseDir
            , "$outputFile.name", "$newName")

    return newName;
}

dependencies {
//    implementation fileTree(dir: 'libs', include: ['*.jar'])
}

然后在library模块下的build.gradle目录下进行引用

  • build.gradle
apply from: rootProject.file("config/lib.gradle")

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    implementation rootProject.ext.dependencies["x_appcompat"]
    testImplementation rootProject.ext.dependencies["junit"]
    androidTestImplementation rootProject.ext.dependencies["runner"]
    androidTestImplementation rootProject.ext.dependencies["espresso-core"]
}
Modlue-lib(debug)-build.gradle

其实这个模块还是属性lib模块,只是在模块化项目下,某些情况下我们只负责某一个库模块,这个时候,如果要进行调试,相对于application可以直接在as上run,lib模块可能会有些劣势了。那有没有办法在最小的改动下我们也可以让lib也能像app一样run起来了?

答案是肯定的,只需一处改动就能实现。还记得config.gradle-ext里面有一个熟悉吗?

ext {
    //true 每个业务Module可以单独开发
    //false 每个业务Module以lib的方式运行
    //修改之后需要Sync方可生效
    isBuildModule = false
    ...
    }

对就是这个isBuildModule属性,当我们想让lib变成app时,只需将isBuildModule改为true然后同步下工程即可

so,怎么实现咧?

还是想上文lib.gradle配置一样,我们也可以重新配置一个libdebug.gradle.下文主要列出一些不同点。

  • libdebug.gradle
if (Boolean.valueOf(rootProject.ext.isBuildModule)) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}

android {
    ...
    sourceSets {
        main {
            jniLibs.srcDirs = ['libs']

            //如果是lib-app isBuildModule模式,走manifest目录下的AndroidManifest
            if (Boolean.valueOf(rootProject.ext.isBuildModule)) {
                manifest.srcFile 'src/main/manifest/AndroidManifest.xml'
            } else {
                manifest.srcFile 'src/main/AndroidManifest.xml'
                java {
                    //lib model ,exclude all file below java/debug/
                    exclude '*modlue'
                }
            }
        }
    }

}
//自定义版本命令规则,生成路径等
if (Boolean.valueOf(rootProject.ext.isBuildModule)) {
    android.applicationVariants.all { variant ->
     ...
    }

} else {
    android.libraryVariants.all { variant ->
     ...
    }
}


然后在待调试的lib中build.gradle配置

  • build.gradle
apply from: rootProject.file("config/libdebug.gradle")

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    if (Boolean.valueOf(rootProject.ext.isBuildModule)) {
        implementation rootProject.ext.dependencies["x_constraint_layout"]
    }
    implementation rootProject.ext.dependencies["x_appcompat"]
    testImplementation rootProject.ext.dependencies["junit"]
    androidTestImplementation rootProject.ext.dependencies["runner"]
    androidTestImplementation rootProject.ext.dependencies["espresso-core"]
}

这样你的library模块就可以向application一样飞一般的run了。

but还是有些问题,如果子模块有重写application逻辑,或者项目中用了类似ARouter的工具结构,那又改怎么配置咧?不急,一步一步来。

  • aRouter

配置aRouter引用,模块build.gradle进行配置

android {
  
     javaCompileOptions {
        annotationProcessorOptions {
            arguments = [AROUTER_MODULE_NAME: project.getName()]
        }
    }
}

dependencies {
    implementation rootProject.ext.dependencies["arouter"]
    annotationProcessor rootProject.ext.dependencies["arouter-compiler"]
}

MainActivity统一拦截url或intent进行分发处理

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        handleIntent(intent);
    }

    private void handleIntent(Intent intent) {
        ARouterHelper.getInstance().dispatchPage(intent);
        finish();
    }
}

Arouter调用时我们也可以封装一个工具类进行路由分发等

/**
 * @author laird
 * @date 2019-12-30 11:09
 * @desc
 */
public class ARouterHelper {
    public static final String PATH_ACTIVITY_LIB_TEST = "/ModuleLib/LibActivity";
    public static final String ACTION_ACTIVITY_LIB_TEST = "top.lairdli.action.LIB_TEST";

    public static ARouterHelper getInstance() {
        return SingletonHolder.INSTANCE;
    }

    private static class SingletonHolder {
        private static final ARouterHelper INSTANCE = new ARouterHelper();
    }

    public void build(String path){
        ARouter.getInstance().build(path).navigation();
    }

    public void build(String path, Activity activity, int requestCode){
        ARouter.getInstance().build(path).navigation(activity,requestCode);
    }

    public void build(String path,String key,String value){
        ARouter.getInstance().build(path).withString(key,value).navigation();
    }

    public Postcard getPostCard(String path){
        return ARouter.getInstance().build(path);
    }

    //fix me
    // 1. intent can be replaced by schame-url
    // 2. withSerializable can be replaced by with object,but u should implement SerializationService First

    public void dispatchPage(Intent intent) {

        if (intent == null || intent.getAction() == null) {
            dispatchPageDefault();
            return;
        }

        switch (intent.getAction()) {
            case ACTION_ACTIVITY_LIB_TEST:
                build(PATH_ACTIVITY_LIB_TEST);
                break;

            default:
                dispatchPageDefault();
        }
    }

    private static void dispatchPageDefault() {
        // to add u default page
    }

}

更多使用方法可以参靠Arouter官方说明

  • Application

lib向app转换时,另外一个问题就是Application逻辑的问题,当主APP包含Lib模块时,我们也希望Lib中的逻辑也能被执行,但manifest却只能配置一个application-name.配置了主App的application后,lib就不能配置了。

本着尽量解耦,最少改动的原则,我们还是用面向接口编程的实现,先看如下类图,看完你就明白了。

android-as-mul-module.png

Main-appliaction

public class MainApplication extends BaseApplication implements IAppApplication {

    private static final String[] MODULESLIST =
            {"top.lairdli.study.testlibrary.LibApplication"};

    @Override
    public List<String> getModuleAppClassList() {
        return Arrays.asList(MODULESLIST);
    }

    @Override
    public void init(Application instance) {
        Log.d(LOG_TAG, "---init");
        //to do u biz
    }
}

Libdebug-application

public class LibApplication extends BaseApplication {
    
    @Override
    public void init(Application instance) {
        Log.d(LOG_TAG,"---init");
        //to do u biz
    }
}

BaseApplication


/**
 * @Description: BaseApplication
 */
@SuppressLint("Registered")
public abstract class BaseApplication extends Application implements IComponentApplication {

    protected String LOG_TAG = "BaseApplication";
    private static BaseApplication instance;

    public  static BaseApplication getInstance() {
        return instance;
    }

    private List<Activity> mList = new LinkedList<Activity>();

    public BaseApplication() {
        super();
        LOG_TAG = this.getClass().getSimpleName();
    }

    @Override
    public void onCreate() {
        Log.v(LOG_TAG, "onCreate()");
        super.onCreate();
        //init ARouter
        if(BuildConfig.DEBUG){
            ARouter.openLog();
            ARouter.openDebug();
        }
        ARouter.init(this);
        instance= this;
        //asbs method ,implementation in sub class
        init(this);
        //init call modulesApplication
        if (IAppApplication.class.isAssignableFrom(this.getClass())) {
            IAppApplication appApplication = (IAppApplication) this;
            modulesApplicationInit(appApplication.getModuleAppClassList());
        }
    }

    private void modulesApplicationInit(List<String> modulesList){
        for (String moduleImpl : modulesList){
            try {
                Class<?> clazz = Class.forName(moduleImpl);
                Object obj = clazz.newInstance();
                if (obj instanceof IComponentApplication){
                    ((IComponentApplication) obj).init(BaseApplication.getInstance());
                }
            } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
                e.printStackTrace();
            }
        }
    }

IApplication

public interface IAppApplication {
    List<String> getModuleAppClassList();
}

IComponentApplication

public interface IComponentApplication {
    void init(Application instance);
}

MainApplication编译时运行打印如下

2019-12-30 11:43:39.219 23539-23539/? D/MainApplication: ---init
2019-12-30 11:43:39.219 23539-23539/? D/LibApplication: ---init

Libdebug-application当模块编译时运行打印如下

2019-12-30 11:43:39.219 23539-23539/? D/LibApplication: ---init

以上是我总结的一种解决模块化Application问题的方法之一,如果还有其他更好的方法也欢迎补充。

eclipse-translate 旧工程迁移

对于以前eclipse旧工程,如果我们不希望破坏之前原有路径或者代码结构,但又想再忍受eclipse的IED,想再AS上进行调试,只需3个步骤三部曲就行

1. 脚本拷贝

新建as工程文件夹,拷贝正常as项目文件配置到步骤1所建立的文件夹

-rw-r--r--   1 laird  staff   713 12 29 16:05 build.gradle
drwxr-xr-x   3 laird  staff    96 12 29 14:42 gradle/
-rw-r--r--   1 laird  staff  1073 12 29 15:20 gradle.properties
-rwxr--r--   1 laird  staff  5296 12 29 14:42 gradlew*
-rw-r--r--   1 laird  staff  2260 12 29 14:42 gradlew.bat
-rw-r--r--   1 laird  staff  2079 12 29 17:26 settings.gradle

2. 项目配置

配置项目setting.gradle,将需要转换的eclispe工程模块include到setting.gradle中。

可参考As-project-setting.gradle

rootProject.name = 'ProjectEclipse2As'

include 'app_module'
project(':app_module').projectDir = new File('../u eclispe app moudle path)

include 'lib_module'
project(':lib_vodservice').projectDir = new File('../../u eclispe lib moudle path')

3. 配置模块build.gradle

模块build.gradle包含library,application两种,掌握了application的配置,library的配置对比着配就行了

建议直接copy一份完整的application-build.gradle,然后我们只需要改几个关键的点就行

  • 源码路径

    由于as默认的源码构建方式和eclipse有些区别,因为第一个重要的点就是配置源码路径

        sourceSets {
            main {
                manifest.srcFile 'manifest/AndroidManifest.xml'
                java.srcDirs = ['src']
                resources.srcDirs = ['src']
                aidl.srcDirs = ['src']
                renderscript.srcDirs = ['src']
                res.srcDirs = ['res']
                assets.srcDirs = ['assets']
            }
        }
    

    需要注意的是manifest,由于最新版本的as对manifest的一些配置有强制限制(版本号相关),因而建议copy一份AndroidManifest.xml到manifest文件夹,重新制定路径。这样不影响之前elcipse工程配置。

  • 依赖配置

    原eclipse工程依赖配置可在project.properties文件中查看,

    target=android-17
    proguard.config=proguard.cfg
    android.library.reference.1=../../../library1
    android.library.reference.2=../../../library2
    

    根据project.properties的配置在build.gradle中的depend中相应配置。

    dependencies {
        implementation fileTree(dir: 'libs', include: ['*.jar'])
        implementation project(':library1')
        implementation project(':library2')
        }
    

    需要注意的是除了模块间的依赖外,libs下的jar包,so依赖也许要注意。

    jar包依赖分两种,一种编译时依赖,一种运行时依赖。

    编译时依赖只参与编译,不打入app源码,一般是引用系统api会用到。

    编译时依赖使用compileOnly

      compileOnly files('libs/compileOnlyxxx.jar')
    

    运行时依赖除了参与编译,会打入app源码,常见的模块键依赖就是这种。

    运行时依赖使用implementation

      implementation files('libs/compilexx.jar
    
  • 编译配置

    eclispe编译配置可在mainifest文件中查看,然后在build.gradle相应配置就行。

后记

Gralde的学习应远不止与此,重在实践与理解。

本文涉及到的相关源码已整理开源到github

示例-androidgradle

https://github.com/lairdli/AndroidGradle

在线浏览-androidgradle

https://lairdli.top/2019/12/30/android-gradle/

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,009评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,808评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,891评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,283评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,285评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,409评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,809评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,487评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,680评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,499评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,548评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,268评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,815评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,872评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,102评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,683评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,253评论 2 341

推荐阅读更多精彩内容