Categories
程式開發

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


導讀

高德地圖開放平台產品不斷迭代,代碼邏輯越來越複雜,現有的測試流程不能保證完全覆蓋所有業務代碼,測試不到的代碼及分支,會存在一定的風險。為了保證測試全面覆蓋,需要引入代碼覆蓋率做為測試指標,需要對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端代碼染色原理及技術實踐 15

示例代碼

Android端代碼染色原理及技術實踐 16

有條件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端代碼染色原理及技術實踐 17

Android端代碼染色原理及技術實踐 18

代碼塊的執行情況

理論上只要在每行代碼前都插入探針即可, 但這樣會有性能問題。 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端代碼染色原理及技術實踐 19

問:為什麼case 1條件裡第一個Test方法前不插入探針?

答:L1上一條是指GOTO指令,使得successor = false,所以該方法調用前無需插入探針。

Android端代碼染色原理及技術實踐 20

探針插樁結論

通過以上分析得出結論,代碼塊中探針的插入策略:

  • return和throw之前插入探針。
  • 複雜if語句,為統計分支覆蓋情況,會進行反轉成if not,再對個分支插入探針。
  • 當前指令是上一條指令的連續,並且當前指令是觸發方法調用,則插入探針。
  • 當前指令和上一條指令是連續的,並且是有多個來源的時候,則插入探針。

構建SDK染色包

利用JaCoCo提供的Ant插件,在原有打包腳本上進行修改。

  • Ant腳本根節點增加JaCoCo聲明。
  • 引入jacocoant 自定義task。
  • 在compile task完成之後,運行instrument任務,對原始classes文件進行插樁,生成新的classes文件。
  • 將插樁後的classes打包成jar包,不需要混淆,就完成了染色包的構建。
 //增加jacoco声明
    //引入自定义task  
     
        
    

    ...
    //对classes插桩
    
      
    


測試工程配置

將生成的染色包放入測試工程lib庫中,測試工程build.gradle配置中開啟覆蓋率統計開關。

官方gradle插件默認自帶JaCoCo支持,需要開啟開關。

testCoverageEnabled = true //开启代码染色覆盖率统计

收集覆蓋率報告的方式有兩種,一種是用官方文檔裡介紹的:配置jacoco-agent.properties文件,放Demo的resources資源目錄下。

Android端代碼染色原理及技術實踐 21

文件配置生成覆蓋率產物的路徑,然後測試完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腳本即可。


    

將ec文件從手機導出,配合插樁前的classes文件、源碼文件(可選),配置Ant腳本中,就可以生成Html格式的覆蓋率報告。



    
        
    

    
        
            
        
        
            
        
    

    


熟悉Java字節碼技術、ASM框架、理解JaCoCo插樁原理,可以有各種手段玩轉SDK,例如在不修改源碼的情況下,在打包階段可以動態插入和刪除相應代碼,完成一些特殊需求。

參考連接

https://www.jacoco.org/jacoco/trunk/doc/index.html

本文轉載自公眾號高德技術(ID:amap_tech)。

原文鏈接

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