寫給 Android 開發者的 Gradle 系列(二)撰寫 task

揪克發表於2018-05-14

歡迎關注本人公眾號,掃描下方二維碼或搜尋公眾號 id: mxszgg

寫給 Android 開發者的 Gradle 系列(二)撰寫 task

本文基於 Android Gradle plugin 3.0.1

前言

task 相當於開發者日常開發中所接觸到的函式、方法,它們是相同的一個概念。在前文寫給 Android 開發者的 Gradle 系列(一)基本姿勢已經提到過 task 的概念,例如 transformClassesAndResourcesWithProguardForRelease task 是為了混淆 release 包中原始碼的。

接下來就開始實操,首先在 app/build.gradle 中新增如下依賴:

compileOnly 'com.android.tools.build:gradle:3.0.1'

即可在 External Libraries 中看到關於 Gradle plugin 的原始碼。

這裡寫圖片描述

Gradle 原始碼如何引入將會在下一節中介紹。

task 撰寫

task 宣告

根據官方文件Task#create() 可以知道,task 的基本寫法可以是如下四種:

task myTask
task myTask { configure closure }
task (myTask) { configure closure }
task (name: myTask) { configure closure }
複製程式碼

每一個 task 都有自己的名字,這樣開發者才能呼叫它,例如呼叫上面的 task:

./gradlew myTask

但是有一個問題,倘若當前專案的 app module 和 a module 都含有一個名為 myTask 的 task,那麼會不會起衝突,該如何呼叫它們?答案是不會衝突,呼叫方式如下:

./gradlew app:myTask(呼叫 app module 的 myTask)

./gradlew a:myTask(呼叫 a module 的 myTask)

通過 ProjectName:taskName 的形式便可以指定唯一絕對路徑去呼叫指定 Project 的指定 task 了。

擴充套件

根據 Task#create() 可以知道,task 的建立是可以宣告引數的,除了上述的 name 引數之外,還有如下幾種:

  • type:預設為 DefaultTask。類似於父類。在後文中將會提及該引數。

  • dependsOn:預設為[]。希望依賴的 tasks,等同於 Task.dependsOn(Object... path) 中的 path。在後文中將會提及該引數。

  • action:預設為 null。等同於 Task.doFirst { Action } 中的 Action。

    task (name: actionTest, action: new Action<Task>() {
      @Override
      void execute(Task task) {
        println 'hello'
      }
    }) {
    }
    複製程式碼

    等同於

    task (name: actionTest) {
    	doFirst {
    		println 'hello'
    	}
    }
    複製程式碼
  • override:預設為 false。是否替換已存在的 task。

  • group:預設為 null。task 的分組型別。

  • description:預設為 null。task 描述。

  • constructorArgs:預設為 null。傳給 task 建構函式的引數。

後面四種大部分開發過程中應該不怎麼會用到,有需要的讀者自行查閱文件。

task 內容格式

  1. 根據官方文件以及前一篇文章中可以知道,如果想給 task 新增操作,可以新增在 doLast {}/doFirst {} 等閉包中,例如:

    task myTask {
    	doFirst {
    		println 'myTask 最先執行的內容'
    	}
    	doLast {
    		println 'myTask 最後執行的內容'
    	}
    	// warning
    	// println 'Configuration 階段和 Execution 階段皆會執行'
    }
    複製程式碼

切記大部分的內容是寫在 doLast{}doFirst{} 閉包中,因為寫在如果寫在 task 閉包中的話,會在 Configuration 階段也被執行。

  1. 根據官方文件可知,為了提高 task 複用性,Gradle 還支援 Task 類的書寫——

2.1 將下述程式碼寫在 build.gradle 中,並用 @TaskAction 標記想要執行的方法。

class GreetingTask extends DefaultTask {
	String greeting = 'hello from GreetingTask'
	
	@TaskAction
    def greet() {
        println greeting
    }
}
複製程式碼

2.2 在 build.gradle 中撰寫 task 呼叫 GreetingTask 類:

// Use the default greeting
task (name: hello , type: GreetingTask)

// Customize the greeting
task (name: greeting , type: GreetingTask) {
	greeting = 'greetings from GreetingTask'
}
複製程式碼

2.3 呼叫該 task——

./gradlew hello

> Task :app:hello
hello from GreetingTask

./gradlew greeting

> Task :app:greeting
greetings from GreetingTask
複製程式碼

所以看到這裡應該不僅能夠理解 Task 類的書寫,並且應該能夠大致明白 type 這個引數的含義了。

不知道會不會和筆者一樣事兒逼的讀者此時會疑惑 @TaskAction 修飾的方法和 doLast {} 以及 doFirst {} 閉包的執行順序是怎樣的?

