1. 程式人生 > >ASM插樁實現Android端無埋點效能監控

ASM插樁實現Android端無埋點效能監控

Android端無埋點實現頁面效能監控

背景

當我們需要了解頁面載入效能時,可以通過手動埋點的方式記錄頁面階段耗時、網路耗時、資料庫載入耗時以及其他耗時點,配合slardar平臺,能直觀地瞭解到頁面的效能情況。

但隨著業務變動,手動埋點存在易寫錯,難維護的麻煩。業界廣泛使用了插樁技術來實現無埋點監控,我們也結合現有監控庫,實現了自己的無埋點監控方案。本文旨在介紹實現原理,方便大家對監控庫的使用。

功能需求

我們希望提供以下功能

  • 和業務無關的程式碼,我們希望能夠以無需手動埋點的方式進行監控,包括頁面生命週期、JSON耗時,網路耗時、SQL查詢耗時、點選事件、頁面進入等
  • 對特定方法進行耗時監控,我們希望使用者給方法加上註解就可以,稱之為半埋點
  • 編譯期,需要能夠支援配置,包括對哪些頁面、哪些操作進行監控
  • 執行期,能夠動態下發配置,包括各類耗時監控的上報開關和閾值等

思路

實現自動監控需要解決2個問題

You solve one problem… and you solve the next one… and then the next. And If you solve enough problems, you get to come home -- The Martian

1. 如何計算方法耗時

統計方法耗時是典型的面向切面程式設計(Aspect-Oriented Programming,AOP)的應用場景。實現AOP有一些成熟的技術方案

  • 靜態代理 和 執行期註解 + 動態代理
  • 編譯時程式碼生成(APT),案例:ButterKnife,Dagger2,Room
  • 切面程式設計庫(AspectJ),案例:Hugo
  • 位元組碼注入(ASM),案例:GrowingIO
  • ...

方案1: 手動代理模式實現AOP顯然不適用本場景。

方案2:  在編譯時根據Annotation生成相關的程式碼是非常便利的技術,但APT主要適合用來生成輔助類,使用者仍然需要通過手動呼叫方法使生成的程式碼在切入點執行。這一點其實不算AOP程式設計,也不適合本場景。

方案3: AspectJ[參考1]是廣泛應用於JavaEE開發的AOP方案,簡單易用,功能強大。它提供了簡便的語法讓我們定義切面邏輯,再通過提供的AJC編譯器,在Java檔案編譯成class檔案的過程裡,把切面程式碼織入到目標業務程式碼裡。本質上,仍然是以代理的方式實現AOP。我們通過AspectJ就能方便的在目標方法執行前後執行我們的計時程式碼。

方案4: 我們還可以直接對class檔案進行修改,ASM[參考2]是位元組碼操作庫,支援對位元組碼進行編輯,實現類、屬性和方法的增刪改查。位元組碼操作庫還有Javaassit庫可以選擇,但ASM靈活度和效率都是最高的。利用操作位元組碼實現方法計時,可以的做法是修改class檔案,在目標方法開始和結束時插入本來需要手動埋點的計時程式碼(稱之為位元組碼插樁)。

註解的作用是提供插入點,AspectJ和ASM既支援以註解作為切入點,也支援根據類方法名/類繼承關係等規則來確定切入點。

2. 如何整合到打包流程

Android工程的構建工具是Gradle, 構建過程由一系列Task構成。Gradle支援自定義Task加入到原有的構建流程,以實現自己的處理邏輯[參考3]。

Hugo plugin在javaCompile Task最後插入一個Action,呼叫ajc函式對class檔案進行處理,把AspectJ的能力引入到了Android打包流程,AspectJx[參考4]是參考Hugo實現的一個在Android上通用的使用AspectJ的開源庫,方案3利用這個庫使用AspectJ。

Android官方提供了Transform API支援在class檔案到dex轉換期間修改class檔案,這個階段正是ASM位元組碼操作庫工作的階段,所以,我們可以通過在自定義外掛中使用Transform的方式,把插樁過程整合到打包流程,方案4使用這個處理方式。

實現

下面分別用AspectJ方案和ASM插樁方案進行Demo實現。

AspectJ方案

