手把手帶你自定義 Gradle 外掛 —— Gradle 系列(2)

彭旭銳發表於2022-05-17

請點贊加關注,你的支援對我非常重要,滿足下我的虛榮心。

? Hi,我是小彭。本文已收錄到 GitHub · Android-NoteBook 中。這裡有 Android 進階成長知識體系,有志同道合的朋友,歡迎跟著我一起成長。(聯絡方式在 GitHub)

前言

Gradle 本質上是高度模組化的構建邏輯,便於重用並與他人分享。例如,我們熟悉的 Android 構建流程就是由 Android Gradle Plugin 引入的構建邏輯。在這篇文章裡,我將帶你探討 Gradle 外掛的使用方法、開發步驟和技巧總結。

這篇文章是全面掌握 Gradle 構建系統系列的第 2 篇:


1. 認識 Gradle 外掛

1.1 什麼是 Gradle 外掛

Gradle 和 Gradle 外掛是兩個完全不同的概念,Gradle 提供的是一套核心的構建機制,而 Gradle 外掛則是執行在這套機制上的一些具體構建邏輯,本質上和 .gradle 檔案是相同。例如,我們熟悉的編譯 Java 程式碼的能力,都是由外掛提供的。

1.2 Gradle 外掛的優點

雖然 Gradle 外掛與 .gradle 檔案本質上沒有區別,.gradle 檔案也能實現 Gradle 外掛類似的功能。但是,Gradle 外掛使用了獨立模組封裝構建邏輯,無論是從開發開始使用來看,Gradle 外掛的整體體驗都更友好。

  • 邏輯複用: 將相同的邏輯提供給多個相似專案複用,減少重複維護類似邏輯開銷。當然 .gradle 檔案也能做到邏輯複用,但 Gradle 外掛的封裝性更好;
  • 元件釋出: 可以將外掛釋出到 Maven 倉庫進行管理,其他專案可以使用外掛 ID 依賴。當然 .gradle 檔案也可以放到一個遠端路徑被其他專案引用;
  • 構建配置: Gradle 外掛可以宣告外掛擴充套件來暴露可配置的屬性,提供定製化能力。當然 .gradle 檔案也可以做到,但實現會麻煩些。

1.3 外掛的兩種實現形式

Gradle 外掛的核心類是 Plugin,一般使用 Project 作為泛型實參。當使用方引入外掛後,其實就是呼叫了 Plugin#apply() 方法,我們可以把 apply() 方法理解為外掛的執行入口。例如:

MyCustomGradlePlugin.groovy

public class MyCustomGradlePlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        println "Hello."
    }
}

如果根據實現形式分類(MyCustomGradlePlugin 的程式碼位置),可以把 Gradle 外掛分為 2 類:

  • 1、指令碼外掛: 指令碼外掛就是一個普通的指令碼檔案,它可以被匯入都其他構建指令碼中。有的朋友說指令碼外掛也需要使用 Plugin 介面才算指令碼外掛,例如:

build.gradle

apply plugin: MyCustomGradlePlugin

class MyCustomGradlePlugin implements Plugin<Project> {
    ...
}
  • 2、二進位制外掛 / 物件外掛: 在一個單獨的外掛模組中定義,其他模組通過 Plugin ID 應用外掛。因為這種方式釋出和複用更加友好,我們一般接觸到的 Gradle 外掛都是指二進位制外掛的形式。

1.4 應用外掛的步驟

我們總結下使用二進位制外掛的步驟:

  • 1、將外掛新增到 classpath: 將外掛新增到構建指令碼的 classpath 中,我們的 Gradle 構建指令碼才能應用外掛。這裡區分本地依賴和遠端依賴兩種情況。

本地依賴: 指直接依賴本地外掛原始碼,一般在除錯外掛的階段是使用本地依賴的方式。例如:

專案 build.gradle

buildscript {
    ...
    dependencies {
        // For Debug
        classpath project(":easyupload")
    }
}

遠端依賴: 指依賴已釋出到 Maven 倉庫的外掛,一般我們都是用這種方式依賴官方或第三方實現的 Gradle 外掛。例如:

專案 build.gradle