task (name: hello, type: GreetingTask) {
  doFirst {
    def list = getActions()
    for (int i = 0; i < list.size(); i++) {
      println list.get(i).displayName
    }
  }

  doLast {

  }
}
複製程式碼

首先宣告 doFirst {}doLast {} 閉包;然後戳進 DefaultTask 原始碼並追蹤到頂級父類 AbstractTask 中可以看到內部通過使用 actions 儲存所有執行的 Action,並通過 getAction() 暴露;actions 是 List 型別,內部的元素型別是 ContextAwareTaskAction,該介面又實現了 Describable,Describable 僅宣告瞭一個 getDisplayName() 方法,所以可以直接通過 displayName 獲取該 Action 的名稱。

理解以上三步即可完成上述 task 撰寫,在命令列中試試——

./gradlew hello

> Task :app:hello
Execute doFirst {} action
Execute greet
Execute doLast {} action
複製程式碼

Gradle 內部將會自動為變數設定 setter、getter 方法,所以當一個 Gradle 有 getXxx() 方法時,可以直接使用 xxx 變數。如果不清楚這個細節,建議回顧上一篇文章的附錄。

task 依賴關係

開發者常使用 dependsOn 來指定依賴關係(另外兩種是指定 task 執行順序,詳見文件 Task Dependencies and Task Ordering),如下:

task a {
	doLast {
		println 'a'
	}
}

task b {
	dependsOn('a')
	doFirst {
		println 'b'
	}
}
複製程式碼

不妨將以上程式碼寫在 app build.gradle 檔案下,當執行 task b 的時候,會輸出如下資訊:

./gradlew task app:b

> Task :app:a

a

> Task :app:b

b

可以看到,由於 task b 需要依賴 task a,所以 task b 執行的時候會先執行 task a。

有經驗的開發者如果在命令列中試過 assembleDebug 等命令會發現,它們的執行將會依賴於許多其他 task。所以不妨在命令列中試試 ./gradlew assembleDebug 觀察輸出結果。

task 實戰

install && launch apk

com.android.application 自帶 installDebug task,開發者可以使用 installDebug 安裝當前專案 apk:

./gradlew installDebug

> Task :app:installDebug

Installing APK 'app-debug.apk' on 'xxxxx' for app:debug Installed on 1 device.

但是似乎看起來有些不盡人意的地方,例如開發者希望安裝的時候能夠順帶能夠啟動該 app。那麼該如何做呢?

首先從問題的可行性上來進行分析,開發者的直覺告訴我們是可以通過 gradle 實現的——命令列可以安裝、啟動 apk——adb install -r app-debug.apkadb shell am start -n 包名/首 Activity。所以關鍵點就是如何通過 gradle 呼叫命令列程式碼以及如何獲取到 包名/首 Activity 資訊。

  1. 開發者的直覺同樣告訴我們 Gradle 開發文件中有關於命令列呼叫的資訊,只需要使用 exec {} 閉包就好了。

  2. 如何獲取 包名/首 Activity 資訊?可以通過 AndroidManifest.xml 來獲取。部分經驗豐富的開發者知道——打入 apk 中的 AndroidManifest.xml 檔案並不是我們平常寫的 AndroidManifest.xml,而是 apk 編譯後位於 Project/app/build/intermediates/manifests/full/debug/ 包下的 AndroidManifest.xml(當然,如果是 Release 包的話,應該是 Project/app/build/intermediates/manifests/full/release/ 包下)。

    • 包名就是 android 閉包下的 defaultConfig 閉包下的 applicationId

      這裡寫圖片描述

    • 目標 Activity 則是包含 action 為 android.intent.action.MAIN 的 Activity。

      這裡寫圖片描述

理解了以上內容,便不難理解下面的內容:

task installAndRun(dependsOn: 'assembleDebug') {
  doFirst {
    exec {
      workingDir "${buildDir}/outputs/apk/debug"
      commandLine 'adb', 'install', '-r', 'app-debug.apk'
    }
    exec {
      def path = "${buildDir}/intermediates/manifests/full/debug/AndroidManifest.xml"
      // xml 解析
      def parser = new XmlParser(false, false).parse(new File(path))
      // application 下的每一個 activity 結點
      parser.application.activity.each { activity ->
        // activity 下的每一個 intent-filter 結點
        activity.'intent-filter'.each { filter ->
          // intent-filter 下的 action 結點中的 @android:name 包含 android.intent.action.MAIN
          if (filter.action.@"android:name".contains("android.intent.action.MAIN")) {
            def targetActivity = activity.@"android:name"
            commandLine 'adb', 'shell', 'am', 'start', '-n',
                "${android.defaultConfig.applicationId}/${targetActivity}"
          }
        }
      }
    }
  }
}
複製程式碼
  1. install apk 的前提必須是得有一個 apk,所以勢必需要依賴 assembleDebug task。

    實際上 installDebug task 也是依賴 assembleDebug task 的,不妨可以試試——

    task showInstallDepends {
      doFirst {
        println project.tasks.findByName("installDebug").dependsOn
      }
    }
    複製程式碼

    ./gradlew showInstallDepends

    > Configure project :app

    [task 'installDebug' input files, assembleDebug]

  2. exec 閉包中的幾個引數提及下——

    2.1 workingDir:工作環境,引數為 File 格式。預設為當前 project 目錄。

    2.2 commandLine:需要命令列執行的命令,引數為 List 格式。

  3. 前一篇文章中提到 ——

    說白了它們其實就是一些閉包、一些固定格式,正是因為它們的格式是固定的,task 才能夠讀取到相應的資料完成相應的事情。

    在第二個 exec 閉包的第八行就很好的體現了這一點,通過 {android.defaultConfig.applicationId} 直接獲取到 Gradle 檔案中 android 閉包下的 defaultConfig 閉包下的 applicationId 的值。由此就獲得了當前應用的包名。

    當然,除了 Gradle 能夠呼叫命令列以外,實際上 groovy 也是可以呼叫命令列的,但在此就不做擴充套件了。

  4. 至於最先啟動的 Activity,肯定是 action 為 android.intent.action.MAIN 的 Activity,那麼問題就是變成如何在 AndroidManifest.xml 中尋找到該 Activity 的事了——作為一個合格的老司機,應該能夠想到 groovy 一定會提供相應的 xml 解析 API 的,至於具體的使用筆者就不在此擴充套件了,留給各位讀者去原始碼中探索成長。

  5. 除去上面的資訊以外,還需要什麼?還需要知道一些 gradle 構建的資訊——例如 debug 包會最終出現在 ${buildDir}/outputs/apk/debug;例如 debug 包中的 AndroidManifest.xml 並不是日常開發中寫的那個 AndroidManifest.xml(雖然可能它倆基本沒什麼差異),而是 ${buildDir}/intermediates/manifests/full/debug 下的 AndroidManifest.xml。所以一是希望各位讀者日常多去翻翻 build 資料夾,二是要知道 ${buildDir}(build 資料夾)有多麼重要,因為 Gradle 構建 apk 的過程中,但凡有輸出檔案那麼基本都會存在這個資料夾中,所以多去翻一翻。

由此之後,可以在命令列輸入以下命令:

./gradlew installAndRun

> Task :app:installAndRun

[ 4%] /data/local/tmp/app-debug.apk

[ 8%] /data/local/tmp/app-debug.apk

[ 12%] /data/local/tmp/app-debug.apk ...

Success

Starting: Intent {com.test.Test/TestActivity}

至此便完成了一個安裝並啟動 apk 的 task 撰寫了。

hook assets

上面的 task 看起來似乎和 Android 的構建過程並無多大關係,沒錯,那麼接下來不妨深層次接觸試試——通過 hook 原生 task 實現更改打包中的檔案——在打包過程中向 assets 插入一張圖片。

儘管這看起來絲毫沒鳥用

在打包流程中,有一個 task 名為 packageDebug,該 task 是打包檔案生成 apk 的——

這裡寫圖片描述

接著,不妨在命令列鍵入以下命令:

./gradlew help --task "packageDebug"

Type

PackageApplication (com.android.build.gradle.tasks.PackageApplication)

可以看到,該 task 的 type 是 PackageApplication——

這裡寫圖片描述

不妨再看看它的父類 PackageAndroidArtifact

這裡寫圖片描述

看到關鍵資訊,該 task 中有一個型別為 FileCollection assets 欄位,這便是最終打入 apk 中的那個 assets 了。所以不難寫出以下程式碼——

task hookAssets {
  afterEvaluate {
    tasks.findByName("packageDebug").doFirst { task ->
      copy {
        from "${projectDir.absolutePath}/test.png"
        into "${task.assets.asPath}"
      }
    }
  }
}
複製程式碼
  1. 在 project afterEvaluate 之後找到 packageDebug task
  2. 不妨在 app 目錄下放入一個 test.png,使用 copy {} 閉包,from 填入的引數為 test.png 的路徑,into 填入的引數為輸出的路徑,也就是 assets 的路徑。

可以看到 /app/build/intermediates/assets/debug/ 下存有 test.png

這裡寫圖片描述

同樣地,解壓 apk 檔案也可以看到——

這裡寫圖片描述

一個實打實的 Gradle task hook 流程就這麼操作完了。

後記

如果說 Gradle task 是函式的話,那麼 Gradle plugin 就是函式庫,在後一節筆者將會對 Gradle plugin 進行闡述。

當然,如果各位讀者有疑問的話,歡迎加入筆者的微信群。 如果二維碼失效,可以檢視筆者最新文章的尾部。

這裡寫圖片描述

相關文章