前言 :jacoco是Java Code Coverage的缩写,是Java代码覆盖率统计的主流工具之一。
在我接到这个需求,需要统计开发人员提交代码自测率的时候,从其他渠道和gradle推荐了解到的实现方式都是jacoco,然后也上网查了不少的资料,网上的资料都非常老了,gradle插件依赖的不是1.+就是2.+,gradle依赖还是4.4左右,所以导致一个问题,也是浪费了我很多时间的问题:网上的资料已经跟不上时代了,然而没有一篇最新的、最正确的jacoco+Android集成实践的博文,来给有这方面有诉求的同学指引方向,在我费尽千辛万苦终于找到突破口并实现了之后,决定记录这个问题,为日后有需求的同学点一盏明灯!
首先标明我的使用环境,应该也是现在主流的项目开发环境,也比较新:
1.gradle插件版本:
classpath 'com.android.tools.build:gradle:3.5.1'(根目录build.gradle)
2.gradle依赖版本:
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip(gradle-wrapper.properties)
3.android sdk版本:
minSdkVersion 19
targetSdkVersion 28(app模块build.gradle)
下面我来说说网上现存博文的问题,文末会附上完整版实现代码。
一、踩坑记录
1、classDirectories路径不正确
以网上代码为例,都是这么写的:
classDirectories = fileTree(dir: "./build/intermediates/classes/debug", excludes: excludesFilter)
首先这个路径是gradle版本比较老的情况下,执行编译的时候才会在这个路径下生成class文件,但是在我使用的开发环境基础上,app/build/intermediates/classes根本就没有内容,这个问题阻拦了我很长一段时间,一度让我郁闷,以为是哪里配置出了问题,导致我的工程无法正确的生成class文件!
但是当我了解到是gradle版本的区别后,新版本的gradle在编译源代码时,生成的路径根本就不是这个,正确的路径是:
app/build/intermediates/javac/debug/classes
一切豁然开朗!!!在这个目录下,我找到了我需要了一切classes文件。
2、多module依赖覆盖率统计
这是第二个让我郁闷的地方,翻遍网上介绍的博文,凡是涉及多个库依赖统计覆盖率的,千篇一律的实现方式是这样:
将moduleA中原来依赖方式由:
compile project(':moduleB')
变成:
debugCompile project(path:':moduleB',configuration:'debug')
为此我还查了怎么让lirbray工程编译提供debug包的方式,然而在我使用configuration的时候,编译就是不通过,大哥,compile依赖都是多久年之前的依赖方式了,现在都用implementation或者api替代了好吗,另外configuration已经不存在了,所以这种依赖module的方式根本不可用!!!
我们在依赖的需要统计覆盖率的module对应的build.gradle中,只需要添加几个地方,可以整理一个jacoco-config.gradle文件,代码如下:
apply plugin: 'jacoco'
android {
defaultPublishConfig "debug"
buildTypes {
debug {
/**打开覆盖率统计开关**/
testCoverageEnabled = true
}
}
}
在你需要统计覆盖率的module的build.gradle中依赖这个gradle文件即可。
其中解释一点:
defaultPublishConfig "debug"
这个已经说明了,我们module默认对外暴露的就是debug,所以在我们的moduleA(一般也是app module)中该怎么依赖就怎么依赖,比如我的就是:
api project(path: ':app_jinggong_sdk')
就是这么朴素,不用加任何多余的指令,这就够了,信我!
3、Unable to read execution data file …/coverage.ec
有的人依赖后执行生成ec文件时会抛出这种问题,提示没有权限处理ec,或者读取失败,别想多的,调整jacoco的版本就完了,用这个,好使!
jacoco {
toolVersion = "0.8.2"
}
4、ec文件保存目录
网上介绍的都是使用这个路径:
"/mnt/sdcard/coverage.ec"
不知道你们是否好使,我用这个路径直接被拒绝,保存文件时报错,告诉我我没权限往这个目录下写文件,悲催!那就换一个路径呗,通过这行代码,将文件保存到files目录下:
getContext().getFilesDir().getPath() + "/coverage.ec"
亲测好使,另外有一点,当我们多次测试生成ec文件时,我通过Android Studio中Device File Explorer查看files目录下coverage.ec的创建时间,一直是上一次的,刚开始我以为是缓存问题,浏览器没来得及更新,但是后来偶然发现,我天真了,如果你也碰到这种情况,把你的手机拔了,再连上去,再看看,是不是马上刷新了,是不是你上次操作生成的文件!
5、executionData路径错误
网上的博客都是这么写的:
executionData = files("$buildDir/outputs/code-coverage/connected/coverage.ec")
其实我们要理解这个是什么意思,executionData指的是jacoco要执行解析ec文件的目录,那么我们应该以你项目执行createDebugCoverageReport任务生成的目录为主,而不是固定这种写法,懂了吗?比如在我的环境下执行createDebugCoverageReport命令后,coverage.ec文件生成的路径如图所示:
所以我的路径是:
executionData = files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec")
所以当我们理解了这是什么含义的时候,我们可以对示例作出相应的修改。
二、实践出真知
在列出了上面的几个坑,相信之前被困扰的同学应该知道自己的问题在哪儿了,这里附上一个完整版的实现代码,这里直接以多模块为例,如果你只有app一个module,修改相应代码就可以了,这也避免了同样的东西讲两遍。
以下列出的这几个文件和网上其他的一样,可以直接拿过来用,这里其实用的是监听我们的主Activity,这个一般是咱们app的首页MainActivity,不要把它理解为启动Activity,做的就是一件事,当这个Activity执行onDestroy方法的时候通知Instrumentation生成ec文件,所以你不想根据这种思路来走完全没有问题,实现一个工具类,在你想要执行生成ec文件的时候调用即可,道理一样,看你的使用场景和需求,废话不多说。
1、FinishListener
public interface FinishListener {
void onActivityFinished();
void dumpIntermediateCoverage(String filePath);
}
2、InstrumentedActivity
public class InstrumentedActivity extends MainActivity {
public FinishListener finishListener;
public void setFinishListener(FinishListener finishListener) {
this.finishListener = finishListener;
}
@Override
public void onDestroy() {
if (this.finishListener != null) {
finishListener.onActivityFinished();
}
super.onDestroy();
}
}
3、JacocoInstrumentation
public class JacocoInstrumentation extends Instrumentation implements FinishListener {
public static String TAG = "JacocoInstrumentation:";
private static String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/coverage.ec";
private final Bundle mResults = new Bundle();
private Intent mIntent;
private static final boolean LOGD = true;
private boolean mCoverage = true;
private String mCoverageFilePath;
public JacocoInstrumentation() {
}
@Override
public void onCreate(Bundle arguments) {
LogUtil.e(TAG, "onCreate(" + arguments + ")");
super.onCreate(arguments);
DEFAULT_COVERAGE_FILE_PATH = getContext().getFilesDir().getPath() + "/coverage.ec";
File file = new File(DEFAULT_COVERAGE_FILE_PATH);
if (file.isFile() && file.exists()) {
if (file.delete()) {
LogUtil.e(TAG, "file del successs");
} else {
LogUtil.e(TAG, "file del fail !");
}
}
if (!file.exists()) {
try {
file.createNewFile();
} catch (IOException e) {
LogUtil.e(TAG, "异常 : " + e);
e.printStackTrace();
}
}
if (arguments != null) {
LogUtil.e(TAG, "arguments不为空 : " + arguments);
mCoverageFilePath = arguments.getString("coverageFile");
LogUtil.e(TAG, "mCoverageFilePath = " + mCoverageFilePath);
}
mIntent = new Intent(getTargetContext(), InstrumentedActivity.class);
mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
start();
}
@Override
public void onStart() {
LogUtil.e(TAG, "onStart def");
if (LOGD) {
LogUtil.e(TAG, "onStart()");
}
super.onStart();
Looper.prepare();
InstrumentedActivity activity = (InstrumentedActivity) startActivitySync(mIntent);
activity.setFinishListener(this);
}
private boolean getBooleanArgument(Bundle arguments, String tag) {
String tagString = arguments.getString(tag);
return tagString != null && Boolean.parseBoolean(tagString);
}
private void generateCoverageReport() {
OutputStream out = null;
try {
out = new FileOutputStream(getCoverageFilePath(), false);
Object agent = Class.forName("org.jacoco.agent.rt.RT")
.getMethod("getAgent")
.invoke(null);
out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class)
.invoke(agent, false));
} catch (Exception e) {
LogUtil.e(TAG, e.toString());
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
private String getCoverageFilePath() {
if (mCoverageFilePath == null) {
return DEFAULT_COVERAGE_FILE_PATH;
} else {
return mCoverageFilePath;
}
}
private boolean setCoverageFilePath(String filePath) {
if (filePath != null && filePath.length() > 0) {
mCoverageFilePath = filePath;
return true;
}
return false;
}
private void reportEmmaError(Exception e) {
reportEmmaError("", e);
}
private void reportEmmaError(String hint, Exception e) {
String msg = "Failed to generate emma coverage. " + hint;
LogUtil.e(TAG, msg);
mResults.putString(Instrumentation.REPORT_KEY_STREAMRESULT, "\nError: "
+ msg);
}
@Override
public void onActivityFinished() {
if (LOGD) {
LogUtil.e(TAG, "onActivityFinished()");
}
if (mCoverage) {
LogUtil.e(TAG, "onActivityFinished mCoverage true");
generateCoverageReport();
}
finish(Activity.RESULT_OK, mResults);
}
@Override
public void dumpIntermediateCoverage(String filePath) {
// TODO Auto-generated method stub
if (LOGD) {
LogUtil.e(TAG, "Intermidate Dump Called with file name :" + filePath);
}
if (mCoverage) {
if (!setCoverageFilePath(filePath)) {
if (LOGD) {
LogUtil.e(TAG, "Unable to set the given file path:" + filePath + " as dump target.");
}
}
generateCoverageReport();
setCoverageFilePath(DEFAULT_COVERAGE_FILE_PATH);
}
}
}
其实这个里面的代码有很多是可以优化的,我这里没有做深究,毕竟不是核心,你们可以自行处理。
4、在app模块下新建一个jacoco.gradle文件
这个jacoco.gradle文件,是提供给app模块build.gradle使用的,负责依赖jacoco插件,指定jacoco版本号,并且创建一个生成报告的任务,具体代码如下所示:
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.2"
}
//源代码路径,你有多少个module,你就在这写多少个路径
def coverageSourceDirs = [
'../app/src/main/java',
'../app_jinggong_sdk/src/main/java',
'../app_jinggong_store/src/main/java',
'../app_jinggong_flutter/src/main/java',
'../app_jinggong_libcore/src/main/java',
]
//class文件路径,就是我上面提到的class路径,看你的工程class生成路径是什么,替换我的就行
def coverageClassDirs = [
'../app/build/intermediates/javac/debug/classes',
'../app_jinggong_sdk/build/intermediates/javac/debug/classes',
'../app_jinggong_store/build/intermediates/javac/debug/classes',
'../app_jinggong_flutter/build/intermediates/javac/debug/classes',
'../app_jinggong_libcore/build/intermediates/javac/debug/classes',
]
//这个就是具体解析ec文件的任务,会根据我们指定的class路径、源码路径、ec路径进行解析输出
task jacocoTestReport(type: JacocoReport) {
group = "Reporting"
description = "Generate Jacoco coverage reports after running tests."
reports {
xml.enabled = true
html.enabled = true
}
classDirectories = files(files(coverageClassDirs).files.collect {
fileTree(dir: it,
// 过滤不需要统计的class文件
excludes: ['**/R*.class',
'**/*$InjectAdapter.class',
'**/*$ModuleAdapter.class',
'**/*$ViewInjector*.class'
])
})
sourceDirectories = files(coverageSourceDirs)
executionData = files("$buildDir/outputs/code_coverage/debugAndroidTest/connected/coverage.ec")
doFirst {
//遍历class路径下的所有文件,替换字符
coverageClassDirs.each { path ->
new File(path).eachFileRecurse { file ->
if (file.name.contains('$$')) {
file.renameTo(file.path.replace('$$', '$'))
}
}
}
}
}
然后在你的app的build.gradle文件中依赖这个jacoco.gradle,下面我给出一个通用的示例:
apply plugin: 'com.android.application'
apply from: 'jacoco.gradle'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.zhangyan.test"
minSdkVersion 19
targetSdkVersion 28
versionCode 1200000
versionName "2.0.0"
ndk {
abiFilters "armeabi" //暂时支持模拟器,上线前移除x86
}
multiDexEnabled true
}
buildTypes {
release {
shrinkResources true
minifyEnabled true //混淆开关 遇到线上类找不到先将混淆去掉 对比是否是混淆问题
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
/**打开覆盖率统计开关*/
testCoverageEnabled = true
signingConfig signingConfigs.BeikeConfig
debuggable true
shrinkResources false
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
//主业务SDK
api project(path: ':app_jinggong_sdk')
}
5、在依赖的Library模块中添加依赖
看到app的build.gradle中我依赖了一个业务module:
//主业务SDK
api project(path: ':app_jinggong_sdk')
所以当我需要统计子module中的代码覆盖率的时候,我的子module也要作出相应的改造,具体jacoco-config.gradle参考上面第一章节第二小节,我将它放在工程根目录下:
这时候打开我的app_jinggong_sdk模块的build.gradle文件,添加代码:
apply plugin: 'com.android.library'
apply from: '../gradleCommon/jacoco-config.gradle'
具体的依赖都在我们的jacoco-config.gradle中,这样我们的module工程也打开了统计代码的开关,能够进行代码覆盖率的统计。有多少个依赖的子module,你就在那些子module的build.gradle文件中都添加这个jacoco-config.gradle文件依赖就好了。
6、配置AndroidManifest.xml
在app模块的AndroidManifest.xml添加一些配置,配置我们上面添加的InstrumentedActivity和JacocoInstrumentation。
//添加所需的权限
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_PROFILE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:name="com.zhangyan.test.MyApplication"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/AppTheme">
...省略你自己其他的配置和页面...
<activity
android:name=".jacoco.InstrumentedActivity"
android:label="InstrumentationActivity" />
</application>
<instrumentation
android:name=".jacoco.JacocoInstrumentation"
android:handleProfiling="true"
android:label="CoverageInstrumentation"
android:targetPackage="com.zhangyan.test" />
配置完后,会发现targetPackage="com.zhangyan.test"显红,但是没关系,不用管它,targetPackage对应的是应用的包名。
7、生成测试报告
1.installDebug
首先我们通过命令行安装app。
选择你的app -> Tasks -> install -> installDebug,安装app到你的手机上。
2.命令行启动
adb shell am instrument com.zhangyan.test/com.zhangyan.test.jacoco.JacocoInstrumentation
通过上述命令行启动app,adb shell am instrument 应用包名/Instrumentation完整路径名。
3.点击测试
这个时候你可以操作你的app,对你想进行代码覆盖率检测的地方,进入到对应的页面,点击对应的按钮,触发对应的逻辑,你现在所操作的都会被记录下来,在生成的coverage.ec文件中都能体现出来。当你点击完了,根据我们之前设置的逻辑,当我们MainActivity执行onDestroy方法时才会通知JacocoInstrumentation生成coverage.ec文件,我们可以按返回键退出MainActivity返回桌面,生成coverage.ec文件可能需要一点时间哦(取决于你点击测试页面多少,测试越多,生成文件越大,所需时间可能多一点)
然后在Android Studio的Device File Explore中,找到data/data/包名/files/coverage.ec文件,右键保存到桌面(随你喜欢)备用
4.createDebugCoverageReport
这个命令正常存在的路径是
双击它,会执行创建覆盖率报告的命令,等待它执行完,这个会生成一个covera.ec文件,但是这个不是我们最终需要分析的,我们需要分析的是我们刚才手动点击保存到桌面的那个。
等它执行完后,会在这个路径下生成一个coverage.ec文件,不用想,删掉它!然后把桌面的那个coverage.ec文件拷贝到这个路径下(当然coverage.ec文件拷贝到哪个路径都可以改,你的jacoco.gradle中执行的executionData对应的路径也得配套修改)
5.jacocoTestReport
找到这个路径,双击执行这个任务,会生成我们最终所需要代码覆盖率报告,执行完后,我们可以在这个目录下找到它
app/build/reports/jacoco/jacocoTestReport/html/index.html
在文件夹下双击打开就能看到我们的代码覆盖率报告
8、分析报告
以我项目实际运行结果为例,打开index.html后,首先会展示的是所有目录的整体覆盖率
点进去看一个
以页面的结果看看
绿色的就是代码执行到了,红色的就是代码没有执行到,我们可以根据这个来完善我们的测试逻辑,做到提交之前代码覆盖率尽可能百分百,不要漏过任何没测试的逻辑。
好了,到这里完整的Android + jacoco集成使用方式讲完了,对于今天想用的同学应该是一盏指路明灯吧,有问题欢迎留言!