buildscript {
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.3'
        // 也可以使用另一種等價語法:
        classpath group: 'com.android.tools.build ', name: 'gradle ', version: '3.5.3'
    }
    ...
}
  • 2、使用 apply 應用外掛: 在需要使用外掛的 .gradle 指令碼中使用 apply 應用外掛,這將建立一個新的 Plugin 例項,並執行 Plugin#apply() 方法。例如:
apply plugin: 'com.android.application'

// 或者

plugins {
    // id «plugin id» [version «plugin version»] [apply «false»]
    id 'com.android.application'
}

注意: 不支援在一個 build.gradle 中同時使用這兩種語法。

1.5 特殊的 buildSrc 模組

外掛模組的名稱是任意的,除非使用了一個特殊的名稱 “buildSrc”,buildSrc 模組是 Gradle 預設的外掛模組。buildSrc 模組本質上和普通的外掛模組是一樣的,有一些小區別:

  • 1、buildSrc 模組會被自動識別為參與構建的模組,因此不需要在 settings.gradle 中使用 include 引入,就算引入了也會編譯出錯:
Build OutPut:
'buildSrc' cannot be used as a project name as it is a reserved name
  • 2、buildSrc 模組會自動被新增到構建指令碼的 classpath 中,不需要手動新增:
buildscript {
    ...
    dependencies {
        // 不需要手動新增
        // classpath project(":buildSrc")
    }
}
  • 3、buildSrc 模組的 build.gradle 執行時機早於其他 Project:
Executing tasks: [test] 

settings.gradle:This is executed during the initialization phase.

> Configure project :buildSrc
build.gradle:buildSrc.

> Task :buildSrc:compileJava NO-SOURCE
> Task :buildSrc:compileGroovy NO-SOURCE
> Task :buildSrc:pluginDescriptors UP-TO-DATE
> Task :buildSrc:processResources NO-SOURCE
> Task :buildSrc:classes UP-TO-DATE
> Task :buildSrc:jar UP-TO-DATE
> Task :buildSrc:assemble UP-TO-DATE
> Task :buildSrc:pluginUnderTestMetadata UP-TO-DATE
> Task :buildSrc:compileTestJava NO-SOURCE
> Task :buildSrc:compileTestGroovy NO-SOURCE
> Task :buildSrc:processTestResources NO-SOURCE
> Task :buildSrc:testClasses UP-TO-DATE
> Task :buildSrc:test NO-SOURCE
> Task :buildSrc:validatePlugins UP-TO-DATE
> Task :buildSrc:check UP-TO-DATE
> Task :buildSrc:build UP-TO-DATE
...
> Configure project :
...
> Task :test
...
BUILD SUCCESSFUL in 19s


2. 自定義 Gradle 外掛的步驟

這一節我們來講實現 Gradle 外掛的具體步驟,基本步驟分為 5 步:

  • 1、初始化外掛目錄結構
  • 2、建立外掛實現類
  • 3、配置外掛實現類
  • 4、釋出外掛
  • 5、使用外掛

2.1 初始化外掛目錄結構

首先,我們在 Android Studio 新建一個 Java or Kotlin Library 模組,這裡以非 buildSrc 模組的情況為例:

然後,將模組 build.gradle 檔案替換為以下內容:

模組 build.gradle

plugins {
    id 'groovy' // Groovy Language
		id 'org.jetbrains.kotlin.jvm' // Kotlin 
    id 'java-gradle-plugin' // Java Gradle Plugin
}
  • groovy 外掛: 使用 Groovy 語言開發必備;
  • org.jetbrains.kotlin.jvm 外掛: 使用 Kotlin 語言開發必備;
  • java-gradle-plugin 外掛: 用於幫助開發 Gradle 外掛,會自動應用 Java Library 外掛,並在 dependencies 中新增 implementation gradleApi()

最後,根據你需要的開發語言補充對應的原始碼資料夾,不同語言有預設的原始碼資料夾,你也可以在 build.gradle 檔案中重新指定:

模組 build.gradle

plugins {
    id 'groovy' // Groovy Language
    id 'org.jetbrains.kotlin.jvm' // Kotlin 
    id 'java-gradle-plugin' // Java Gradle Plugin
}

