android 打造自己的gradle构建脚本(以集成Tinker为例)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/anyfive/article/details/80160279

转载请注明出处: https://blog.csdn.net/anyfive/article/details/80160279

前言

随着android studio的普及,gradle作为android studio的官方构建工具,也变得越来越重要。那么学会编写自己需要的gradle脚本,就变成了一件很酷的事。

不了解gradle的同学,对gradle的印象一定是:
* 真鸡儿慢
* 动一下就要Sync now
* 为毛规定这个要写在这里,那个又要写在那里啊
* 真鸡儿丑,一点也不优雅
* 等等

在这篇文章里,我会用力地帮你把这坨代码变优雅,并且让他去做一些很酷的事。

准备

在正式开始写代码之前,我觉得还是有必要先搞懂gradle是什么,他是怎么个工作法。

首先,gradle其实就是一个构建工具,对我们来说,他可以帮我们下载依赖库、编译文件、打包等等。

其次,gradle脚本是用groovy写的,什么是groovy呢?groovy就是一套语言,执行groovy的时候,脚本会先编译成java字节码,再运行于JVM中。嗯,没错,编译成java字节码,说明啥?说明兼容java啊,同志们,我们完全可以用java语法来写groovy脚本啊,这就是一个纸老虎啊!

最后,其实AS上面的操作,包括build啊clean啊打包啊,都是跑了一次特定的脚本。

我们还是正经地来介绍一下groovy的语法和gradle的构建流程吧。

groovy

int i=0  //所有java的基本数据类型groovy都可以用
def i="" //也可以不指定变量的类型

def list=["A","B","C"] //List集合
def map=[A: "A",B: "B"] //Map集合

 //指一个test方法,声明返回类型为String,参数类型为String
String test(String arg) {
    return arg + "test"
}
//当然,你也可以不指定具体类型
def test(arg) {
    arg + "test" //默认返回最后一行执行结果,所以可以不用return
}

//执行方法
test("Hello world")
//执行方法的另一种方式
test "Hello world"

//定义一个闭包
def closure = { arg->
    arg + "test"
}
def closureNoArg = {
    println("当闭包没有声明参数时,默认有一个it参数,相当于this")
}

//调用闭包
closure("Hello world")
closure.call("Hello world")
//把闭包当成一个回调一样传入方法
test1(closure) //test1可以在内部执行closure
test1({}) //传入一个闭包,闭包内可写执行代码
test1 {} //当test1最后一个参数是闭包时,可以免括号

gradle

gradle项目根目录必须有build.gradle和setting.gradle文件。setting.gradle定义了内部的子项目,也就是android里的Library/Module和project(默认命名为app),大家可以打开看一哈。每个子项目都有自己的build.gradle,build.gradle其实就是编译脚本。

gradle的编译其实就是执行一堆相互依赖的task组成的插件,task是gradle执行的最小单元,一堆task组成插件,比如编译Java的Java插件、编译Android的Android插件。

gradle的执行顺序为:
1. 初始化,确定哪些project要构建,执行setting.gradle
2. 配置,解析所有project的build.gradle,构建task的依赖关系
3. 执行,根据tasks的执行顺序执行

//定义了一个task,task内的方法在配置时执行
task test {
    println "Hello world"
}
project.task("tast1") {
    println "Hello world"
}
project.tasks.create("test2") {
    println "Hello world"
}

//执行task
./gradlew test test1 test2

//每一个task都可以指定执行前后的操作,在执行阶段
test.doFirst {
    println "pre test"
}
test.doLast {
    println "after test"
}
//输出为:
//Hello world
//pre test
//after test

//也可以通过@TaskAction指定task执行时的执行方法
class TestTask extends DefaultTask {
    String message = "Hello world"
    @TaskAction
    def hello() {
        println message
    }
}
//输出Hello world

//task也可以通过type继承
task TestChild(type: TestTask) {
    message = "Hello TestChild"
}
//替换了TestTask的message方法,输出Hello TestChild

//task可以依赖其他task,执行task会自动先执行他依赖的tasks
task TestDepend(dependsOn: test) {
    println "Hello TestDepend"
}
//输出:
//Hello world
//Hello TestDepend

关于groovy和gradle的更详细全面准确的教程,推荐阅读大神的《深入理解Android之Gradle》

关于gradle的执行时序、task的更详细的教程,推荐阅读《全面理解Gradle - 执行时序》 《全面理解Gradle - 定义Task》

模块化配置build.gradle

说了这么多,估计大兄弟们已经按耐不住了,我们现在马上开始上个实例。

模块化配置build.gradle是什么意思呢?我们先来看看AS给我们生成的build.gradle是怎样的:

apply plugin: 'com.android.application'
android {
    compileSdkVersion 27
    defaultConfig {
        applicationId "com.example.wzm.test"
        minSdkVersion 15
        targetSdkVersion 27
        versionCode 1
        versionName "1.0"
    }
    signingConfigs {
        release {
            storeFile file('../test.jks')
            storePassword '123456'
            keyAlias 'key0'
            keyPassword '123456'
        }
    }
    buildTypes {
        release {
            minifyEnabled true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            signingConfig signingConfigs.release
        }
    }
}
dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation 'com.android.support:appcompat-v7:27.1.1'
    implementation 'com.android.support.constraint:constraint-layout:1.1.0'
    // tinker
    provided('com.tencent.tinker:tinker-android-anno:1.9.2')
    annotationProcessor('com.tencent.tinker:tinker-android-anno:1.9.2')
    compile('com.tencent.tinker:tinker-android-lib:1.9.2')
}

emmmm….其实这样看还好,没有太丑,但是当你往上加入很多配置的时候,你就会觉得,what the xxxx? 越来越长,看起来越来越乱,怎么办?

在开始配置之前,我还要说一个知识点,一个少了他不行的东东,叫做ext。ext是什么?ext是额外属性(extra property)的意思,意思就是:一个gradle的文件里定义了ext,其他apply他的gradle文件就可以获得ext里面的内容。有点像Node.js里面的module.exports有木有?

有了ext,我们就可以在build.gradle加载一个配置文件config.gradle了,我们先来看看修改后的build.gradle:

apply plugin: 'com.android.application'
apply from: './config.gradle'
def androidConfig = ext.androidConfig
def dependenciesConfig = ext.dependenciesConfig
def androidDefualtConfig = ext.androidDefualtConfig
def mSigningConfig = ext.signingConfig
android {
    compileSdkVersion androidDefualtConfig.compileSdkVersion
    defaultConfig {
        applicationId androidDefualtConfig.applicationId
        minSdkVersion androidDefualtConfig.minSdkVersion
        targetSdkVersion androidDefualtConfig.targetSdkVersion
        versionCode androidConfig.versionCode
        versionName androidConfig.versionName
    }
    signingConfigs {
        release {
            storeFile mSigningConfig.storeFile
            storePassword mSigningConfig.storePassword
            keyAlias mSigningConfig.keyAlias
            keyPassword mSigningConfig.keyPassword
        }
    }
    buildTypes {
        release {
            minifyEnabled true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug {
            signingConfig signingConfigs.release
        }
    }
}
dependencies {
    //用循环从依赖配置里获得库后依赖
    for(def lib: dependenciesConfig.providedLib) {
        provided lib
    }
    for(def lib: dependenciesConfig.annotationLib) {
        annotationProcessor lib
    }
    for(def lib: dependenciesConfig.implementationLib) {
        implementation lib
    }
}

我们再看一下config.gradle:

//tinker
def tinkerLib = [
        implementation     : 'com.tencent.tinker:tinker-android-lib:1.9.2',
        provided           : 'com.tencent.tinker:tinker-android-anno:1.9.2',
        annotationProcessor: 'com.tencent.tinker:tinker-android-anno:1.9.2'
]
ext {
    androidConfig = [
            versionCode: 1,
            versionName: '1.0',
    ]
    //依赖配置
    dependenciesConfig = [
            implementationLib: [
                    fileTree(include: ['*.jar'], dir: 'libs'),
                    'com.android.support:appcompat-v7:27.1.1',
                    'com.android.support.constraint:constraint-layout:1.1.0',
                    tinkerLib.implementation
            ],
            providedLib: [
                    tinkerLib.provided
            ],
            annotationLib: [
                    tinkerLib.annotationProcessor
            ]
    ]
    //基本不需要变动的配置
    androidDefualtConfig = [
            compileSdkVersion: 27,
            applicationId    : 'com.example.jimi_wu.test',
            minSdkVersion    : 15,
            targetSdkVersion : 27,
    ]
    //签名配置
    signingConfig = [
            storeFile    : file('../test.jks'),
            storePassword: '123456',
            keyAlias     : 'key0',
            keyPassword  : '123456'
    ]
}

哇,这个config.gradle真是,赏心悦目啊!虽然build.gradle还是一坨,但是以后修改配置,也不用打开他了,打开config.gradle就可以了,把会变动的配置放到上面,这样改起来也方便了一丢丢有木有?

仔细观察,发现我们前面说的groovy的语法在gradle文件里就用上了有没有?build.gradle里的for循环,config.gradle里的list和map,这下证明我前面说的不是废话了吧。

当然,这里只是一个例子,具体的配置还是要跟项目结合。

集成tinker的自动打包备份

Tinker是微信推出的一款Android热修复框架,也是微信正在使用的热修复框架。关于Tinker的工作原理和集成方式,网上一大把,在这里就不累赘了,建议大兄弟们还是去搜一搜了解一下。

我们在这里,以打包基准包和补丁包来作为实例。

之前我们说到,gradle的每一个task,都可以有一个doFirst和doLast,而我们打包apk用到的task就是assembleRelease,那么我们就可以在这个task的执行前进行校验工作,在执行后进行文件的备份工作。

首先,同样的,我们把tinker的配置也写到一个单独的gradle文件内,然后在build.gradle内apply;然后,我们新建一个专门用于打包的gradle脚本,命名为release.gradle,话不多说,直接来看下这个release.gradle:

//版本号,也是tinkId
def mVersionName = project.androidConfig.versionName
//补丁版本号
def mTinkerVersion = "1.0"

//输出目录
def mOutputPath = "${rootDir}/release/v${mVersionName}/"
//基准包输出目录
def mBaseApkPath = "${mOutputPath}/BaseApk"
//补丁包输出目录
def mPatchPath = "${mOutputPath}/tinker-version-${mTinkerVersion}/"

/**
 *  使用命令行运行assembleRelease时:
 *  运行assembleRelease前,校验版本是否已存在
 *  运行assembleRelease后,自动把生成的apk、mapping.txt、R.txt拷贝到基准apk目录下
 */
project.afterEvaluate { //在建立有向图之后,才能找到对应的task
    //不是使用assembleRelease命令运行的,不做出来,比如打补丁包
    if (!project.gradle.startParameter.taskNames.contains("assembleRelease")) return
    tasks.getByName("assembleRelease") {
        it.doFirst {
            println "开始打基准包..."
            println "开始校验基准包版本....."
            //做校验,如果版本号存在,中断
            if (new File(mOutputPath).exists()) {
                println "该版本基准apk已存在,打包任务停止"
                assert false
            }
            println "校验通过,开始打包..."
        }
        it.doLast {
            println "打包完成,开始复制文件,目录路径为:${mBaseApkPath}"
            def baseApkName = "${rootProject.name}-v${mVersionName}.apk"
            println "开始复制apk文件,并更名为:${baseApkName}"
            copy {
                from "${buildDir}/outputs/apk/release/app-release.apk"
                into mBaseApkPath
                rename {String name->
                    return baseApkName
                }
            }
            println "开始复制mapping.txt文件..."
            copy {
                from "${buildDir}/outputs/mapping/release/mapping.txt"
                into mBaseApkPath
            }
            println "开始复制R.txt文件..."
            copy {
                from "${buildDir}/intermediates/symbols/release/R.txt"
                into mBaseApkPath
            }
            println "复制完成"
        }
    }
}

我们先来看一下我们对assembleRelease做了哪些好事:
1. 判断是不是执行的assembleRelease命令,为什么这里要这样判断呢?这是因为tinker打补丁包的task其实也是依赖于assembleRelease的,如果我们这里不做判断,那么在打补丁包的时候,我们也会执行版本的校验和打包后的拷贝操作,这明显与我们要的不一样;
2. 获得assembleRelease的task,在doFirst中,进行版本的校验;
3. 在doLast后,把apk、mapping.txt、R.txt拷贝我们的输出目录内,这三个都是我们打补丁包时需要用到的文件;同时对apk进行重命名;

同样,打tinker补丁包的时候,运行的是tinkerPatchRelease,那么我们也给这个task加点料:

/**
 *  使用命令行运行tinkerPatchRelease时:
 *  运行tinkerPatchRelease前,校验基准包和补丁包
 *  运行tinkerPatchRelease后,把生成的patch_signed.apk拷贝到输出目录
 */
project.afterEvaluate {
    //不是用的tinkerPatchRelease命令运行,也就是并不是要打补丁包
    if (!project.gradle.startParameter.taskNames.contains("tinkerPatchRelease")) return

    tasks.getByName("tinkerPatchRelease") {
        it.doFirst {
            println "开始打补丁包...当前基准包版本: ${mVersionName}...当前补丁包版本: ${mTinkerVersion}"
            println "开始校验基准包是否存在....."
            //如果基准包号不存在,中断
            if (!(new File(mBaseApkPath+mBaseApkName).exists())) {
                println "基准包不存在,打包中断"
                assert false
            }
            println "基准包校验通过,开始校验补丁包版本..."
            if(new File(mPatchPath).exists()) {
                println "该版本补丁包已存在,打包中断"
                assert false
            }
            println "补丁包版本校验通过,开始打包..."
        }
        it.doLast {
            println "打包完成,开始复制文件,目录路径为:${mPatchPath}"
            println "开始复制补丁apk文件..."
            copy {
                from "${buildDir}/outputs/apk/tinkerPatch/release/patch_signed_7zip.apk"
                into mPatchPath
            }
            println "开始复制log文件..."
            copy {
                from "${buildDir}/outputs/apk/tinkerPatch/release/log.txt"
                into mPatchPath
            }
            println "复制完成"
        }
    }
}

可以看到,我们对tinkerPatchRelease做的操作与上一个基本没什么不同,都是校验、拷贝,所以就不详细展开了。

现在我们执行一下./gradlew assembleRelease命令(win系统应该是gradle assembleRelease命令),当执行完成后,就可以看到项目根目录下出现了release文件夹,里面就有我们需要的东西:

image

然后开始打补丁包,执行./gradlew tinkerPatchRelease命令,执行完成后,我们看到我们的release文件夹中,多了补丁包的目录,同时打包后的补丁apk和log文件也已经转移过去了:

image

多渠道的自动打包备份

上一节我们讲到给打包task加上校验和备份,但各位大兄弟们应该也对这种方式的缺点心中有数,缺点是什么呢?就是实际工作中基本都是多渠道打包,哪有那么好只打一个包!!别着急,我们现在来说说多渠道打包怎么搞啊。

多渠道的打包和单渠道的打包有不同之处(废话),上一节我们说到了单渠道的打包是对assembleRelease的任务进行操作,而多渠道打包不是这样,为何?多渠道的时候,assembleRelease任务不会对包进行具体的渠道操作,渠道操作将会由不同的assemble${FLAVORNAME}(如assembleWandoujia)来进行,因此我们在assembleRelease任务中,并不能辨识渠道。也就是说,我们的拷贝操作,要放到具体的每一个assembleFLAVORNAME任务后,我们来看下修改后的release.gradle文件:

//版本号,也是tinkId
def mVersionName = project.androidConfig.versionName
//补丁版本号
def mTinkerVersion = "1.0"

//输出目录
def mOutputPath = "${rootDir}/release/v${mVersionName}/"
//基准包输出目录
def mBaseApkPath = "${mOutputPath}/BaseApk/flavorName/"
//基准apk名称
def mBaseApkName = "${rootProject.name}-v${mVersionName}-flavorName.apk"
//补丁包输出目录
def mPatchPath = "${mOutputPath}/tinker-version-${mTinkerVersion}/flavorName/"
//渠道
List<String> flavors = new ArrayList<>()
android.applicationVariants.all { variant ->
    if (variant.name.contains("Release")) {
        flavors.add(variant.flavorName)
    }
}

/**
 *  使用命令行运行assembleRelease时:
 *  运行assembleRelease前,校验文件是否已存在
 *  运行assembleRelease后,自动把生成的apk、mapping.txt、R.txt拷贝到基准apk目录下
 */
android.applicationVariants.all { variant ->
    //不是使用assembleRelease运行的
    if (!project.gradle.startParameter.taskNames.contains("assembleRelease")) return
    //做校验,如果版本号存在,中断
    if (new File(mOutputPath).exists()) {
        print "该版本已存在,打包任务停止"
        assert false
    }
    //获得task名
    String taskName = variant.getName().capitalize()
    if (!taskName.contains("Release")) return
    tasks.getByName("assemble${taskName}") {
        String flavorName = taskName.substring(0, taskName.length() - 7).toLowerCase()
        String dir = mBaseApkPath.replace("flavorName", flavorName)
        String apkName = mBaseApkName.replace("flavorName", flavorName)
        it.doLast {
            //复制apk
            copy {
                from "${buildDir}/outputs/apk/${flavorName}/release/app-${flavorName}-release.apk"
                into "${dir}"
                rename { String fileName ->
                    return apkName
                }
            }
            //复制mapping.txt
            copy {
                from "${buildDir}/outputs/mapping/${flavorName}/release/mapping.txt"
                into "${dir}"
            }
            //复制R.txt
            copy {
                from "${buildDir}/intermediates/symbols/${flavorName}/release/R.txt"
                into "${dir}"
            }
        }
    }
}

我们来看一下具体的步骤:
1. 使用android.applicationVariants.all方法来遍历每一个variant(变体),在内部我们可以拿到每一个渠道;
2. 判断是不是assembleRelease命令运行的;
3. 校验版本号;
4. 获得每个渠道对应的task,添加doLast进行拷贝;

那么打补丁包的时候呢?当然你也可以用上面的方法,问题是,打多个渠道包可以用assembleRelease命令来打,那怎么做到一个命令打多个渠道的补丁包呢?其实TinkerSample的build.gradle已经告诉了我们答案,我们来看一下对多渠道打包,Tinker是怎么做的:

project.afterEvaluate {
        //sample use for build all flavor for one time
        if (hasFlavors) {
            task(tinkerPatchAllFlavorRelease) {
                group = 'tinker'
                def originOldPath = getTinkerBuildFlavorDirectory()
                for (String flavor : flavors) {
                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
                    dependsOn tinkerTask
                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}ReleaseManifest")
                    preAssembleTask.doFirst {
                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 15)
                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release.apk"
                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-mapping.txt"
                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-release-R.txt"

                    }

                }
            }

            task(tinkerPatchAllFlavorDebug) {
                group = 'tinker'
                def originOldPath = getTinkerBuildFlavorDirectory()
                for (String flavor : flavors) {
                    def tinkerTask = tasks.getByName("tinkerPatch${flavor.capitalize()}Debug")
                    dependsOn tinkerTask
                    def preAssembleTask = tasks.getByName("process${flavor.capitalize()}DebugManifest")
                    preAssembleTask.doFirst {
                        String flavorName = preAssembleTask.name.substring(7, 8).toLowerCase() + preAssembleTask.name.substring(8, preAssembleTask.name.length() - 13)
                        project.tinkerPatch.oldApk = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug.apk"
                        project.tinkerPatch.buildConfig.applyMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-mapping.txt"
                        project.tinkerPatch.buildConfig.applyResourceMapping = "${originOldPath}/${flavorName}/${project.name}-${flavorName}-debug-R.txt"
                    }

                }
            }
        }
    }

我们来分析一下:
1. 定义一个名为tinkerPatchAllFlavorRelease的task;
2. 遍历渠道名数组;
3. 让tinkerPatchAllFlavorRelease依赖于所有的tinkerPatch${FLAVORNAME}Release任务;
4. 获得每一个process${FLAVORNAME}ReleaseManifest任务,添加doFirst设置基准文件的具体目录;

那么上面那段代码说明了些什么呢?tinker对补丁包的多渠道操作与assembleRelease一样,也是交给具体的tinkerPatch${FLAVORNAME}Release任务来进行;process${FLAVORNAME}ReleaseManifest任务是配置tinker的任务,我们可以通过设置其中的变量来对tinker的具体配置进行更改。知道了这两点,我们就可以很优雅地写出我们自己的多渠道打包代码了:

project.afterEvaluate {
    if (!project.gradle.startParameter.taskNames.contains("tinkerPatchRelease")) return
    //校验基准包是否存在
    if (!(new File(mBaseApkPath.replace("flavorName/", "")).exists())) {
        print "基准包不存在,打包中断..."
        assert false
    }
    //校验补丁包是否存在
    if(new File(mPatchPath.replace("flavorName/", "")).exists()) {
        println "该版本补丁包已存在,打包中断..."
        assert false
    }
    task(tinkerPatchRelease) {
        group "Tinker"
        for (String flavor : flavors) {
            def tinkerFlavorRelease = tasks.getByName("tinkerPatch${flavor.capitalize()}Release")
            dependsOn tinkerFlavorRelease
            //配置oldApk等基准路径
            def preTask = tasks.getByName("tinkerProcess${flavor.capitalize()}ReleaseManifest")
            preTask.doFirst {
                String flavorName = preTask.name.substring(13, preTask.name.length() - 15).toLowerCase()
                String dir = mBaseApkPath.replace("flavorName", flavorName)
                String apkName = mBaseApkName.replace("flavorName", flavorName)
                project.tinkerPatch.oldApk = "${dir}/${apkName}"
                project.tinkerPatch.buildConfig.applyMapping = "${dir}/mapping.txt"
                project.tinkerPatch.buildConfig.applyResourceMapping = "${dir}/R.txt"
            }
            //复制生成的文件到相应的目录
            tinkerFlavorRelease.doLast {
                String flavorName = tinkerFlavorRelease.name.substring(11, tinkerFlavorRelease.name.length() - 7).toLowerCase()
                String dir = mPatchPath.replace("flavorName", flavorName)
                //复制apk
                copy {
                    from "${buildDir}/outputs/apk/${flavorName}/tinkerPatch/${flavorName}/release/patch_signed_7zip.apk"
                    into "${dir}"
                }
                //复制log
                copy {
                    from "${buildDir}/outputs/apk/${flavorName}/tinkerPatch/${flavorName}/release/log.txt"
                    into "${dir}"
                }
            }
        }
    }
}

可以看到,我们定义了一个名为tinkerPatchRelease的task,同时完成了补丁包的校验和备份操作。

现在我们来执行一下./gradlew assembleRelease命令,执行完成后,我们的release目录下,所有的基准文件已经乖乖地按版本、渠道躺好了:
image

我们再来执行一下./gradlew tinkerPatchRelease命令,执行完成后,我们看到补丁包也已经乖乖地躺好了,美滋滋:

image

后记

其实看完多渠道打包,很多同学应该会想,那单渠道打包为什么不用这种方式呢?其实单渠道和多渠道的打包,主要还是希望用两种不同的方式来告诉各位读者:同一个需求有很多种实现方式,我们不选择最好的,而是选择最适合我们的。

当你学会编写自己的gradle构建脚本后,你可以做的事远不止打包校验和备份,比如你可以在打包的时候,维护一份打包记录的json/xml文件,然后把该文件和包放到服务器的静态路径下,这样你的客户端就可以通过读取这个文件来完成补丁包的检测和下载或者新版本的检测和下载了;所以说,单纯把脚本复制粘贴只能用一次,自己尝试过才能应对不同的需求。

源码地址: https://github.com/whichname/GradleTest
ps: 源码与本文实例代码有所不同,在源码中,tinker将作为独立的module来方便不同项目的迁移。

猜你喜欢

转载自blog.csdn.net/anyfive/article/details/80160279
今日推荐