AspecJ完整給出了AOP程式設計裡的一些概念:切面(Aspect)、通知(Advice)、切入點(Pointcut),這些概念通過程式碼可以很清晰的理解。

網上有較多的統計Activity生命週期耗時的例子。本文以統計JSON反序列化耗時為例。

通過new JSONObject(String jsonStr)方法可以把JSON格式的字串反序列化為JSON物件處理,我們要切入的點就是JSONObject的建構函式,需要做的處理是在建構函式執行前後插入我們的計時程式碼

@Aspect // 程式碼1
public class JsonAspect {  
    private static final String TAG = "JsonAspect";
    @Around("call(org.json.JSONObject.new(..))") // 程式碼2
    public JSONObject onJsonConstruct(ProceedingJoinPoint joinPoint) throws Throwable { // 程式碼3
        JSONObject result = null;
        long start = System.currentTimeMillis();
        result = (JSONObject) joinPoint.proceed(); // 程式碼4
        long end = System.currentTimeMillis();
        Log.d(TAG, "onJsonConstruct: " + (end - start) + (joinPoint.getArgs()[0].toString()));
        return result;
    }
}

程式碼1: 通過@Aspect註解,告知ajc編譯期這個類是一個Aspect, 我們在這個類裡定義在哪裡切入,如何切入

程式碼2: 這裡定義了一個匿名的Pointcut,@Around是一個Advice, 表示要在pointcut的前後進行插入,對應的還有before和after。@Around裡的字串定義了怎麼尋找這個pointcut, "call(org.json.JSONObject.new(..))"表示pointcut是當JSONObject的建構函式被呼叫的時候

程式碼3: 我們定義了一個方法,進行我們的邏輯處理。需要了解的是方法的引數joinPoint, joinPoint表達的是連線點物件.

程式碼4通過joinPoint.proceed()實現對原有邏輯的呼叫,我們正是在這一處前後插入我們的執行邏輯

上面的程式碼就已經實現了無埋點進行JSON反序列化耗時統計。

通過註解來統計方法耗時,可以參照Hugo的原始碼。

可以看出,AspectJ方案寫起來很簡單,非常適合做一些Android裡需要的AOP程式設計操作,比如動態許可權檢查。但AspectJ還是有一些侷限,我們統計Activity頁面生命週期耗時需要以生命週期為切點,在實際工程程式碼裡,我們最終使用的頁面Activity類一般是經過多次抽象後繼承實現的,程式碼裡已經不包含OnCreate/onResume方法了,這時候AspectJ就無能為力了。另外檢視處理後的class檔案,可以發現除了樁點程式碼外,還會增加額外的一些程式碼,對包大小限制不利。

ASM插樁方案

我們知道,class檔案是按照JVM規範格式儲存的二進位制檔案,本質上是一個表,記錄了類的常量池、訪問標誌、屬性和方法等。ASM庫不僅能夠對class檔案進行解讀,還提供了方便的API進行位元組碼的修改,支援直接產生二進位制class檔案。

ASM提供了基於事件的API,ClassReader用於讀取class檔案的二進位制流,ClassVisitor以事件的形式輸出class的結構資訊, ClassWriter則用於把修改後的位元組碼生成二進位制流。

我們先以Java工程的方式演示對Class檔案的處理,不考慮整合打包。

我們定義一個簡單的頁面MainActivity,增加一個加了編譯期註解的方法

public class MainActivity extends AppCompatActivity {

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
    @TraceTime
    public void fun(){
        Log.d("tt_apm", "annotated function");
    }
}

它的class檔案在工程的app/build/intermediates/classes目錄下,用ASM讀取分析

public static void main(String[] args) {  
    try {
        File classFile = new File("./source/MainActivity.class");
        File dir = new File(".");
        transformClassFile(dir, classFile)
    } catch (Exception e){}
}
private static File transformClassFile(File dir, File sourceFile){  
    String className = sourceFile.getName();
    // 得到class檔案二進位制流
    FileInputStream fileInputStream = new FileInputStream(sourceFile);
    byte[] sourceClassBytes = IOUtils.toByteArray(fileInputStream);
    // 定義classWriter,用於輸出修改後的二進位制流
    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    // 自定義ClassVisitor, 負責位元組碼的消費
    MyClassVisitor myClassVisitor = new MyClassVisitor(classWriter);
    // ClassReader負責位元組碼的讀取
    ClassReader classReader = new ClassReader(sourceClassBytes);
    // 開始位元組碼處理
    classReader.accept(myClassVisitor, 0);
    // 生成二進位制流並儲存成新的檔案
    byte[] destByte = classWriter.toByteArray();
    File modified = new File(dir, className)
    if (modified.exists()) {
        modified.delete()
    }
    modified.createNewFile();
    new FileOutputStream(modified).write(destByte)
    return modified;
}