sourceSets {
    main {
        groovy {
            srcDir 'src/main/groovy'
        }

        java {
            srcDir 'src/main/java'
        }

        resources {
            srcDir 'src/main/resources'
        }
    }
}

外掛目錄結構:

2.2 建立外掛實現類

新建一個 Plugin 實現類,並重寫 apply 方法中新增構建邏輯,例如:

com.pengxr.easyupload.EasyUpload.groovy

class EasyUpload implements Plugin<Project> {

    @Override
    void apply(Project project) {
        // 構建邏輯
        println "Hello."
    }
}

2.3 配置外掛實現類

在模組 build.gradle 檔案中增加以下配置,gradlePlugin 定義了外掛 ID 和外掛實現類的對映關係:

gradlePlugin {
    plugins {
        modularPlugin {
            // Plugin id.
            id = 'com.pengxr.easyupload'
            // Plugin implementation.
            implementationClass = 'com.pengxr.easyupload.EasyUpload'
        }
    }
}

這其實是 Java Gradle Plugin 提供的一個簡化 API,其背後會自動幫我們建立一個 [外掛ID].properties 配置檔案,Gradle 就是通過這個檔案類進行匹配的。如果你不使用 gradlePlugin API,直接手動建立 [外掛ID].properties 檔案,作用是完全一樣的。

要點:

  • 1、[外掛ID].properties 檔名是外掛 ID,用於應用外掛
  • 2、[外掛ID].properties 檔案內容配置了外掛實現類的對映,需要使用implementation-class來指定外掛實習類的全限定類名
implementation-class=com.pengxr.easyupload.EasyUpload

2.4 釋出外掛

我們使用 maven 外掛 來發布倉庫,在模組 build.gradle 檔案中增加配置:

模組 build.gradle

plugins {
    id 'groovy' // Groovy Language
		id 'org.jetbrains.kotlin.jvm' // Kotlin 
    id 'java-gradle-plugin' // Java Gradle Plugin
}

gradlePlugin {
    plugins {
        modularPlugin {
            // Plugin id.
            id = 'com.pengxr.easyupload'
            // Plugin implementation.
            implementationClass = 'com.pengxr.easyupload.EasyUpload'
        }
    }
}

uploadArchives {
    repositories {
        mavenDeployer {
            repository(url: uri('../localMavenRepository/snapshot'))
            pom.groupId = 'com.pengxr'
            pom.artifactId = 'easyupload'
            pom.version = '1.0.0'
        }
    }
}

執行 uploadArchives 任務,會發布外掛到專案根目錄中的 localMavenRepository 資料夾,實際專案中通常是釋出到 Nexus 私庫或 Github 公共庫等。不熟悉元件釋出的話可以回顧:Android工程化實踐:元件化釋出,此處不展開。

2.5 使用外掛

在專案級 build.gradle 檔案中將外掛新增到 classpath:

專案 build.gradle

buildscript {
    repositories {
        google()
        jcenter()
        maven { url "$rootDir/localMavenRepository/snapshot" }
        maven { url "$rootDir/localMavenRepository/release" }
    }
    dependencies {
        // For debug
        // classpath project(":easyupload")
        classpath "com.pengxr:easyupload:1.0.0"
    }
    ...
}

在模組級 build.gradle 檔案中 apply 外掛:

模組 build.gradle

// '專案 build.gradle' 是在 gradlePlugin 中定義的外掛 ID
apply plugin: 'com.pengxr.easyupload'

完成以上步驟並同步專案,從 Build Output 可以看到我們的外掛生效了:

Build Output:

Hello.

到這裡,自定義 Gradle 外掛最基本的步驟就完成了,接下來就可以在 Plugin#apply 方法中開始你的表演。


3. 外掛擴充套件機制

Extension 擴充套件是外掛為外部構建指令碼提供的配置項,用於支援外部自定義外掛的工作方式,其實就是一個對外開放的 Java Bean 或 Groovy Bean。例如,我們熟悉的 android{} 就是 Android Gradle Plugin 提供的擴充套件。

當你應用一個外掛時,外掛定義的擴充套件會以 副檔名-擴充套件物件 鍵值對的形式儲存在 Project 中的 ExtensionContainer 容器中。外掛內外部也是通過 ExtensionContainer 訪問擴充套件物件的。

