前言 :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文件生成的路径如图所示:

Test Runner for Java 代码覆盖率 java代码覆盖率原理_android


所以我的路径是:

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参考上面第一章节第二小节,我将它放在工程根目录下:

Test Runner for Java 代码覆盖率 java代码覆盖率原理_ide_02


这时候打开我的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。

Test Runner for Java 代码覆盖率 java代码覆盖率原理_java_03


选择你的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

这个命令正常存在的路径是

Test Runner for Java 代码覆盖率 java代码覆盖率原理_android_04


双击它,会执行创建覆盖率报告的命令,等待它执行完,这个会生成一个covera.ec文件,但是这个不是我们最终需要分析的,我们需要分析的是我们刚才手动点击保存到桌面的那个。

Test Runner for Java 代码覆盖率 java代码覆盖率原理_ide_05


等它执行完后,会在这个路径下生成一个coverage.ec文件,不用想,删掉它!然后把桌面的那个coverage.ec文件拷贝到这个路径下(当然coverage.ec文件拷贝到哪个路径都可以改,你的jacoco.gradle中执行的executionData对应的路径也得配套修改)

5.jacocoTestReport

Test Runner for Java 代码覆盖率 java代码覆盖率原理_ide_06


找到这个路径,双击执行这个任务,会生成我们最终所需要代码覆盖率报告,执行完后,我们可以在这个目录下找到它

app/build/reports/jacoco/jacocoTestReport/html/index.html

在文件夹下双击打开就能看到我们的代码覆盖率报告

8、分析报告

以我项目实际运行结果为例,打开index.html后,首先会展示的是所有目录的整体覆盖率

Test Runner for Java 代码覆盖率 java代码覆盖率原理_android_07


点进去看一个

Test Runner for Java 代码覆盖率 java代码覆盖率原理_java_08


以页面的结果看看

Test Runner for Java 代码覆盖率 java代码覆盖率原理_ide_09


绿色的就是代码执行到了,红色的就是代码没有执行到,我们可以根据这个来完善我们的测试逻辑,做到提交之前代码覆盖率尽可能百分百,不要漏过任何没测试的逻辑。

好了,到这里完整的Android + jacoco集成使用方式讲完了,对于今天想用的同学应该是一盏指路明灯吧,有问题欢迎留言!