導讀
高德地圖開放平臺產品不斷迭代,程式碼邏輯越來越複雜,現有的測試流程不能保證完全覆蓋所有業務程式碼,測試不到的程式碼及分支,會存在一定的風險。為了保證測試全面覆蓋,需要引入程式碼覆蓋率做為測試指標,需要對SDK程式碼進行染色,測試結束後可生成程式碼覆蓋率報告,作為發版前的一項重要卡點指標。本文小結了Android端程式碼染色原理及技術實踐。
JaCoCo工具
JaCoCo有以下優點:
- 支援Ant和Gradle打包方式,可以自由切換。
- 支援離線模式,更貼合SDK的使用場景。
- JaCoCo文件比較全面,還在持續維護,有問題便於解決。
JaCoCo主要是通過ASM技術對Java位元組碼進行處理和插樁,ASM和Java位元組碼技術不是本文重點,感興趣的朋友可以自行了解。下面重點介紹JaCoCo的插樁原理。
Jacoco探針
由於Java位元組碼是線性的指令序列,所以JaCoCo主要是利用ASM處理位元組碼,在需要的地方插入一些特殊程式碼。
我們通過Test1方法觀察一下JaCoCo做的處理。
//原始java方法 public static int Test1(int a, int b) { int c = a + b; int d = c + a; return d; } //--------------------------我是分割線--------------------------------------------// //jacoco處理後的方法 private static transient /* synthetic */ boolean[] $jacocoData; public static int Test1(final int a, final int b) { final boolean[] $jacocoInit = $jacocoInit(); final int c = a + b; final int n; final int d = n = c + a; $jacocoInit[3] = true; return n; } private static boolean[] $jacocoInit() { boolean[] $jacocoData; if (($jacocoData = TestInstrument.$jacocoData) == null) { $jacocoData = (TestInstrument.$jacocoData = Offline.getProbes(-6846167369868599525L, "com/jacoco/test/TestInstrument", 4)); } return $jacocoData; }
可以看出程式碼中插入了多個Boolean陣列賦值,自動新增了jacocoInit方法和jacocoData陣列宣告。
JaCoCo統計覆蓋率就是標記Boolean陣列, 只要執行過的程式碼,就對相應角標的Boolean陣列進行賦值, 最後對Boolean進行統計即可得出覆蓋率,這個陣列官方的名字叫探針 (Probe)。
探針是由以下四行位元組碼組成,探針不改變該程式碼的行為,只記錄他們是否已被執行,從理論上講,可以在每行程式碼都插入一個探針,但是探針本身需要多個位元組碼指令,這將增加幾倍的類檔案的大小和執行速度,所以JaCoCo有一定的插樁策略。
ALOAD probearray xPUSH probeid ICONST_1 BASTORE
探針插樁策略
探針的插入需要遵循一定策略,大體可分成以下三個策略:
-
統計方法的執行情況。
-
統計分支語句的執行情況。
-
統計普通程式碼塊的執行情況。
方法的執行情況
這個比較容易處理, 在方法頭或者方法尾加就可以了。
-
方法尾加: 能說明方法被執行過, 且說明了探針上面的方法被執行了,但是這種處理比較麻煩, 可能有多個return或者throw。
-
方法頭加: 處理簡單, 但只能說明方法有進去過。
通過分析原始碼,發現JaCoCo是在方法結尾處插入探針,retrun和throw之後都會加入探針。
public void visitInsn(final int opcode) { switch (opcode) { case Opcodes.IRETURN: case Opcodes.LRETURN: case Opcodes.FRETURN: case Opcodes.DRETURN: case Opcodes.ARETURN: case Opcodes.RETURN: case Opcodes.ATHROW: probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId()); break; default: probesVisitor.visitInsn(opcode); break; } }
分支的執行情況
Java位元組碼通過Jump指令來控制跳轉,分為有條件Jump和無條件Jump。
-
無條件Jump (goto)
這種一般出現在continue, break 中, 由於在任何情況下都執行無條件跳轉,因此在GOTO指令之前插入探針。
官方文件中介紹
示例程式碼
有條件Jump (if-else)
這種經常出現於if等有條件的跳轉語句,JaCoCo會對if語句進行反轉,將位元組碼變成if not的邏輯結構。
為什麼要對if進行反轉?下面示例將說明原因。
Test4方法是一個普通的單條件if語句,可以看到JaCoCo將>10的條件反轉成<=10,為什麼要進行反轉而不是直接在原有if後面增加else塊呢?繼續往下看複雜一點的情況。
//原始碼 public static void Test4(int a) { if(a>10){ a=a+10; } a=a+12; } //jacoco處理後的位元組碼 public static void Test4(int a) { boolean[] var1 = $jacocoInit(); if (a <= 10) { var1[11] = true; } else { a += 10; var1[12] = true; } a += 12; var1[13] = true; }
Test5方法是一個多條件的if語句,可以看出來將兩個組合條件拆分成單一條件,並進行反轉。
這樣做的好處:可以完整統計到每個條件分支的執行情況,各種條件都會插入探針,保證了完整的覆蓋,而反轉操作再配合GOTO指令可以更簡單的插入探針,這裡可以看出JaCoCo的處理非常巧妙。
//原始碼,if有多個條件 public static void Test5(int a,int b) { if(a>10 || b>10){ a=a+10; } a=a+12; } //jacoco處理後的位元組碼。 public static void Test5(int a, int b) { boolean[] var2; label15: { var2 = $jacocoInit(); if (a > 10) { var2[14] = true; } else { if (b <= 10) { var2[15] = true; break label15; } var2[16] = true; } a += 10; var2[17] = true; } a += 12; var2[18] = true; }
可以通過測試報告看出來,標記為黃色代表分支執行情況覆蓋不完整,標記為綠色代表分支所有條件都執行完整了。
程式碼塊的執行情況
理論上只要在每行程式碼前都插入探針即可, 但這樣會有效能問題。JaCoCo考慮到非方法呼叫的指令基本都是按順序執行的, 因此對非方法呼叫的指令不插入探針, 而對方法呼叫的指令之前都插入探針。
Test6方法內在呼叫Test方法前都插入了探針。
public static void Test6(int a, int b) { boolean[] var2 = $jacocoInit(); a += b; b = a + a; var2[19] = true; Test(); int var10000 = a + b; var2[20] = true; Test(); var2[21] = true; }
原始碼解析
通過上面的示例,我們暫時通過表面現象理解了探針插入策略。知其然不知其所以然,我們通過原始碼分析論證一下JaCoCo的真實邏輯,看看JaCoCo是如何通過ASM,來實現探針插入策略的。
原始碼MethodProbesAdapter.java類中,通過needsProbe方法判斷Lable前面是否需要插入探針。
@Override public void visitLabel(final Label label) { if (LabelInfo.needsProbe(label)) { if (tryCatchProbeLabels.containsKey(label)) { probesVisitor.visitLabel(tryCatchProbeLabels.get(label)); } probesVisitor.visitProbe(idGenerator.nextId()); } probesVisitor.visitLabel(label); }
下面看一下needsProbe方法,主要的限制條件有三個successor、multiTarget、methodInvocationLine。
public static boolean needsProbe(final Label label) { final LabelInfo info = get(label); return info != null && info.successor && (info.multiTarget || info.methodInvocationLine); }
先看到successor屬性。顧名思義,表示當前的Lable是否是前一條Lable的繼任者,也就是說當前指令和上一條指令是否是連續的,兩條指令中間沒有插入GOTO或者return.
LabelFlowAnalyzer.java類中,對每行指令進行流程分析,對successor屬性賦值。
boolean successor = false;//預設是false boolean first = true; //預設是true @Override public void visitJumpInsn(final int opcode, final Label label) { LabelInfo.setTarget(label); if (opcode == Opcodes.JSR) { throw new AssertionError("Subroutines not supported."); } //如果是GOTO指令,successor=false,表示前後兩條指令是斷開的。 successor = opcode != Opcodes.GOTO; first = false; } @Override public void visitInsn(final int opcode) { switch (opcode) { case Opcodes.RET: throw new AssertionError("Subroutines not supported."); case Opcodes.IRETURN: case Opcodes.LRETURN: case Opcodes.FRETURN: case Opcodes.DRETURN: case Opcodes.ARETURN: case Opcodes.RETURN: case Opcodes.ATHROW: successor = false; //return或者throw,表示兩條指令是斷開的 break; default: successor = true; //普通指令的話,表示前後兩條指令是連續的 break; } first = false; } @Override public void visitLabel(final Label label) { if (first) { LabelInfo.setTarget(label); } if (successor) {//這裡設定當前指令是不是上一條指令的繼任者, //原始碼中,只有這一個地方地方會觸發這個條件賦值,也就是訪問每個label的第一條指令。 LabelInfo.setSuccessor(label); } }
再看一下methodInvocationLine屬性,當ASM訪問到visitMethodInsn方法的時候,就標記當前Lable代表呼叫一個方法,將methodInvocationLine賦值為True
@Override public void visitLineNumber(final int line, final Label start) { lineStart = start; } @Override public void visitMethodInsn(final int opcode, final String owner, final String name, final String desc, final boolean itf) { successor = true; first = false; markMethodInvocationLine(); } private void markMethodInvocationLine() { if (lineStart != null) { //lineStart就是當前這個Lable LabelInfo.setMethodInvocationLine(lineStart); } } LabelInfo.java類 public static void setMethodInvocationLine(final Label label) { create(label).methodInvocationLine = true; }
再看一下multiTarget屬性,它表示當前指令是否可能從多個來源跳轉過來。原始碼在下面。
當執行到一條Jump語句時,第二個參數列示要跳轉到的Label,這時就會標記一次來源,後續分析流到了該Lable,如果它還是一條繼任者指令,那麼就將它標記為多來源指令。
public void visitJumpInsn(final int opcode, final Label label) { LabelInfo.setTarget(label);//Jump語句 將Lable標記一次為true if (opcode == Opcodes.JSR) { throw new AssertionError("Subroutines not supported."); } successor = opcode != Opcodes.GOTO; first = false; } //如果當設定它是否是上一條指令的後續指令時,再一次設定它為multiTarget=true,表示至少有2個來源 public static void setSuccessor(final Label label) { final LabelInfo info = create(label); info.successor = true; if (info.target) { info.multiTarget = true; } }
特殊問題解答
有了前面對原始碼的分析,再來看一些特殊情況。
問:else塊結尾為什麼會插入探針?
答:L3的來源有兩處,一處是GOTO來的,一處是L1順序執行來的,使得multiTarget = true條件成立,所以在L3之前插入探針,表現在Java程式碼中就是在else塊結尾增加了探針。
問:為什麼case 1條件裡第一個Test方法前不插入探針?
答:L1上一條是指GOTO指令,使得successor = false,所以該方法呼叫前無需插入探針。
探針插樁結論
通過以上分析得出結論,程式碼塊中探針的插入策略:
-
return和throw之前插入探針。
-
複雜if語句,為統計分支覆蓋情況,會進行反轉成if not,再對個分支插入探針。
-
當前指令是上一條指令的連續,並且當前指令是觸發方法呼叫,則插入探針。
-
當前指令和上一條指令是連續的,並且是有多個來源的時候,則插入探針。
構建SDK染色包
利用JaCoCo提供的Ant外掛,在原有打包指令碼上進行修改。
-
Ant指令碼根節點增加JaCoCo宣告。
-
引入jacocoant 自定義task。
-
在compile task完成之後,執行instrument任務,對原始classes檔案進行插樁,生成新的classes檔案。
-
將插樁後的classes打包成jar包,不需要混淆,就完成了染色包的構建。
<project name="Example" xmlns:jacoco="antlib:org.jacoco.ant"> //增加jacoco宣告 //引入自定義task <taskdef uri="antlib:org.jacoco.ant" resource="org/jacoco/ant/antlib.xml"> <classpath path="path_to_jacoco/lib/jacocoant.jar"/> </taskdef> ... //對classes插樁 <jacoco:instrument destdir="target/classes-instr" depends="compile"> <fileset dir="target/classes" includes="**/*.class"/> </jacoco:instrument> </project>
測試工程配置
將生成的染色包放入測試工程lib庫中,測試工程build.gradle配置中開啟覆蓋率統計開關。
官方gradle外掛預設自帶JaCoCo支援,需要開啟開關。
testCoverageEnabled = true //開啟程式碼染色覆蓋率統計
收集覆蓋率報告的方式有兩種,一種是用官方文件裡介紹的:配置jacoco-agent.properties檔案,放Demo的resources資源目錄下。
檔案配置生成覆蓋率產物的路徑,然後測試完Demo,在終止JVM也就是退出應用的時候,會自動將覆蓋率資料寫入,這種方式不方便對覆蓋率檔案命名自定義,多輪測試產物不明確。
destfile=/sdcard/jacoco/coverage.ec
另一種方式是利用反射技術:反射呼叫jacoco.agent.rt.RT類的getExecutionData方法,獲取上文中探針的執行資料,將資料寫入sdcard中,生成ec檔案。這段程式碼可以在應用合適位置觸發,推薦退出之前呼叫。
/** * 生成ec檔案 */ public static void generateEcFile(boolean isNew, Context context) { File file = new File(DEFAULT_COVERAGE_FILE_PATH); if(!file.exists()){ file.mkdir(); } DEFAULT_COVERAGE_FILE = DEFAULT_COVERAGE_FILE_PATH + File.separator+ "coverage-"+getDate()+".ec"; Log.d(TAG, "生成覆蓋率檔案: " + DEFAULT_COVERAGE_FILE); OutputStream out = null; File mCoverageFilePath = new File(DEFAULT_COVERAGE_FILE); try { if (!mCoverageFilePath.exists()) { mCoverageFilePath.createNewFile(); } out = new FileOutputStream(mCoverageFilePath.getPath(), true); 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)); Log.d(TAG,"寫入" + DEFAULT_COVERAGE_FILE + "完成!" ); Toast.makeText(context,"寫入" + DEFAULT_COVERAGE_FILE + "完成!",Toast.LENGTH_SHORT).show(); } catch (Exception e) { Log.e(TAG, "generateEcFile: " + e.getMessage()); Log.e(TAG,e.toString()); } finally { if (out == null) return; try { out.close(); } catch (IOException e) { e.printStackTrace(); } } }
覆蓋率報告生成
JaCoCo支援將多個ec檔案合併,利用Ant指令碼即可。
<jacoco:merge destfile="merged.exec"> <fileset dir="executionData" includes="*.exec"/> </jacoco:merge>
將ec檔案從手機匯出,配合插樁前的classes檔案、原始碼檔案(可選),配置Ant指令碼中,就可以生成Html格式的覆蓋率報告。
<jacoco:report> <executiondata> <file file="jacoco.exec"/> </executiondata> <structure name="Example Project"> <classfiles> <fileset dir="classes"/> </classfiles> <sourcefiles encoding="UTF-8"> <fileset dir="src"/> </sourcefiles> </structure> <html destdir="report"/> </jacoco:report>
熟悉Java位元組碼技術、ASM框架、理解JaCoCo插樁原理,可以有各種手段玩轉SDK,例如在不修改原始碼的情況下,在打包階段可以動態插入和刪除相應程式碼,完成一些特殊需求。
參考連線