注意事項:

  • 副檔名: 不支援在同一個 Project 上新增重複的副檔名;
  • 對映關係: 新增擴充套件後,不支援重新設定擴充套件物件;
  • DSL: 支援用 副檔名 {} DSL 的形式訪問擴充套件物件。

3.1 基本步驟

這一節我們來講實現 Extension 擴充套件的具體步驟,基本步驟分為 5 步:

  • 1、定義擴充套件類: 定義一個擴充套件配置類:

Upload.groovy

class Upload {
    String name
}

提示: 根據 ”約定優先於配置“ 原則,儘量為配置提供預設值,或者保證配置預設時也能正常執行。

  • 2、建立並新增擴充套件物件: 在 Plugin#apply() 中,將擴充套件物件新增到 Project 的 ExtensionContainer 容器中:

EasyUpload.groovy

class EasyUpload implements Plugin<Project> {

    // 副檔名
    public static final String UPLOAD_EXTENSION_NAME = "upload"

    @Override
    void apply(Project project) {
        // 新增擴充套件
        applyExtension(project)
        // 新增 Maven 釋出能力
        applyMavenFeature(project)
    }

    private void applyExtension(Project project) {
        // 建立擴充套件,並新增到 ExtensionContainer
        project.extensions.create(UPLOAD_EXTENSION_NAME, Upload)
    }

    private void applyMavenFeature(Project project) {
        // 構建邏輯
    }
}
  • 3、配置擴充套件: 使用方應用外掛後,使用 副檔名 {} DSL定製外掛行為:

build.gradle

apply plugin: 'com.pengxr.easyupload'

upload {
    name = "Peng"
}
  • 4、使用擴充套件: 在 Plugin#apply() 中,通過 Project 的 ExtensionContainer 容器獲取擴充套件物件,獲取的程式碼建議封裝在擴充套件物件內部。例如:
class EasyUpload implements Plugin<Project> {

    // 副檔名
    public static final String UPLOAD_EXTENSION_NAME = "upload"

    @Override
    void apply(Project project) {
        // 新增擴充套件
        applyExtension(project)
        // 新增 Maven 釋出能力
        applyMavenFeature(project)
    }

    private void applyExtension(Project project) {
        // 建立擴充套件,並新增到 ExtensionContainer 容器
        project.extensions.create(UPLOAD_EXTENSION_NAME, Upload)
    }

    private void applyMavenFeature(Project project) {
        project.afterEvaluate {
            // 1. Upload extension
            Upload rootConfig = Upload.getConfig(project.rootProject)
            // 構建邏輯 ...
        }
    }
}

Upload.groovy

class Upload {

    String name

    // 將獲取擴充套件物件的程式碼封裝為靜態方法
    static Upload getConfig(Project project) {
        // 從 ExtensionContainer 容器獲取擴充套件物件
        Upload extension = project.getExtensions().findByType(Upload.class)
        // 配置預設的時候,賦予預設值
        if (null == extension) {
            extension = new Upload()
        }
        return extension
    }

    /**
     * 檢查擴充套件配置是否有效
     *
     * @return true:valid
     */
    boolean checkParams() {
        return true
    }
}

提示: ExtensionContainer#create() 支援變長引數,支援呼叫擴充套件類帶引數的建構函式,例如:project.extensions.create(UPLOAD_EXTENSION_NAME, Upload,"Name") 將呼叫建構函式 Upload(String str)

  • 5、構建邏輯: 到這裡,實現外掛擴充套件最基本的步驟就完成了,接下來就可以在 Plugin#apply 方法中繼續完成你的表演。

3.2 project.afterEvaluate 的作用

使用外掛擴充套件一定會用到 project.afterEvaluate() 生命週期監聽,這裡解釋一下:因為擴充套件配置程式碼的執行時機晚於 Plugin#apply() 的執行時機,所以如果不使用 project.afterEvaluate(),則在外掛內部將無法正確獲取配置值。

project.afterEvaluate() 會在當前 Project 配置完成後回撥,這個時機擴充套件配置程式碼已經執行,在外掛內部就可以正確獲取配置值。

apply plugin: 'com.pengxr.easyupload'

// 執行時機晚於 apply
upload {
    name = "Peng"
}

