Android 增量代码覆盖率插件实现(支持java和kotlin)

1,902 阅读5分钟

Android增量代码覆盖率插件的实现原理(支持java和kotlin)

想要得到增量的数据,大概分为以下几个步骤

1、根据git diff获取增量数据

2、解析增量数据里的类名和方法名

3、在插桩和生成报告时过滤增量的方法

4、处理多进程报告和定义好自动化脚本

使用git diff获取增量数据

这里使用的是jGit这个框架,根据它的api可以对比不同分支直接的差异,代码来源diff-jacoco,对部分做了修改

val localMasterRef = gitAdapter.repository!!.exactRef(REF_HEADS + oldBranchName11)
//  更新本地分支
gitAdapter.checkOutAndPull(localMasterRef, oldBranchName11)
val oldTreeParser = gitAdapter.prepareTreeParser(localMasterRef)

val diffCommand = git.diff().setOldTree(oldTreeParser)
//默认用当前分支的最新代码,这里会包括未提交到git的代码
if (!newBranchName.isNullOrBlank()){
    gitAdapter.checkOutAndPull(localBranchRef, newBranchName)
    //  获取分支信息
    val newTreeParser = gitAdapter.prepareTreeParser(localBranchRef)
    diffCommand.setNewTree(newTreeParser)
}
//  对比差异
val diffs = diffCommand.setShowNameAndStatusOnly(true).call()

相同分支的不同版本对比

val previousHead = repo.resolve("$oldTag^{tree}")
// Instanciate a reader to read the data from the Git database
val reader = repo.newObjectReader()
// Create the tree iterator for each commit
val oldTreeIter = CanonicalTreeParser()
oldTreeIter.reset(reader, previousHead)

val diffCommand = git!!.diff().setOldTree(oldTreeIter)
//如果没有最新的newTag,默认用当前的代码,这里会包括未提交到git的代码
if (!newTag.isNullOrBlank()) {
    val head = repo.resolve("$newTag^{tree}")
    val newTreeIter = CanonicalTreeParser()
    newTreeIter.reset(reader, head)
    diffCommand.setNewTree(newTreeIter)
}
//  对比差异
val diffs = diffCommand.setShowNameAndStatusOnly(true).call()

这样我们就会得到List<DiffEntry>,里面会包含修改的文件和行数及内容,接下来就要根据这里的数据获取类名和方法名了

解析增量数据里的类名和方法名

我们想要知道一个java某一行属于哪个方法,我们首先要把这个文件解析成抽象语法树,简单来说就是获取一个文件的方法和变量等一些信息, 这里我们会用到解析java的 ASTGenerator (代码来源diff-jacoco)

private fun fillKotlinMethodInfos(
        methodInfoList: MutableList<MethodInfo>,
        newAstGenerator: KotlinASTGenerator,
        newMethods: List<Node.Decl.Func>,
        methodsMap: Map<String, Node.Decl.Func?>,
        oldContent: String,
        newContent: String
    ) {
        for (method in newMethods) {
            // 如果方法名是新增的,则直接将方法加入List
            if (!KotlinASTGenerator.isMethodExist(method, methodsMap)) {
                val methodInfo = newAstGenerator.getMethodInfo(method)
                methodInfoList.add(methodInfo)
                continue
            }

            // 如果两个版本都有这个方法,则根据MD5判断方法是否一致
            val odlMethod = methodsMap[method.name.toString() + method.params.toString()]!!

            if (!KotlinASTGenerator.isMethodTheSame(
                    method,
                    odlMethod
                )
            ) {
                val methodInfo = newAstGenerator.getMethodInfo(method)
                methodInfoList.add(methodInfo)
            }
        }
    }

假设我们获取到了MainActivity两个版本之前的Diff数据,我们根据它修改的行数知道了修改的方法,然后我们就可以把这些数据记录下来,大概像这个图这样

img_4.png

由于kotlin编译后class字节码变化很大(同样写法的lambda表达式kotlin可能会生成类或者是一个方法,还有其他的internal等之类也会改变方法名字),这里我们用的是kcp(kotlin compiler plugin)来对kotlin的diff处理, (不太了解kcp的可以看看我之前写的如何利用kcp compose 事件埋点)

首先我们拿到kotlin的diff的文件名和变更的行数, 然后在kcp的IrTransformer类的的方法visitFunctionNew中对比当前方法是否在变更行数中,如果在就添加一个注解JacocoXDiff, 后面会通过ASM过滤方法的注解,选择插入代码,下面会介绍

override fun visitFunctionNew(declaration: IrFunction): IrStatement {
    val startLine = currentFile.fileEntry.getLineNumber(startOffset)+1
    val endLine = currentFile.fileEntry.getLineNumber(endOffset).coerceAtLeast(startLine)
    val find = if (isFakeOverride) null else lines?.find {
        //判断方法的行数和变更的行数是否有交集
        startLine in it.first[0] .. it.first[1] || endLine in it.first[0] .. it.first[1] || it.first.any { it1 ->
            it1 in startLine..endLine
        }
    }
    
    val irStatement = action(this)
    if (find != null && irStatement is IrFunction) {
        ...
        irStatement.annotations = irStatement.annotations + getDiffAnnotation()
    }

在插桩和生成报告时过滤增量的方法

jacoco主要使用的是ASM进行插桩,它会在这个类ClassProbesAdaptervisitMethod进行处理,所以我们在这里过滤增量方法,我使用的jacoco版本为0.8.4

val match = DiffManager.INSTANCE?.needHackMethod(name, className) ?: false
log?.d("JacocoXClassProbesAdapter", "class:$className#$name, match:$match")
if (!match || this.name!!.startsWith("org/jacoco")) {
    return orginCv?.visitMethod(access, name, desc, signature, exceptions) ?: EMPTY_METHOD_PROBES_VISITOR
}
val methodProbes: MethodProbesVisitor
val mv: MethodProbesVisitor? = classProbesVisitor?.visitMethod(
    access, name, desc,
    signature, exceptions
)

然后把我们自定义的ClassProbesAdapter挂载到Transform里,这样插桩就完成了

在运行APP和完成自测后,我们需要把代码执行过的数据保存到本地

/**
     * 生成ec文件
     *
     * @param isNew 是否重新创建ec文件
     */
    fun generateEcFile(isNew: Boolean, context: Context) {
        var out: OutputStream? = null
        val coveragePath = context.externalCacheDir!!.path + "/coverage/"+ processName+".ec"
        val mCoverageFilePath = File(coveragePath)
        try {
            mCoverageFilePath.parentFile?.let {
                if (!it.exists()) {
                    it.mkdirs()
                }
            }
            if (isNew && mCoverageFilePath.exists()) {
                Log.d(TAG, "清除旧的ec文件")
                mCoverageFilePath.delete()
            }
            if (!mCoverageFilePath.exists()) {
                mCoverageFilePath.createNewFile()
            }
            out = FileOutputStream(mCoverageFilePath.path, true)
            val agent = Class.forName("org.jacoco.agent.rt.RT")
                .getMethod("getAgent")
                .invoke(null)
            if (agent != null) {
                out.write(
                    agent.javaClass.getMethod("getExecutionData", Boolean::class.javaPrimitiveType)
                        .invoke(agent, false) as ByteArray
                )
            }
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            try {
                out?.close()
            } catch (e: IOException) {
                e.printStackTrace()
            }
        }
    }

最后我们需要把生成的数据转化成盒代码关联的html的报告,需要定义一个这样的task,运行就会生成报告

task jacocoTestReport(type: JacocoReport) {
    reports {
        xml.enabled = false
        html.enabled = true
        html.outputLocation = file("$buildDir/$reportFile")
    }
    ....
}

这个task会经过JacocoReport,最终会执行一个已经定义好的ReportTask,这样用的是groovy的元编程

protected void configureAntReportTask(FileCollection classpath, final Action<GroovyObjectSupport> action) {
    this.ant.withClasspath(classpath).execute(new Closure<Object>(this, this) {
        public Object doCall(Object it) {
            GroovyObjectSupport antBuilder = (GroovyObjectSupport)it;
            antBuilder.invokeMethod("taskdef", ImmutableMap.of("name", "jacocoReport", "classname", "org.jacoco.ant.ReportTask"));
            action.execute(antBuilder);
            return null;
        }
    });
}

然后在ReportTask回调用Analyzer,然后就会调用ClassProbesAdapter,所以我们需要把这些替换成我们自定义的类, 具体可查看baseJacoco.gradle里的jacocoTestReport task,这样在生成报告的时候也会过滤增量的方法

处理多进程报告和定义好自动化脚本

1、Android gradle自带的jacoco在生成报告时需要配置class源码的路径,当我们有很多个module和多渠道针对不同的buildType、flavor有不同的代码时就会有点复杂和麻烦, 这里我会在打包的时候获取当前打包的buildType和flavor然后保存到本地文件,在生成报告的时候读取

2、在生成报告的时候,我们可以通过命令行发生一条自定义广播给APP,APP接收到广播后把内存里覆盖率数据保存到本地,然后我们再通过adb pull出来,这里我们也可以通过ASM插桩把广播自动注册到APP内

3、假如APP是多进程的就要生成多份数据,因为每个进程都有单独的内存区域,然后我们用jacoco的MergeTask把多份数据合并成一份

最后的自定义脚本大概这样,在工程下的jacocoreport

#!/bin/bash
export JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home'
adb shell am broadcast -a jacocox.generate.com.ckenergy.jacocox
sleep 0.5
./gradlew JacocoXInit
adb pull /storage/emulated/0/Android/data/com.ckenergy.jacocox/cache/coverage/ ./app/build/outputs/code-coverage
./gradlew JacocoXMerge
./gradlew JacocoXReport
open ./app/build/reports/jacocoXReport/html/index.html

参考:

简单两步实现 Jacoco+Android 代码覆盖率的接入!

diff-jacoco

kastree

Merge Jacoco coverage reports for multiproject setups

Android增量代码测试覆盖率工具