private static class MyClassVisitor extends ClassVisitor {

    public MyClassVisitor(ClassVisitor classVisitor) {
        super(Opcodes.ASM6, classVisitor);
    }
    @Override
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        System.out.println("visit: access: " + access + " ,name: " + name + " , superName: " + superName + " ,singature: " + signature + ", interfaces: " + interfaces.join("/"));
            super.visit(version, access, name, signature, superName, interfaces);
    }
    @Override
    public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        System.out.println("visitMethod: access: " + access + " ,name: " + name + " , desc: " + descriptor + " ,singature: " + signature);
        MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
        MethodVisitor myMv = new MethodVisitor(Opcodes.ASM6, mv) {
            @Override
            AnnotationVisitor visitAnnotation(String desc, boolean visible) {
                System.out.println("visitAnnotation: desc: " + desc);
                return super.visitAnnotation(desc, visible)
            }
            @Override
            void visitCode() {
                super.visitCode()
            }
        }
        return myMv;
    }
}

我們用ClassReader讀取了MainActivity.java的class檔案,並用自定義的ClassVisitor接收事件。檢視輸出:

visit: access: 33 ,name: com/example/wangkai/MainActivity , superName: android/support/v7/app/AppCompatActivity ,singature: null, interfaces:  
visitMethod: access: 1 ,name: <init> , desc: ()V ,singature: null  
visitMethod: access: 4 ,name: onCreate , desc: (Landroid/os/Bundle;)V ,singature: null  
visitMethod: access: 1 ,name: fun , desc: ()V ,singature: null  
visitAnnotation: desc: Lcom/example/wangkai/annotation/TraceTime;  

我們通過visit回撥可以讀取到class的名字、父類名和介面,這樣就可以判斷出一個類是否是我們要插樁的白名單頁面,是不是Activity子類以及是否實現了點選事件介面View$onClickListener(實現對點選事件的監控)

通過visitMethod我們拿到了方法名,這樣就可以判斷這個方法是不是我們要監控的生命週期方法

通過在visitMethod方法裡返回自定義的MethodVisitor物件,我們拿到了方法上的註解,從而可以知道這個方法是否是要插樁的方法

visitCode表示方法開始執行,如果能在這裡插入程式碼,那我們的程式碼就能在原始程式碼執行前執行。

我們已經找到了切入點,下一步就是插入程式碼了。插入程式碼要難一些,因為我們是在位元組碼層面操作,插入的也只能是位元組碼,這就需要對位元組碼有一定了解。包括區域性變量表和運算元棧的概念,常見指令(ALOAD, INVOKEVIRTUAL等)的含義[參考5]。

這裡以實現監聽點選事件為例。手動埋點時,我們需要插入這樣的程式碼:

private static class MyClickListener implements View.OnClickListener{  
    @Override
    public void onClick(View v) {
        ClickAgent.click(v); //待插入程式碼,方法裡獲取view的ID和當前時間,實現對點選事件的記錄
        Log.d(TAG, "onClick: ");
     }
}

我們要做的是,通過ASM methodVisitor提供的API,把ClickAgent.click(v)的位元組碼,注入到原始onClick方法裡。

檢視位元組碼:

L0  
LINENUMBER 27 L0  
ALOAD 1  
INVOKESTATIC com/example/wangkai/ClickAgent.click (Landroid/view/View;)V  
L1  
LINENUMBER 28 L1  
LDC "MainActivity"  
LDC "onClick: "  
INVOKESTATIC android/util/Log.d (Ljava/lang/String;Ljava/lang/String;)I  

可以看到ClickAgent.click(v)對應的位元組碼是兩行

