前言 :jacoco是Java Code Coverage的缩写,是Java代码覆盖率统计的主流工具之一。关于jacoco的原理介绍的文章在网上有很多,感兴趣的同学可以去找别的博客看看,我这里不做赘述。它的作用是在安卓项目的代码覆盖率统计使用了jacoco的离线插桩方式,在测试前先对文件进行插桩,然后生成插过桩的class或jar包,测试(单元测试、UI测试或者手工测试等)插过桩的class和jar包后,会生成动态覆盖信息到文件,最后统一对覆盖信息进行处理,并生成报告。


一、环境确认(可以改用自己实际的环境)

1. 根目录build.gradle插件版本
classpath 'com.android.tools.build:gradle:4.0.1'

2.gradle依赖版本(gradle-wrapper.properties):

distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip

3.app模块build.gradle的android sdk版本:

minSdkVersion 27
	targetSdkVersion 30

二、开始实践

1.创建一个jacoco.gradle文件:

是提供给app模块build.gradle使用的,负责依赖jacoco插件,指定jacoco版本号,并且创建一个生成报告的任务,具体代码如下所示:

apply plugin: 'jacoco'
jacoco {
    toolVersion = "0.8.2"
}
//源代码路径,你有多少个module,你就在这写多少个路径
def coverageSourceDirs = [
        '/src/main/java'
]

//class文件路径,就是我上面提到的class路径,看你的工程class生成路径是什么,替换一下就行
def coverageClassDirs = [
        '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.setFrom(fileTree(
            dir: 'build/intermediates/javac/debug/classes',
            excludes: ['**/R*.class',
                       '**/*$InjectAdapter.class',
                       '**/*$ModuleAdapter.class',
                       '**/*$ViewInjector*.class'
            ]))
    sourceDirectories.setFrom(files(coverageSourceDirs))
    executionData.setFrom(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('$$', '$'))
                }
            }
        }
    }

}

2.在build.gradle文件中添加依赖

依赖上面的jacoco.gradle,下面是一个通用的示例:

apply from: 'jacoco.gradle'
	android {
    compileSdkVersion 30
    buildToolsVersion "30.0.3"
    defaultConfig {
        minSdkVersion 27
        targetSdkVersion 30
        multiDexEnabled true
        ...
    }
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
        debug {
            testCoverageEnabled = true
            debuggable true
            minifyEnabled false
            shrinkResources false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

3.配置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:allowBackup="true"
	...
    android:largeHeap="true"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">

     ...
  
  <activity
    android:name=".test.InstrumentedActivity"
    android:label="InstrumentationActivity" />
</application>

<instrumentation
  android:name=".test.JacocoInstrumentation"
  android:handleProfiling="true"
  android:label="CoverageInstrumentation"
  android:targetPackage="com.example.myapplication" />

配置完后,会发现targetPackage="com.example.myapplication"显红,但是没关系,不用管它,targetPackage对应的是应用的包名。

4.在com.example.myapplication下创建一个test包

以下列出的这几个文件和网上其他的一样,可以直接拿过来用,这里其实用的是监听我们的主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);
    }
  }
}

5.生成测试报告

1、installDebug

首先我们通过命令行安装app,选择你的app -> Tasks -> install -> installDebug,安装app到你的设备上。

2、命令行启动app
adb shell am instrument com.example.myapplication/com.example.myapplication.test.JacocoInstrumentation
3、正常点击测试app的功能

这个时候你可以操作你的app,对你想进行代码覆盖率检测的地方,进入到对应的页面,点击对应的按钮,触发对应的逻辑,你现在所操作的都会被记录下来,在生成的coverage.ec文件中都能体现出来。当你点击完了,根据我们之前设置的逻辑,当我们MainActivity执行onDestroy方法时才会通知JacocoInstrumentation生成coverage.ec文件,我们可以按返回键退出MainActivity返回桌面,生成coverage.ec文件可能需要一点时间哦(取决于你点击测试页面多少,测试越多,生成文件越大,所需时间可能多一点)
然后在Android Studio的Device File Explore中,找到data/data/包名/files/coverage.ec文件,右键保存到桌面(随你喜欢)备用。

4、createDebugCoverageReport

点击Android Studio的右上角“Gradle”,这个命令正常存在的路径是

应用名称/app/verification/createDebugCoverageReport

双击它,会执行创建覆盖率报告的命令,等待它执行完,这个会在…/app/build/outputs/code_coverage/debugAndroidTest/connected下生成一个covera.ec文件,但是这个不是我们最终需要分析的,我们需要分析的是我们刚才手动点击保存到桌面的那个。然后把桌面的那个coverage.ec文件拷贝到这个路径下(当然coverage.ec文件拷贝到哪个路径都可以改,你的jacoco.gradle中执行的executionData对应的路径也得配套修改)

5、jacocoTestReport

点击Android Studio的右上角“Gradle”,路径是

应用名称/app/reporting/jacocoTestReport

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

应用名称\app\build\reports\jacoco\jacocoTestReport\coverageTestReport\index.html

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