3.3 巢狀擴充套件

在擴充套件類中組合另一個配置類的情況,我們稱為巢狀擴充套件,例如我們熟悉的 defaultConfig{} 就是一個巢狀擴充套件:

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.0"
    defaultConfig {
        minSdkVersion 21
        ...
    }
    ...
}

預設下巢狀擴充套件是不支援使用閉包配置,我們需要在外部擴充套件類中定義閉包函式。例如:

Upload.groovy

class Upload {

    // 巢狀擴充套件
    Maven maven

    // 巢狀擴充套件
    Pom pom

    // 巢狀擴充套件閉包函式,方法名為 maven(方法名不一定需要與屬性名一致)
    void maven(Action<Maven> action) {
        action.execute(maven)
    }

    // 巢狀擴充套件閉包函式,方法名為 maven
    void maven(Closure closure) {
        ConfigureUtil.configure(closure, maven)
    }

    // 巢狀擴充套件閉包函式,方法名為 pom
    void pom(Action<Pom> action) {
        action.execute(pom)
    }

    // 巢狀擴充套件閉包函式,方法名為 pom
    void pom(Closure closure) {
        ConfigureUtil.configure(closure, pom)
    }
}

使用時:

build.gradle

apply plugin: 'com.pengxr.easyupload'

upload {
    maven {
        ...
    }
}

3.4 NamedDomainObjectContainer 命名 DSL

在 Android 工程中,你一定在 build.gradle 檔案中見過以下配置:

build.gradle

android {
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
        debug {
            ...
        }
        // 支援任意命名
        preview {
            ...
        }
    }
}

除了內建的 release 和 debug,我們可以在 buildType 中定義任意多個且任意名稱的型別,這個是如果實現的呢?—— 這背後是因為 buildTypes 是 NamedDomainObjectContainer 型別,原始碼體現:

com.android.build.api.dsl.CommonExtension.kt

val buildTypes: NamedDomainObjectContainer<BuildType>

NamedDomainObjectContainer 的作用:

NamedDomainObjectContainer 直譯是命名領域物件容器,是一個支援配置不固定數量配置的容器。主要功能分為 3 點:

  • Set 容器: 支援新增多個 T 型別物件,並且不允許命名重複;
  • 命名 DSL: 支援以 DSL 的方式配置 T 型別物件,這也要求 T 型別必須帶有 String name 屬性,且必須帶有以 String name 為引數的 public 建構函式;
  • SortSet 容器: 容器將保證元素以 name 自然順序排序。

那麼,以上配置相當於以下虛擬碼:

build.gradle

val buildTypes : Collections<BuildType>

BuildType release = BuildType("release")
release.minifyEnabled = false
release.proguardFiles = getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'

BuildType debug = BuildType("debug")
...

BuildType preview = BuildType("preview")
...

buildTypes.add(release)
buildTypes.add(debug)
buildType.add(preview)

NamedDomainObjectContainer 的用法:

這裡介紹一下具體用法,我們僅以你熟悉的 BuildType 為例,但不等於以下為原始碼。

  • 1、定義型別 T: 在型別 T 中必須帶有以 String name 為引數的 public 建構函式。例如:

BuildType.groovy

class BuildType {
    // 必須有 String name 屬性,且不允許構造後修改
    @Nonnull
    public final String name

    // 業務引數
    boolean minifyEnabled

    BuildType(String name) {
        this.name = name
    }
}
  • 2、定義 NamedDomainObjectContainer 屬性: 在擴充套件類中定義一個 NamedDomainObjectContainer 型別屬性。例如:

CommonExtension.grooyv

class CommonExtension {

    NamedDomainObjectContainer<BuildType> buildTypes

    CommonExtension(Project project) {
        // 通過 project.container(...) 方法建立 NamedDomainObjectContainer
        NamedDomainObjectContainer<BuildType> buildTypeObjs = project.container(BuildType)
        buildTypes = buildTypeObjs
    }

    // 巢狀擴充套件閉包函式,方法名為 buildTypes
    void buildTypes(Action<NamedDomainObjectContainer<BuildType>> action) {
        action.execute(buildTypes)
    }

