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
回撥裡判斷name
、desc
和signature
和原始方法一致,並且該類實現的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檔案讀取,插樁的統一處理等。另外考慮到實現的複雜度和對效能的消耗,無埋點並不能完全代替手工埋點,部分埋點資訊仍然需要手工補全。
成果
通過上述方案,我們實現了無埋點監控。...