ALOAD 1表示把區域性變量表裡索引為1的值,推到運算元棧上,也就是引數值View v。對應到ASM,是methodVisitor.visitVarInsn(Opcodes.ALOAD, 1);

INVOKESTATIC com/example/wangkai/ClickAgent.click (Landroid/view/View;)V就是呼叫ClickAgent的靜態方法click。對應到ASM,是methodVisitor.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/wangkai/ClickAgent", "click", "(Landroid/view/View;)V", false)

當我們在visitorMethod回撥裡判斷namedescsignature和原始方法一致,並且該類實現的interfaces包含了View$onClickListener時,就可以注入了。

怎麼注入進去呢?這樣寫就可以了:

  @Override
  void visitCode() {
      super.visitCode()
      mv.visitVarInsn(Opcodes.ALOAD, 1);
      mv.visitMethodInsn(Opcodes.INVOKESTATIC, "com/example/wangkai/ClickAgent", "click", "(Landroid/view/View;)V", false)
 }

修改後執行,會生成插樁後的class檔案,可以用JD_GUI檢視插樁後的效果,

怎麼實現AspectJ方案裡演示的對JSONObject的反序列化監控呢?只需要將JSONObject對應的建構函式替換成我們的函式

@Override
void visitMethodInsn(int opcode, String owner, String methodName, String descriptor, boolean isInterface) {  
    if(opcode == Opcodes.INVOKESPECIAL && owner.equals("org/json/JSONObject")
        && methodName.equals("<init>") && descriptor.equals("(Ljava/lang/String;)V")) {
        super.visitMethodInsn(Opcodes.INVOKESTATIC, "com.example.apm.JSONObjectAgent",
                            "init", "(Ljava/lang/String;)Lorg/json/JSONObject;", false);
    } else {
        super.visitMethodInsn(opcode, owner, methodName, descriptor, isInterface)
    }
}

插樁的問題已經解決了!

Oh, we have solved one problem.

我們再把插樁的處理過程整合到Gradle打包裡就可以

我們知道,通過在build.gradle裡配置apply plugin: 'xxplugin',可以實現呼叫自定義的plugin。自定義plugin:

class ApmPlugin implements Plugin<Project> {  
    @Override
    void apply(Project project) {
        android = project.extensions.getByType(AppExtension);
        ApmTransform transform = new ApmTransform(project)
        android.registerTransform(transform)
    }
}

apply方法會在build.gradle apply plugin: 'xxplugin'行執行時被呼叫。我們在apply方方法裡註冊了自定義的Transform,實現對class檔案的處理。

Transform是Android gradle提供的修改class的一套API,Transform每次都是將一個輸入進行處理,然後將處理結果輸出,而輸出的結果將會作為另一個Transform的輸入。

我們在回撥裡可以拿到輸入

class ApmTransform extends Transform {  
    ...
    @Override
    void transform(
            @NonNull Context context,
            @NonNull Collection<TransformInput> inputs,
            @NonNull Collection<TransformInput> referencedInputs,
            @Nullable TransformOutputProvider outputProvider,
            boolean isIncremental) throws IOException, TransformException, InterruptedException {
    }
}

transform回撥方法裡的inputs即上一個Transform輸出的class檔案目錄,是本工程自己的程式碼檔案,referencedInputs是上一個Transform輸出的jar包,是本工程依賴的jar包。我們遍歷inputs就能拿到class檔案

input.directoryInputs.each { DirectoryInput directoryInput ->  
      File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY);
      File dir = directoryInput.file
      if (dir) {
          dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
          File classFile ->
              File modified = modifyClassFile(dir, classFile, context.getTemporaryDir()); // 修改class檔案
             ...           
        }
... 

注意到,修改class檔案這部分,我們在之前的Java工程裡已經實現了!

I love what I do and I'm good at it. GO HOME!

以上簡單介紹了無埋點插樁實現的過程。實際的外掛工程要複雜,需要考慮黑白名單處理,Manifest檔案讀取,插樁的統一處理等。另外考慮到實現的複雜度和對效能的消耗,無埋點並不能完全代替手工埋點,部分埋點資訊仍然需要手工補全。

成果

通過上述方案,我們實現了無埋點監控。...

附錄