    void buildTypes(Closure closure) {
        ConfigureUtil.configure(closure, buildTypes)
    }
}
  • 3、建立 Extension: 按照 4.1 節介紹的步驟建立擴充套件。例如:
project.extensions.create("android", CommonExtension)

到這裡,就可以按照 buildTypes {} 的方式配置 BuildType 列表了。然而,你會發現每個配置項必須使用 = 進行賦值。這就有點膈應人了,有懂的大佬指導一下。

android {
    buildTypes {
        release {
            // 怎樣才能省略 = 號呢?
            minifyEnabled = false
            proguardFiles = getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

4. 外掛除錯

4.1 兩個除錯方法

在開發外掛的過程一定需要除錯,除了通過日誌除錯,我們也有斷點除錯的需求。這裡總結兩個方法:方法 1 雖然只支援除錯簡單執行任務,但已經能滿足大部分需求,而且相對簡單。而方法 2 支援命令列新增引數。

方法 1(簡單): 直接提供 Android Studio 中 Gradle 皮膚的除錯功能,即可除錯外掛。如下圖,我們選擇與外掛功能相關的 Task,並右鍵選擇 Debug 執行。

方法 2: 通過配置 IDE Configuration 以支援除錯命令列任務,具體步驟:

  • 1、建立 Remote 型別 Configuration:

  • 2、執行命令: ./gradlew Task -Dorg.gradle.debug=true --no-daemon (開啟 Debug & 不使用守護程式),執行後命令列會進入等待狀態:

  • 3、Attach Debug: 點選除錯按鈕,即可開始斷點除錯。

4.2 除錯技巧

一些除錯技巧:

  • 引用外掛原始碼: 在開發階段可以直接本地依賴外掛原始碼,而不需要將外掛釋出到 Maven 倉庫,只需要在 build.gradle 檔案中修改配置:

專案 build.gradle

buildscript {
    repositories {
        google()
        jcenter()
    }
    dependencies {
        // For Debug
        classpath project(":easyupload")
        // classpath "com.pengxr:easyupload:1.0.0"
    }
    ...
}
  • 外掛程式碼開關: 由於 Plugin#apply 中的程式碼在配置階段執行,如果其中的程式碼有問題就會出現 Sync 報錯。又因為編譯外掛程式碼需要先 Sync,只能先將工程中所有使用外掛的程式碼註釋掉,重新編譯外掛模組,再將註釋修改回來。真麻煩!我們還是加一個開關吧,例如:

gradle.properties

ENABLED=true

模組 build.gradle

if (ENABLED.toBoolean()) {
    apply plugin: 'com.pengxr.easyupload'
    upload {
        name = "123"
    }
}

5. 外掛開發技巧總結

  • 判斷是否當前是 App 模組還是 Library 模組: 當我們開發 Android 專案相關外掛時,經常需要根據外掛的使用環境區分不同邏輯。例如外掛應用在 App 模組和 Library 模組會採用不同邏輯。此時,我們可以用在 Plugin#apply() 中採用以下判斷:
project.afterEvaluate {
    // 1. Check if apply the ‘com.android.application’ plugin
    if (!project.getPluginManager().hasPlugin("com.android.application")) {
        return
    }
}
  • 外掛開發語言: 最初,Groovy 是 Gradle 的首要語言,但隨著 Java 和 Kotlin 語言的演進,這一現狀有所改變。現在的趨勢是:Gradle 指令碼使用 Groovy 或 Kotlin 開發,而 Gradle 外掛使用 Kotlin 開發。例如,我們可以發現 AGP 現在已經用 Kotlin 開發了。雖然趨勢是往 Kotlin 靠,但目前存量的 Gradle 指令碼 / 外掛還是以 Groovy 為主。
    • Groovy 優勢:社群沉澱、動態語言
    • Kotlin 優勢:IDE 支援、趨勢

原文: In general, a plugin implemented using Java or Kotlin, which are statically typed, will perform better than the same plugin implemented using Groovy.


6. 總結

到這裡,Gradle 外掛的部分就講完了,需要 Demo 的同學可以看下我們之前實現過的小外掛: EasyPrivacy。在本系列後續的文章中,也會有新的外掛 Demo。關注我,帶你瞭解更多,我們下次見。

參考資料

相關文章