Android端程式碼染色原理及技術實踐

高德技術發表於2020-09-15
導讀
高德地圖開放平臺產品不斷迭代,程式碼邏輯越來越複雜,現有的測試流程不能保證完全覆蓋所有業務程式碼,測試不到的程式碼及分支,會存在一定的風險。為了保證測試全面覆蓋,需要引入程式碼覆蓋率做為測試指標,需要對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指令之前插入探針。
 
官方文件中介紹
Android端程式碼染色原理及技術實踐
 
示例程式碼
Android端程式碼染色原理及技術實踐
 
有條件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;
    }

  

可以通過測試報告看出來,標記為黃色代表分支執行情況覆蓋不完整,標記為綠色代表分支所有條件都執行完整了。
Android端程式碼染色原理及技術實踐
 
Android端程式碼染色原理及技術實踐
程式碼塊的執行情況
理論上只要在每行程式碼前都插入探針即可, 但這樣會有效能問題。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塊結尾增加了探針。
Android端程式碼染色原理及技術實踐
 
問:為什麼case 1條件裡第一個Test方法前不插入探針?
答:L1上一條是指GOTO指令,使得successor = false,所以該方法呼叫前無需插入探針。
 
 
Android端程式碼染色原理及技術實踐
 
探針插樁結論
通過以上分析得出結論,程式碼塊中探針的插入策略:
  • 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資源目錄下。
Android端程式碼染色原理及技術實踐
檔案配置生成覆蓋率產物的路徑,然後測試完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,例如在不修改原始碼的情況下,在打包階段可以動態插入和刪除相應程式碼,完成一些特殊需求。
 
參考連線

相關文章