前言
本次做jacoco覆蓋率發現,主工程依賴的jar包都未產生覆蓋率數據,熟悉jar包方式組件化架構的同學應該明白主工程僅是空殼,若按照網上教程做則壓根覆蓋不到業務組件,必須解決,經研究發現,AS的Gradle build插件執行過程僅創建一個jacoco Task並對當前工程源碼的class執行離線模式字節碼插入,並不對工程依賴的jar包進行插樁,so必須自定義一個Gradle插件來干預編譯流程,經探索最初擬出兩種方案:
1.獨立腳本形式,通過監聽gradle task與Build執行週期完成自定義插樁操作,具體插樁行爲通過調用cmd命令的形式去做,腳本受限程度較高,依賴於gradle腳本的api
2.獨立構建插件形式,自定義Transfrom,編譯時開發者自定義的Transform將置於默認Transform之前,以此處爲錨點的自定義操作可以方便的插樁。
Gradle構建插件即apply plugin: 'com.android.application'
它的jar包可以查看源碼並仿寫,位置大概在.gradle\caches\modules-2\files-2.1\com.android.tools.build\com.android.tools.build
注:腳本是基於gradle開放api的,構建插件是基於android編譯插件的並非直接基於Gradle的Plugin,兩種並不完全相同,此處說構建其實就是安卓的編譯過程。
概念認識
一.jacoco兩種模式
a.在線模式,即在虛擬機類加載時進行插樁,運行結束後本地不保存插樁字節碼
b.離線模式,即直接對.class文件進行流讀取並將插樁字節碼保存在.class文件中,屬於侵入式
注:android獨有的運行環境決定了離線模式是唯一的模式,因爲它最後是odex字節碼,jacoco本身不支持dex字節碼的插樁,就算是app有熱修復那種可以掃描jar包並自定義類加載有錨點做處理,也不能總在每次打開app上做處理,手機纔多少資源
二.Gradle中的Task
Gradle有兩個重要概念,Project與Task,1個moudle工程=1個Project,1個Project含有n個Task,Project是由settings.gradle中的include解析生成的.這裏的Project跟eclipse的Project層級倒是相同,android插件的若干Task中組成了我們平時的編譯過程
Task :app:generateNormalDebugBuildConfig
Task :app:prepareLintJar
Task :app:prepareLintJarForPublish
Task :app:compileNormalDebugAidl
Task :app:compileNormalDebugRenderscript
Task :app:generateNormalDebugSources
Task :app:dataBindingExportBuildInfoNormalDebug
Task :app:dataBindingMergeDependencyArtifactsNormalDebug
Task :app:dataBindingMergeGenClassesNormalDebug
Task :app:generateNormalDebugResValues
Task :app:generateNormalDebugResources
Task :app:mergeNormalDebugResources
Task :app:mainApkListPersistenceNormalDebug
Task :app:createNormalDebugCompatibleScreenManifests
Task :app:processNormalDebugManifest
Task :app:dataBindingGenBaseClassesNormalDebug
Task :app:dataBindingExportFeaturePackageIdsNormalDebug
Task :app:processNormalDebugResources
Task :app:compileNormalDebugKotlin
Task :app:javaPreCompileNormalDebug
Task :app:compileNormalDebugJavaWithJavac
Task :app:compileNormalDebugSources
Task :app:checkNormalDebugDuplicateClasses
Task :app:desugarNormalDebugFileDependencies
Task :app:mergeNormalDebugShaders
Task :app:compileNormalDebugShaders
Task :app:generateNormalDebugAssets
Task :app:mergeNormalDebugAssets
Task :app:processNormalDebugJavaRes
Task :app:validateSigningNormalDebug
Task :app:signingConfigWriterNormalDebug
Task :app:mergeNormalDebugJniLibFolders
Task :app:jacocoNormalDebug
Task :app:mergeNormalDebugJavaResource
Task :app:mergeNormalDebugNativeLibs
Task :app:stripNormalDebugDebugSymbols
Task :app:stripNormalDebugDebugSymbols
Task :app:multiDexListNormalDebug
Task :app:transformClassesWithDexBuilderForNormalDebug
Task :app:mergeDexNormalDebug
Task :app:packageNormalDebug
Task :app:assembleNormalDebug
Task :app:assembleDebug
三.Task本身不參與編譯流程(此段描述可能有誤)
我認爲Task本身就算一個mini任務,功能單一,其是獨立的,一般我們寫完就直接在腳本文件的左側用綠三角按鈕執行,如下圖:
那麼編譯流程是如何串起來這些Task的,除了跟gradle本身相關外,其實還跟
apply plugin: 'com.android.application'
這個插件有關,這個插件是Android的官方編譯插件,看其源碼可以學到很多自定義插件的知識,路徑大致在.gradle\caches\modules-2\files-2.1\com.android.tools.build\com.android.tools.build
很多Task是這個插件動態創建的,包括build.gradle文件中的配置,即我們常見的如下配置
android {
compileSdkVersion 30
buildToolsVersion "30.0.2"
defaultConfig {
applicationId "com.biabia.testjacoco"
...
}
buildTypes {...
}
}
,對於插件來說這些配置就算一個java類AppExtention extents BaseExtention
或者說是接口AndroidConfig
,所以本次我們想要將Task與編譯流程綁定上就得嘗試gradle腳本開放的方法(.gradle\wrapper\dists\gradle-5.4.1-all\3221gyojl5jsh0helicew7rwx\gradle-5.4.1\src\core\org\gradle\api
),譬如mustRunAfter、dependsOn,綁定到這些固定Task前或後面,本次未做這種干擾官方插件編譯Task順序的嘗試,結果未知。
四.編譯過程
兩張圖須知
方法一:自定義獨立腳本
在app的build文件加入 apply from: 'xxxx.gradle'
,即可引入獨立腳本
- 操作步驟
1.如下圖,調整至Project視圖,在/app
文件夾下創建一個JacocoLibProbe.gradle
文件
2.在JacocoLibProbe.gradle文件中添加apply plugin: 'com.android.application'
;(添加目的在於寫groovy代碼時可以有隻能提示,書寫發現在腳本中添加任意一個插件以引入gradle庫,寫groovy語言時纔會有智能提示,同時需注意在寫groovy時使用未導包的變量或者類在編譯時會報no signal...什麼錯誤需留意)
3.實現Task及Build的接口,如下:
apply plugin: 'com.android.application'
class BuildTaskTraceListener implements TaskExecutionListener, BuildListener{
private Gradle gradle
private Project project
BuildTaskTraceListener(Project project) {
this.project = project
}
@Override
void buildStarted(Gradle gradle) {
this.gradle = gradle
}
@Override
void settingsEvaluated(Settings settings) {
}
@Override
void projectsLoaded(Gradle gradle) {
}
@Override
void projectsEvaluated(Gradle gradle) {
}
@Override
void buildFinished(BuildResult result) {
}
@Override
void beforeExecute(Task task) {
}
@Override
void afterExecute(Task task, TaskState state) {
}
}
gradle.addListener(new BuildTaskTraceListener(project))
由上述代碼可看到涉及Task與Build的週期回調,可以在Task執行前與後做些配置,在transformClassesWithDexBuilderForDebug
前操作即class轉爲dex文件前在勾子函數中完成對所有jar包的插樁即可,從此處開始就開啓了一個麻煩的步驟:
1.收集工程所有在線、離線依賴的包
2.解壓.aar、解壓.jar包
3.調用jacoco的包對解壓的.class文件進行流讀取插樁並寫出保存至.class文件中
4.壓縮插樁後的解壓文件夾重新生成.aar或.jar包
經我研究發現所有的第三方包在task鏈執行前就已下載到位,所以不存在一邊轉碼一邊下載的情況,由於此次是自定義腳本,並未找到在腳本中引入第三方jar包進行插樁的方法,也未找到gradle版本的插樁庫(嘗試過在app build.gradle中添加implementation 'org.jacoco:org.jacoco.core:0.7.9'
,在獨立腳本文件頭部添加import org.jacoco.core.instr.*
皆無效)故將上述四個步驟用java寫成一個外部jar包,gradle腳本通過執行cmd命令調起jar包進行操作,但在後續研究中發現自定義Transform更加簡便。
方法二:自定義獨立插件
自定義獨立插件有兩種方式:
- 發佈到maven上,之後被工程在線引入
優點:在線引用對於當前工程來說代碼分割工程更有條理,引用也方便,最主要的是在線依賴能被多個工程同時使用。
缺點:以在線jar包方式依賴無法即改即用,還需要依賴外部發布平臺- 直接放在當前工程中,在編譯時工程會先編譯自定義插件之後再編譯主工程
優點:源碼放置在工程本地,可以隨時更改,其本質上就是未在settings.gradle配置的一個特殊android moudle工程,。
缺點:增加本地管理工作量,無法被多個工程引用
本次選用上述的第二種,不進行發佈,即改即用
-
操作步驟
1.切換至Project視圖
創建固定文件夾buildSrc
(官方定義名稱)等文件如下所示,其中紅框部分爲人工創建,buildSrc
文件夾下的.gradle
、build
、buildSrc.iml
構建後生成非人工創建,其本質就是一個moudle工程,但不同的是它不可出現在settings.gradle
文件中配置include,在創建main文件夾後,可以創建groovy、java、resources文件夾,此處使用的是groovy文件夾,因爲groovy文件支持寫java用它可以兼容兩種寫法,是最合適的
2.本次自定義的Transform及其處理類,如下
FilterUtil.groovy
package com.xxx.probe;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Enumeration;
import java.util.List;
import java.util.Properties;
import static com.sun.org.apache.xalan.internal.xsltc.compiler.util.Util.println;
public class FilterUtil {
/**
* 精準屏蔽不需要插樁的jar包
* @param jarName org.codehaus.groovy.runtime
* @return true:需要過濾,不插樁 flase:無需過濾,
* 需注意要插樁jar包名與使用過程中的依賴名可能不同
*/
public static boolean checkFilterJar(String jarName) throws IOException {
if(jarName.startsWith("SNF")||jarName.contains("_android-")){
return false
}else {
return true
}
}
/**
* 過濾具體插樁文件
* @param entryName 關鍵字
* @return true:需要過濾 flase:不需要過濾
*/
public static boolean checkFilterFile(String entryName) {
if (entryName.contains(".class")) {
//只對類進行判斷
if (entryName.contains("R.class")
|| entryName.contains("BuildConfig.class")
|| entryName.contains("R.\$")
|| entryName.contains("com/google/common/")
|| entryName.contains("javax/annotation/")
|| entryName.contains("org/apache/commons")
) {
return true
} else {
return false
}
} else {
System.out.println("非.class文件:" + entryName)
return true
}
}
}
JarAvatar.java文件
package com.xxx.probe;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.CRC32;
import java.util.zip.CheckedOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public class JarAvatar {
/**
* 解壓jar包
*
* @param destDir 解壓位置
* @param jarFilePath 需要解壓的jar
*/
public static void extractJar(String destDir, String jarFilePath, ProbeListener pl) throws IOException {
// String jarFile = "E:/test/com.ide.core_1.0.0.jar";
// System.out.println("解壓位置:"+destDir+"\n需解的jar:"+jarFilePath);
if ((jarFilePath == null) || (jarFilePath.equals("")) || !jarFilePath.endsWith(".jar")) {
return;
}
try {
JarFile jar = new JarFile(jarFilePath);
Enumeration<?> enumeration = jar.entries();
while (enumeration.hasMoreElements()) {
JarEntry jarEntry = (JarEntry) enumeration.nextElement();
String[] names = jarEntry.getName().split("/");
for (int i = 0; i < names.length; i++) {
String path = destDir;
for (int j = 0; j < (i + 1); j++) {
path += File.separator + names[j];
}
File file = new File(path);
// if (!file.exists()) {
if ((i == (names.length - 1)) && !jarEntry.getName().endsWith("/")) {
file.createNewFile();
} else {
file.mkdirs();
continue;
}
// } else {
// continue;
// }
InputStream is = jar.getInputStream(jarEntry);
FileOutputStream fos = new FileOutputStream(file);
if (pl != null) {
boolean writed = pl.probeFile(is, fos, jarEntry);
if (writed) {
fos.flush();
fos.close();
continue;
}
}
while (is.available() > 0) {
fos.write(is.read());
}
fos.flush();
fos.close();
}
}
if (pl != null) {
pl.unZipEnd(true, jar.getName());
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
if (pl != null) {
pl.unZipEnd(false, e.getMessage());
}
}
}
private static List<String> list = new ArrayList();
/**
* 壓縮jar包
*
* @param srcDir 需要壓縮的文件
* @param targetPath 壓縮的jar包,存放路徑
*/
@SuppressWarnings("resource")
public static void compressJar(String srcDir, String targetPath) {
FileOutputStream fileOutputStream = null;
CheckedOutputStream cs = null;
ZipOutputStream out = null;
InputStream ins = null;
try{
File file = new File(srcDir);
//判斷是否是目錄
if (file.isDirectory()) {
if (list.size() > 0)
list.clear();
byte b[] = new byte[128];
// 壓縮文件的保存路徑
System.out.println("開始壓縮... " + file.getName() + "\n壓縮文件保存至-> " + " " + targetPath);
String zipFile;
if (targetPath.endsWith(".jar")) {
zipFile = targetPath;
} else {
zipFile = targetPath + File.separator + file.getName() + ".jar";
System.out.println("壓縮文件:" + zipFile);
}
// 壓縮文件目錄
// String filepath = file.getAbsolutePath() + File.separator;
List<String> fileList = allFile(srcDir + File.separator);
fileOutputStream = new FileOutputStream(zipFile);
// 使用輸出流檢查
cs = new CheckedOutputStream(fileOutputStream, new CRC32());
// 聲明輸出zip流
out = new ZipOutputStream(new BufferedOutputStream(cs));
for (int i = 0; i < fileList.size(); i++) {
ins = new FileInputStream((String) fileList.get(i));
String fileName = ((String) (fileList.get(i))).replace(File.separatorChar, '/');
// System.out.println("ziping " + fileName);
String tmp = file.getName() + "/";
fileName = fileName.substring(fileName.lastIndexOf(tmp) + file.getName().length() + 1);
ZipEntry e = new ZipEntry(fileName);
out.putNextEntry(e);
int len = 0;
while ((len = ins.read(b)) != -1) {
out.write(b, 0, len);
}
out.closeEntry();
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (ins != null) {
ins.close();
}
if (out != null) {
out.close();
}
if (out != null) {
out.close();
}
if (cs != null) {
cs.close();
}
if (fileOutputStream != null) {
fileOutputStream.close();
}
list.clear();
System.gc();//一定加上這個,否則源文件刪除不了
} catch (Exception e2) {
// TODO: handle exception
}
}
}
/**
* jar壓縮
*
* @param parentDirPath 要壓縮文件夾的父文件夾
* @param targetPath 目標文件夾
*/
public static void zipDirectory(String parentDirPath, String targetPath) {
try {
File dirFile = new File(parentDirPath);
File[] listArr = dirFile.listFiles();
for (File childFile : listArr) {
if (childFile.isDirectory()) {
if (list.size() > 0)
list.clear();
byte b[] = new byte[128];
// 壓縮文件的保存路徑
String zipFile = targetPath + File.separator + childFile.getName() + ".jar";
// 壓縮文件目錄
String filepath = childFile.getAbsolutePath() + File.separator;
List fileList = allFile(filepath);
FileOutputStream fileOutputStream = new FileOutputStream(zipFile);
// 使用輸出流檢查
CheckedOutputStream cs = new CheckedOutputStream(fileOutputStream, new CRC32());
// 聲明輸出zip流
ZipOutputStream out = new ZipOutputStream(new BufferedOutputStream(cs));
for (int i = 0; i < fileList.size(); i++) {
InputStream in = new FileInputStream((String) fileList.get(i));
String fileName = ((String) (fileList.get(i))).replace(File.separatorChar, '/');
String tmp = childFile.getName() + "/";
fileName = fileName.substring(fileName.lastIndexOf(tmp) + childFile.getName().length() + 1);
ZipEntry e = new ZipEntry(fileName);
out.putNextEntry(e);
int len = 0;
while ((len = in.read(b)) != -1) {
out.write(b, 0, len);
}
out.closeEntry();
}
out.close();
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
private static List allFile(String path) {
File file = new File(path);
String[] array = null;
String sTemp = "";
if (file.isDirectory()) {
} else {
return null;
}
array = file.list();
if (array.length > 0) {
for (int i = 0; i < array.length; i++) {
sTemp = path + array[i];
file = new File(sTemp);
if (file.isDirectory()) {
allFile(sTemp + "/");
} else {
list.add(sTemp);
}
}
} else {
return null;
}
return list;
}
}
PrintUtil.java
package com.xxx.probe;
public class PrintUtil {
public static void print(String content){
System.out.println(content);
}
}
ProbeListener.java
package com.xxx.probe;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.jar.JarEntry;
public interface ProbeListener {
boolean probeFile(InputStream is, FileOutputStream fos, JarEntry jarEntry);
void unZipEnd(boolean result,String msg);
}
ProbePathUtil.groovy
package com.xxx.probe
import java.io.*
import java.lang.*
class ProbePathUtil {
//G:\TEST_SPACE_xxx\MakeGradlePlugin\app\libs\baksmali-2.1.3/
public static String getOutDirByFile(String jarFilePath){
jarFilePath = jarFilePath.replaceAll("\\\\", "/")
String outDir = jarFilePath.substring(0,jarFilePath.lastIndexOf("."))+"/"
File outDirF = new File(outDir)
if (!outDirF.exists())outDirF.mkdirs()
return outDir
}
public static String getOutDirByFileNoSuffix(String jarFilePath){
jarFilePath = jarFilePath.replaceAll("\\\\", "/")
String outDir = jarFilePath.substring(0,jarFilePath.lastIndexOf("."))
File outDirF = new File(outDir)
if (!outDirF.exists())outDirF.mkdirs()
return outDir
}
public static String getFileNameByPath(String path){
String specPath = path.replaceAll("\\\\", "/")
int pos = specPath.lastIndexOf("/")
return path.substring(pos+1,specPath.length())
}
public static String getNameByPath(String path){
String specPath = path.replaceAll("\\\\", "/")
int pos = specPath.lastIndexOf("/")
int lastPos = path.lastIndexOf(".")
return path.substring(pos+1,lastPos)
}
//通過此方法拿出的字符串是正確的但是groovy運行觀察發現通不過String.contains方法,不知爲何
@Deprecated
public static String getSpecPath(String path){
String specPath = path.replaceAll("\\.", "/")
return specPath
}
}
ProbePlugin.groovy
package com.xxx.probe
import com.android.build.gradle.AppExtension
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Action
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.jvm.tasks.Jar;
public class ProbePlugin implements Plugin<Project> {
@Override
void apply(Project project) {
Map<String,?> map = project.getProperties()
AppExtension appExtension = (AppExtension)map.get("android")
appExtension.registerTransform(new TestProbeTransform(project))
}
}
TestProbeTransform.groovy
import com.android.annotations.NonNull
import com.android.build.api.transform.DirectoryInput
import com.android.build.api.transform.Format
import com.android.build.api.transform.JarInput
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformInvocation
import com.android.build.api.transform.TransformOutputProvider
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.ide.common.internal.WaitableExecutor
import com.android.utils.FileUtils
import com.xxx.probe.FilterUtil
import com.xxx.probe.JarAvatar
import com.xxx.probe.ProbeListener
import com.xxx.probe.ProbePathUtil
import org.gradle.api.Project
import org.gradle.api.tasks.Input
import org.jacoco.core.instr.Instrumenter
import org.jacoco.core.runtime.OfflineInstrumentationAccessGenerator
import java.util.concurrent.Callable
import java.util.jar.JarEntry
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.atomic.AtomicInteger
class TestProbeTransform extends Transform {
Project project
TestProbeTransform(Project project) {
this.project = project
}
@Override
String getName() {
return "TestProbeTransform"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Input
@NonNull
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return false
}
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider()
transformInvocation.inputs.each { TransformInput input ->
input.jarInputs.each { JarInput jarInput ->
handleJar(jarInput, outputProvider)
}
input.directoryInputs.each { DirectoryInput dirInput ->
handleDir(dirInput, outputProvider)
}
}
super.transform(transformInvocation)
}
void handleJar(JarInput jarInput, TransformOutputProvider outputProvider) {
//輸出文件
File destJar = outputProvider.getContentLocation(jarInput.getName(), jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR)
//輸入文件
File srcJar = jarInput.file
if (!FilterUtil.checkFilterJar(srcJar.name)) {
println("【" + srcJar.name + "】插樁處理開始————————————————————————————————")
String unzipDir = ProbePathUtil.getOutDirByFileNoSuffix(srcJar.path)
clearUnZipCache(unzipDir)
String jarName = ProbePathUtil.getNameByPath(srcJar.path)
println("將解壓至:" + unzipDir)
JarAvatar.extractJar(unzipDir, srcJar.path, new ProbeListener() {
@Override
boolean probeFile(InputStream is, FileOutputStream fos, JarEntry jarEntry) {
String jarEntryName = jarEntry.name
if (!FilterUtil.checkFilterFile(jarEntryName)) {
String clazName = jarEntryName.contains("/") ? jarEntryName.substring(jarEntryName.lastIndexOf("/") + 1) : jarEntryName
// println("探針插入中:" + jarEntry.name + " " + clazName)
final Instrumenter instr = new Instrumenter(new OfflineInstrumentationAccessGenerator());
final byte[] instrumented = instr.instrument(is, clazName)
fos.write(instrumented)
return true
}
return false
}
@Override
void unZipEnd(boolean result, String msg) {
JarAvatar.compressJar(unzipDir, destJar.path)
Thread.sleep(1000)
FileUtils.deleteRecursivelyIfExists(new File(unzipDir))
println("壓縮打包完畢:成功?" + result + " \njar來源:" + msg)
println(">>>>>>>>插樁處理完畢————————————————————————————————\n")
}
})
} else {
println("忽略Jar包:" + srcJar.path)
FileUtils.copyFile(srcJar, destJar)
}
}
void handleDir(DirectoryInput dirInput, TransformOutputProvider outputProvider) {
//輸出文件
File destJar = outputProvider.getContentLocation(dirInput.getName(), dirInput.getContentTypes(), dirInput.getScopes(), Format.DIRECTORY)
//輸入文件
File srcJar = dirInput.file
FileUtils.copyDirectory(srcJar, destJar)
}
//防止上次緩存導致兩個版本的包出現增量問題
private void clearUnZipCache(String unzipDir) {
println("清除緩存差異:" + unzipDir)
File f = new File(unzipDir)
if (f.exists()) {
try {
FileUtils.deleteDirectoryContents()
} catch (Exception e) {
}
}
}
}
3.在resources/META-INF/gradle-plugins/jacoprobe.properties添加implementation-class=com.xxx.probe.ProbePlugin
此處表示的是插件的入口類
文件夾下可以定義多個.properties文件指向不同的入口類,這樣就跟官方的com.android.application
、com.android.library
、android
引用方式相同,一個jar包多種引用名
4.在/app/build.gradle文件頂部添加apply plugin: 'jacoprobe'
期間遇到一個貌似關於androidx遷移的打包錯誤
在gradle.properties文件中添加轉換過濾android.jetifier.blacklist=bcprov-jdk15on
經實際檢驗發現,沒問題,至此結束本次處理.