title: Android AOP之字節碼插樁
author: 陶超
description: 實現數據收集SDK時,爲了實現非侵入的,全量的數據採集,採用了AOP的思想,探索和實現了一種Android上AOP的方式。本文基於數據收集SDK的AOP實現總結而成。
categories: Android
date: 2017/02/11
tags:javascript
本篇文章基於《網易樂得無埋點數據收集SDK》總結而成,關於網易樂得無埋點數據採集SDK的功能介紹以及技術總結後續會有文章進行闡述,本篇單講SDK中用到的Android端AOP的實現。java
隨着流量紅利時代過去,精細化運營時代的開始,網易樂得開始構建本身的大數據平臺。其中,客戶端數據採集是第一步。傳統收集數據的方式是埋點,這種方式依賴開發,採集時效慢,數據採集代碼與業務代碼不解藕。android
爲了實現非侵入的,全量的數據採集,AOP成了關鍵,數據收集SDK探索和實現了一種Android上AOP的方式。web
面向切向編程(Aspect Oriented Programming),相對於面向對象編程(ObjectOriented Programming)而言。
OOP的精髓是把功能或問題模塊化,每一個模塊處理本身的家務事。但在現實世界中,並非全部問題都能完美得劃分到模塊中,有些功能是橫跨並嵌入衆多模塊裏的,好比下圖所示的例子。編程
上圖是一個APP模塊結構示例,按照照OOP的思想劃分爲「視圖交互」,「業務邏輯」,「網絡」等三個模塊,而如今假設想要對全部模塊的每一個方法耗時(性能監控模塊)進行統計。這個性能監控模塊的功能就是須要橫跨並嵌入衆多模塊裏的,這就是典型的AOP的應用場景。api
AOP的目標是把這些橫跨並嵌入衆多模塊裏的功能(如監控每一個方法的性能) 集中起來,放到一個統一的地方來控制和管理。若是說,OOP若是是把問題劃分到單個模塊的話,那麼AOP就是把涉及到衆多模塊的某一類問題進行統一管理。數組
咱們在開發無埋點數據收集是一樣也遇到了不少須要橫跨並嵌入衆多模塊裏的場景,這些場景將在第二章(AOP應用情景)進行介紹。下面咱們調研下Android AOP的實現方式。網絡
AOP從實現原理上能夠分爲運行時AOP和編譯時AOP,對於Android來說運行時AOP的實現主要是hook某些關鍵方法,編譯時AOP主要是在Apk打包過程當中對class文件的字節碼進行掃描更改。Android主流的aop 框架有:app
除此以外,還有一些非框架的可是能幫助咱們實現 AOP的工具類庫:框架
Dexposed,Xposed的缺陷很明顯,xposed須要root權限,Dexposed只對部分系統版本有效。
與之相比aspactJ沒有這些缺點,可是aspactJ做爲一個AOP的框架來說對於咱們來說過重了,不只方法數大增,並且還有一堆aspactJ的依賴要引入項目中(這些代碼定義了aspactJ框架諸如切點等概念)。更重要的是咱們的目標僅僅是按照一些簡單的切點(用戶點擊等)收集數據,而不是將整個項目開發從OOP過渡到AOP。
AspactJ對於咱們想要實現的數據收集需求過重了,可是這種編譯期操做class文件字節碼實現AOP的方式對咱們來講是合適的。
所以咱們實現Android上AOP的方式肯定爲:
在具體講解實現技術以前,先看一下無埋點數據收集需求遇到的三個須要AOP的場景。
下面舉出數據收集SDK經過修改字節碼進行AOP的三個應用情景,其中情景一和二的字節碼修改是方法級別的,情景三的字節碼修改是指令級別的。
收集頁面數據時發現有些fragment是但願看成頁面來看待,而且計算pv的(如首頁用fragmen實現的tab)。而fragment的頁面顯示/隱藏事件須要根據:
onResume()
onPause()
onHiddenChanged(boolean hidden)
setUserVisibleHint(boolean isVisibleToUser)複製代碼
這四個方法綜合得出。
也就是說當項目中任一一個Fragment發生如上狀態變化,咱們都要拿到這個時機,並上報相關頁面事件,也就是對Fragment的這幾個方法進行AOP。
作法是:
假設咱們有一個Fragment1(空類,內部什麼代碼也沒有)
public class Fragment1 extends Fragment {}複製代碼
通過掃描修改字節碼後變爲:
public class Fragment1 extends Fragment {
@TransformedDCSDK
public void onResume() {
super.onResume();
Monitor.onFragmentResumed(this);
}
@TransformedDCSDK
public void onPause() {
super.onPause();
Monitor.onFragmentPaused(this);
}
@TransformedDCSDK
public void onHiddenChanged(boolean var1) {
super.onHiddenChanged(var1);
Monitor.onFragmentHiddenChanged(this, var1);
}
@TransformedDCSDK
public void setUserVisibleHint(boolean var1) {
super.setUserVisibleHint(var1);
Monitor.setFragmentUserVisibleHint(this, var1);
}
}複製代碼
注:
點擊事件是分析用戶行爲的一個重要事件,Android中的點擊事件回調大可能是View.OnClickListener的onClick方法(固然還有一部分是DialogInterface.OnClickListener或者重寫OnTouchEvent本身封裝的點擊)。
也就是說當項目中任一一個控件被點擊(觸發了OnClickListener),咱們都要拿到這個時機,並上報點擊事件。也就是對View.OnClickListener的onClick方法進行AOP。作法是:
假設有個實現接口的類
public class MyOnClickListener implements OnClickListener {
public void onClick(View v) {
//此處表明點擊發生時的業務邏輯
}
}複製代碼
通過掃描修改字節碼後變爲:
public class MyOnClickListener implements OnClickListener {
@TransformedDCSDK
public void onClick(View v) {
if (!Monitor.onViewClick(v)) {
//此處表明點擊發生時的業務邏輯
}
}
}複製代碼
注:
彈窗顯示/關閉事件,固然彈窗的實現能夠是Dialog,PopupWindow,View甚至Activity,這裏僅以Dialog爲例。
當項目中任意一個地方彈出/關閉Dialog,咱們都要拿到這個時機,即對Dialog.show/dismiss/hide這幾個方法進行AOP。作法是:
假設項目中有一個代碼(例如方法)塊以下,其中某處調用了dialog.show()
某個方法 {
//其餘代碼
dialog.show()
//其餘代碼
}複製代碼
通過掃描修改字節碼後變爲
某個方法 {
//其餘代碼
Monitor.showDialog(dialog)
//其餘代碼
}複製代碼
注:Monitor.showDialog除了調用dialog.show()還進行一些數據收集邏輯
第二章 (AOP應用情景)簡單地列舉了AOP在三種應用情景中達到的效果,下面介紹AOP的實現,實現的大體流程以下圖所示:
關鍵有如下幾點:
A、字節碼插樁入口(圖3-1 中1,3兩個環節)。
咱們知道Android程序從Java源代碼到可執行的Apk包,中間有(但不止有)兩個環節:
咱們要想對字節碼進行修改,只須要在javac以後,dex以前對class文件進行字節碼掃描,並按照必定規則進行過濾及修改就能夠了,這樣修改事後的字節碼就會在後續的dex打包環節被打到apk中,這就是咱們的插樁入口(更具體的後面還會詳述)。
B、bytecode manipulate(上圖3-1 中第二個環節),這個環節主要作:
最後B步驟修改過字節碼的class文件,將連同資源文件,一塊兒打入Apk中,獲得最終能夠在Android平臺能夠運行的APP。
下面分別就插樁入口和ASM字節碼操做兩個方面進行詳述。
如 第三章(AOP實現概述)所述,咱們在Android 打包流程的javac以後,dex以前得到字節碼插樁入口。
完整的Android 打包流程以下圖所示:
說明:
圖4-1中「dex」節點,表示將class文件打包到dex文件的過程,其輸入包括1.項目java源文件通過javac後生成的class文件以及2.第三方依賴的class文件兩種,這些class文件都是咱們進行字節碼掃描以及修改的目標。
具體來講,進行圖4-1中dex任務是一個叫dx.jar的jar包,存在於Android SDK的sdk/build-tools/22.0.1/lib/dx.jar目錄中,經過相似 :
java dx.jar com.android.dx.command.Main --dex --num-threads=4 —-output output.jar input.jar複製代碼
的命令,進行將class文件打包爲dex文件的步驟。
從上面的演示命令能夠看出,dex任務是啓動一個java進程,執行dx.jar中com.android.dx.command.Main類(固然對於multidex的項目入口可能不是這個類,這個再說)的main()方法進行dex任務,具體完成class到dex轉化的是這個方法:
private static boolean processClass(String name,byte[] bytes) {
//內容省略
}複製代碼
方法processClass的第二個參數是一個byte[],這就是class文件的二進制數據(class文件是一種緊湊的8位字節的二進制流文件, 各個數據項按順序緊密的從前向後排列, 相鄰的項[包括字節碼指令]之間沒有間隙),咱們就是經過對這個二進制數據進行掃描,按照必定規則過濾以及字節碼修改達到第二部分所描述的AOP情景。
那麼咱們怎麼得到插樁入口呢?
對於Android Gradle Plugin 版本在1.5.0及以上的狀況,Google官方提供了transformapi用做字節碼插樁的入口。此處的Android Gradle Plugin 版本指的是build.gradle dependencies的以下配置:
compile 'com.android.tools.build:gradle:1.5.0'複製代碼
此處1.5.0即爲Android Build Gradle Plugin 版本。
關於transform api如何使用就不詳細介紹了,
可自行查看API,
參考熱修復項目Nuwa的gradle插樁插件(使用transfrom api實現)
那麼對於Android Build Gradle Plugin 版本在1.5.0如下的狀況呢?
下面咱們介紹一種不依賴transform api而得到插樁入口的方法,暫且稱爲 hook dx.jar吧。
提示:具體使用能夠考慮綜合這兩種方式,首先檢查build環境是否支持transform api(反射檢查類com.android.build.gradle.BaseExtension是否有registerTransform這個方法便可)而後決定使用哪一種方式的插樁入口。
hook dx.jar 便是在圖4-1中的dex步驟進行hook,具體來說就是hook 4.1節介紹的dx.jar中com.android.dx.command.Main.processClass方法,將這個方法的字節碼更改成:
private static boolean processClass(String name,byte[] bytes) {
bytes=掃描並修改(bytes);// Hook點
//原有邏輯省略
}複製代碼
注:這種方式得到插樁入口也可參見博客《APM之原理篇》
如何在一個標準的java進程(記得麼?dex任務是啓動一個java進程,執行dx.jar中com.android.dx.command.Main類的main()方法進行dex任務)中對特定方法進行字節碼插樁?
這就須要運用Java1.5引入的Instrumentation機制。
java Instrumentation指的是能夠用獨立於應用程序以外的代理(agent)程序來監測和協助運行在JVM上的應用程序。這種監測和協助包括但不限於獲取JVM運行時狀態,替換和修改類定義等。
Instrumentation 的最大做用就是類定義的動態改變和操做。
方式一(java 1.5+):
開發者能夠在一個普通 Java 程序(帶有 main 函數的 Java 類)運行時,經過 – javaagent 參數指定一個特定的 jar 文件(agent.jar)(包含 Instrumentation 代理)來啓動 Instrumentation 的代理程序。例如:
java -javaagent agent.jar dex.jar com.android.dx.command.Main --dex …........複製代碼
如此,則在目標main函數執行以前,執行agent jar包指定類的 premain方法 :
premain(String args, Instrumentation inst)複製代碼
方式二(java 1.6+):
VirtualMachine.loadAgent(agent.jar)
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent(jarFilePath, args);複製代碼
此時,將執行agent jar包指定類的 agentmain方法:
agentmain(String args, Instrumentation inst)複製代碼
關於上述代碼中出現的agent.jar?
這裏的agent就是一個包含一些指定信息的jar包,就像OSGI的插件jar包同樣,在jar包的META-INF/MANIFEST.MF中添加以下信息:
Manifest-Version: 1.0
Agent-Class: XXXXX
Premain-Class: XXXXX
Can-Redefine-Classes: true
Can-Retransform-Classes: true複製代碼
這個jar包就成了agent jar包,其中Agent-Class指向具備agentmain(String args, Instrumentation inst)方法的類,Premain-Class指向具備premain(String args, Instrumentation inst)的類。
關於premain(String args, Instrumentation inst)?
第二個參數,Instumentation 類有個方法
addTransformer(ClassFileTransformer transformer,boolean canRetransform)複製代碼
而一旦爲Instrumentation inst添加了ClassFileTransformer:
ClassFileTransformer c=new ClassFileTransformer()
inst.addTransformer(c,true);複製代碼
那麼之後這個jvm進程中再有任何類的加載定義,都會出發此ClassFileTransformer的transform方法
byte[] transform( ClassLoader loader,String className,Class classBeingRedefined,ProtectionDomain protectionDomain,byte[] classfileBuffer)throwsIllegalClassFormatException;複製代碼
其中,參數byte[] classfileBuffer是類的class文件數據,對它進行修改就能夠達到在一個標準的java進程中對特定方法進行字節碼插樁的目的。
完整流程以下圖所示:
注:apply plugin: 'bytecodeplugin'中的bytecodeplugin是咱們用於字節碼插樁的gradle插件
A. 經過任意方式(as界面內點擊/命令gradle build等)都會啓動圖4-2所描述的build流程。
B. 經過Java Instrumentation機制,爲得到插樁入口,對於apk build過程進行了兩處插樁(即hook),圖4-2中標紅部分:
在build進程,對ProcessBuilder.start()方法進行插樁
ProcessBuilder類是J2SE 1.5在java.lang中新添加的一個新類,此類用於建立操做系統進程,它提供一種啓動和管理進程的方法,start方法就是開始建立一個進程,對它進行插樁,使得經過下面方式啓動dx.jar進程執行dex任務時:
java dex.jar com.android.dx.command.Main --dex …........複製代碼
增長參數-javaagent agent.jar,使得dex進程也可使用Java Instrumentation機制進行字節碼插樁
在dex進程
對咱們的目標方法com.android.dx.command.Main.processClasses進行字節碼插入,從而實現打入apk的每個項目中的類都按照咱們制定的規則進行過濾及字節碼修改。
C. 圖4-2左側build進程使用Instrumentation的方式時以前敘述過的VirtualMachine.loadAgent方式(方式二),dex進程中的方式則是-javaagent agent.jar方式(方式一)。
由此,咱們得到了進行字節碼插樁的入口,下面咱們就使用ASM庫的API,對項目中的每個類進行掃描,過濾,及字節碼修改。
在這一部分咱們以第二部分描述的情景二的應用場景爲例,對View.OnClickListener的onClick方法進行字節碼修改。在實踐bytecode manipulation時須要一些關於字節碼以及ASM的基礎知識須要瞭解。所以本部分組織結構以下:
ASM是一個java字節碼操縱框架,它能被用來動態生成類或者加強既有類的功能。ASM 能夠直接產生二進制 class 文件,也能夠在類被加載入 Java 虛擬機以前動態改變類行爲。相似功能的工具庫還有javassist,BCEL等。
那麼爲何選擇ASM呢?
ASM與同類工具庫(這裏以javassist爲例)相比:
A. 較難使用,API很是底層,貼近字節碼層面,須要字節碼知識及虛擬機相關知識
B. ASM更快更高效,Javassist實現機制中包括了反射,因此更慢。下表是使用不一樣工具庫生成同一個類的耗時比較
Framework | First time | Later times |
---|---|---|
Javassist | 257 | 5.2 |
BCEL | 473 | 5.5 |
ASM | 62.4 | 1.1 |
C. ASM庫更增強大靈活,好比能夠感知細到字節碼指令層次(第二部分情景三中的場景)
總結起來,ASM雖然不太容易使用,可是功能強大效率高值得挑戰。
關於ASM庫的使用能夠參考手冊,下面對其API進行簡要介紹:
ASM(core api) 按照visitor模式按照class文件結構依次訪問class文件的每一部分,有以下幾個重要的visitor。
按照class文件格式,按次序訪問類文件每一部分,以下:
public abstract class ClassVisitor {
public ClassVisitor(int api);
public ClassVisitor(int api, ClassVisitor cv);
public void visit(int version, int access, String name,
String signature, String superName, String[] interfaces); public void visitSource(String source, String debug);
public void visitOuterClass(String owner, String name, String desc); AnnotationVisitor visitAnnotation(String desc, boolean visible); public void visitAttribute(Attribute attr);
public void visitInnerClass(String name, String outerName,
String innerName, int access);
public FieldVisitor visitField(int access, String name, String desc,
String signature, Object value);
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions); void visitEnd();
}複製代碼
與之對應的class文件格式爲:
重點看ClassVisitor的以下幾個方法:
其餘方法可參考前面推薦的ASM手冊,下面介紹一下負責訪問方法的MethodVisitor。
按如下次序訪問一個方法:
visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )*
( visitCode
( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn | visitLocalVariable | visitLineNumber )*
visitMaxs )?
visitEnd複製代碼
注:上述出現的「*」表示出現「0+」次,「?」表示出現「0/1」次。 含義可類比正則式元字符。
下面說明幾個比較關鍵的visit方法:
簡單介紹了asm庫後,因爲使用ASM還須要對字節碼有必定的瞭解,故在實踐以前再介紹一些關於字節碼的基礎知識:
關於字節碼,有如下概念定義比較重要:
類android.widget.AdapterView.OnItemClickListener的全限定名爲:
android/widget/AdapterView$OnItemClickListener複製代碼
如圖5-2所示,在class文件中類型 boolean用「Z」描述,數組用「[」描述(多維數組可疊加),那麼咱們最多見的自定義引用類型呢?「L全限定名;」.例如:
Android中的android.view.View類,描述符爲「Landroid/view/View;」
2.方法描述符的組織結構爲:
(參數類型描述符)返回值描述符複製代碼
其中無返回值void用「V」代替,舉例:
方法boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) 的描述符以下:
(Landroid/widget/ExpandableListView;Landroid/view/View;IJ)Z複製代碼
jvm執行引擎用於執行字節碼,以下圖
如圖5-3所示,縱向來看有三個線程,其中每個線程內部都有一個棧結構(即一般所說的「堆棧」中的虛擬機棧),棧中的每個元素(一幀)稱爲一個棧幀(stack frame)。棧幀與咱們寫的方法一一對應,每一個方法的調用/return對應線程中的一個棧幀的入棧/出棧。
方法體中各類字節碼指令的執行都在棧幀中完成,下面介紹下棧幀中兩個比較重要的部分:
boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id)複製代碼
剛進入此方法時,局部變量表的槽位狀態以下:Slot Number | value |
---|---|
0 | this |
1 | ExpandableListView parent |
2 | View v |
3 | int groupPosition |
4 | long id |
例如,方法體中有語句以下:
1+1複製代碼
##
對上圖中三個步驟的詳細說明:
ASM的ClassVisitor對全部類的class文件進行掃描,在visit方法中獲得當前類實現了哪些接口,判斷這些接口中是否包含全限定名爲「android/view/View$OnClickListener」的接口。若是有,證實當前類是View.OnClickListener,進行步驟二,不然終止掃描;
ClassVisitor每掃描到一個方法時,在visitMethod中進行以下斷定:
若是所有斷定經過,則證實本次掃描到的方法是View.OnClickListener的onClick方法,而後將
將掃描邏輯交給MethodVisitor,進行字節碼的修改(步驟三)。
假設待修改的onClick方法以下:
public void onClick(View v) {
System.out.println("test");//表明方法中原有的代碼(邏輯)
}複製代碼
修改以後須要變成:
public void onClick(View v) {
if(!Monitor.onViewClick(v)) {
System.out.println("test");//表明方法中原有的代碼(邏輯)
}
}複製代碼
即:
進入方法以後先執行Monitor.onViewClick(v)(裏面是數據收集邏輯),而後根據返回值決定是執行原有onClick方法內的邏輯,仍是說直接返回。下面是修改以後onClick方法的字節碼:
public onClick(Landroid/view/View;)V
ALOAD 1//插入的字節碼,將index爲1的局部變量(入參v)壓入操做數棧
INVOKESTATIC com/netease/lede/bytecode/monitor/Monitor.onViewClick (Landroid/view/View;)Z//插入的字節碼,調用方法Monitor.onViewClick(v),將返回值(true/false)壓入操做數棧
IFEQ L0//插入的字節碼,若是操做數棧棧頂爲0(if條件爲false),則跳轉到lable L0,執行原有邏輯
RETURN//插入的字節碼,上條指令判斷不知足(即操做數棧棧頂爲1(true)),直接返回
L0
LINENUMBER 11 L0
FRAME SAME
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
LDC "test"
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
L1
LINENUMBER 12 L1
RETURN
L2
LOCALVARIABLE this Lcom/netease/caipiao/datacollection/bytecode/ViewOnclickListener; L0 L2 0
LOCALVARIABLE v Landroid/view/View; L0 L2 1
MAXSTACK = 2//操做數棧最大爲2
MAXLOCALS = 2//局部變量表最大爲2複製代碼
如上圖所示,插入的字節碼主要是前面四行(圖中已經用註釋的形式作了標記),圖中的字節碼指令能夠參照下表:
字節碼指令 | 說明 | 指令入參 |
---|---|---|
ALOAD | 將引用類型的對象從局部變量表load到操做數棧 | 局部變量表index |
INVOKESTATIC | 調用類方法(即靜態方法) | 1.類全限定名 2.方法描述符 |
INVOKEVIRTUAL | 調用對象方法 | 1.類全限定名 2.方法描述符 |
IFEQ | 檢查操做數棧棧定位置是否爲0 | 跳轉Lable(棧頂爲0時跳轉) |
RETURN | 無返回值返回(操做數棧無彈棧操做) | |
IRETURN | 返回int值(操做數棧將棧頂int值彈棧) | |
GETSTATIC | 獲取類字段(靜態成員變量) | 1.類全限定名,2.字段類型描述符 |
LDC | 從常量池取int,float,String等常量到操做數棧頂 | 常量值 |
MAXSTACK | 操做數棧最大容量(javac編譯時肯定) | |
MAXLOCALS | 局部變量表最大容量(javac編譯時肯定) |
具體插入的代碼是字節碼代碼的前四行,邏輯比較簡單:
注:值得注意的是MAXSTACK,MAXLOCALS 兩個值在javac生成的class文件就已經固定,即,棧內存大小已經肯定(有別於堆內存能夠在運行時動態申請/釋放)。
如此,通過上述三個步驟,咱們完成了第二部分情景二描述的AOP實踐。
文章寫的比較長,下面對主要的幾點進行總結:
首先介紹了AOP的概念,已及在Android平臺的主流框架,面對無埋點數據收集的需求,這些現有的都不太合適所以須要本身動手實現,
而後,簡單列舉了無埋點數據收集SDK中須要AOP的應用情景
最後介紹了實現的技術細節,主要有兩點: