Android AOP之位元組碼插樁

陶菜菜89發表於2017-04-11

title: Android AOP之位元組碼插樁
author: 陶超
description: 實現資料收集SDK時,為了實現非侵入的,全量的資料採集,採用了AOP的思想,探索和實現了一種Android上AOP的方式。本文基於資料收集SDK的AOP實現總結而成。
categories: Android
date: 2017/02/11
tags:

  • Android AOP
  • 位元組碼
  • java
  • bytecode
  • 資料收集

背景

  本篇文章基於《網易樂得無埋點資料收集SDK》總結而成,關於網易樂得無埋點資料採集SDK的功能介紹以及技術總結後續會有文章進行闡述,本篇單講SDK中用到的Android端AOP的實現。

  隨著流量紅利時代過去,精細化運營時代的開始,網易樂得開始構建自己的大資料平臺。其中,客戶端資料採集是第一步。傳統收集資料的方式是埋點,這種方式依賴開發,採集時效慢,資料採集程式碼與業務程式碼不解藕。

  為了實現非侵入的,全量的資料採集,AOP成了關鍵,資料收集SDK探索和實現了一種Android上AOP的方式。

目錄

一、Android AOP

1.1 什麼是AOP

  面向切向程式設計(Aspect Oriented Programming),相對於物件導向程式設計(ObjectOriented Programming)而言。
  OOP的精髓是把功能或問題模組化,每個模組處理自己的家務事。但在現實世界中,並不是所有問題都能完美得劃分到模組中,有些功能是橫跨並嵌入眾多模組裡的,比如下圖所示的例子。

Android AOP之位元組碼插樁
圖1-1 AOP概念說明示例

  上圖是一個APP模組結構示例,按照照OOP的思想劃分為“檢視互動”,“業務邏輯”,“網路”等三個模組,而現在假設想要對所有模組的每個方法耗時(效能監控模組)進行統計。這個效能監控模組的功能就是需要橫跨並嵌入眾多模組裡的,這就是典型的AOP的應用場景。

  AOP的目標是把這些橫跨並嵌入眾多模組裡的功能(如監控每個方法的效能) 集中起來,放到一個統一的地方來控制和管理。如果說,OOP如果是把問題劃分到單個模組的話,那麼AOP就是把涉及到眾多模組的某一類問題進行統一管理。

  我們在開發無埋點資料收集是同樣也遇到了很多需要橫跨並嵌入眾多模組裡的場景,這些場景將在第二章(AOP應用情景)進行介紹。下面我們調研下Android AOP的實現方式。

1.2 Android AOP方式概述

  AOP從實現原理上可以分為執行時AOP和編譯時AOP,對於Android來講執行時AOP的實現主要是hook某些關鍵方法,編譯時AOP主要是在Apk打包過程中對class檔案的位元組碼進行掃描更改。Android主流的aop 框架有:

  • Dexposed,Xposed等(執行時)
  • aspactJ(編譯時)

  除此之外,還有一些非框架的但是能幫助我們實現 AOP的工具類庫:

  • java的動態代理機制(對java介面有效)
  • ASM,javassit等位元組碼操作類庫
  • (偏方)DexMaker:Dalvik 虛擬機器上,在編譯期或者執行時生成程式碼的 Java API。
  • (偏方)ASMDEX(一個類似 ASM 的位元組碼操作庫,執行在Android平臺,操作Dex位元組碼)

1.3 Android AOP方式對比選擇

  Dexposed,Xposed的缺陷很明顯,xposed需要root許可權,Dexposed只對部分系統版本有效。
  與之相比aspactJ沒有這些缺點,但是aspactJ作為一個AOP的框架來講對於我們來講太重了,不僅方法數大增,而且還有一堆aspactJ的依賴要引入專案中(這些程式碼定義了aspactJ框架諸如切點等概念)。更重要的是我們的目標僅僅是按照一些簡單的切點(使用者點選等)收集資料,而不是將整個專案開發從OOP過渡到AOP。
  AspactJ對於我們想要實現的資料收集需求太重了,但是這種編譯期操作class檔案位元組碼實現AOP的方式對我們來說是合適的。
  因此我們實現Android上AOP的方式確定為:

  • 採用編譯時的位元組碼操作的做法
  • 自己hook Android編譯打包流程並藉助ASM庫對專案位元組碼檔案進行統一掃描,過濾以及修改。

  在具體講解實現技術之前,先看一下無埋點資料收集需求遇到的三個需要AOP的場景。

二、AOP應用情景

  下面舉出資料收集SDK通過修改位元組碼進行AOP的三個應用情景,其中情景一和二的位元組碼修改是方法級別的,情景三的位元組碼修改是指令級別的。

2.1 Fragment生命週期

說明

  收集頁面資料時發現有些fragment是希望當作頁面來看待,並且計算pv的(如首頁用fragmen實現的tab)。而fragment的頁面顯示/隱藏事件需要根據:

onResume()
onPause()
onHiddenChanged(boolean hidden)
setUserVisibleHint(boolean isVisibleToUser)複製程式碼

  這四個方法綜合得出。
  也就是說當專案中任一一個Fragment發生如上狀態變化,我們都要拿到這個時機,並上報相關頁面事件,也就是對Fragment的這幾個方法進行AOP。
  做法是:

  • 對專案中所有程式碼進行掃描,篩選出所有Fragment的子類
  • 對這些篩選出來的類的的onResumed,onPaused,onHiddenChanged,setFragmentUserVisibleHint這幾個方法的位元組碼進行修改,新增上類似回撥的邏輯
  • 這樣在專案中任何一個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);
    }
}複製程式碼

注:

  1. Monitor.onFragmentResumed等函式用於上報頁面事件
  2. @TransformedDCSDK 註解標記方法被資料收集SDK進行了位元組碼修改

2.2 使用者點選事件

說明

  點選事件是分析使用者行為的一個重要事件,Android中的點選事件回撥大多是View.OnClickListener的onClick方法(當然還有一部分是DialogInterface.OnClickListener或者重寫OnTouchEvent自己封裝的點選)。
  也就是說當專案中任一一個控制元件被點選(觸發了OnClickListener),我們都要拿到這個時機,並上報點選事件。也就是對View.OnClickListener的onClick方法進行AOP。做法是:

  • 對專案中所有程式碼進行掃描,篩選出所有實現View.OnClickListener介面的類(匿名or不匿名)
  • 對onClick方法的位元組碼進行修改,新增回撥。
  • 達到的效果就是當APP中任何一個View被點選時,我們都可以在捕捉到這個時機,並且上報相關點選事件。

示例

  假設有個實現介面的類

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)) {
           //此處代表點選發生時的業務邏輯
        }
    }
}複製程式碼

注:

  1. Monitor.onViewClick函式裡面包含上報點選事件的邏輯
  2. 可以通過Monitor.onViewClick的返回值控制原有業務邏輯是否執行,基本都是執行的,只有在特殊模式下(圈選)資料收集SDK才會忽略原有邏輯

2.3 彈窗事件

說明

  彈窗顯示/關閉事件,當然彈窗的實現可以是Dialog,PopupWindow,View甚至Activity,這裡僅以Dialog為例。
  當專案中任意一個地方彈出/關閉Dialog,我們都要拿到這個時機,即對Dialog.show/dismiss/hide這幾個方法進行AOP。做法是:

  • 對專案中所有程式碼進行掃描,篩選出所有位元組碼指令中有呼叫Dialog.show/dismiss/hide的地方
  • 位元組碼指令替換,替換成一段回撥邏輯。
  • 這樣APP中所有Dialog的顯示/關閉時,我們都可以在這時進行一些收集資料的操作。

示例

  假設專案中有一個程式碼(例如方法)塊如下,其中某處呼叫了dialog.show()

某個方法 {
    //其他程式碼
    dialog.show()
    //其他程式碼
}複製程式碼

經過掃描修改位元組碼後變為

某個方法 {
    //其他程式碼
    Monitor.showDialog(dialog)
    //其他程式碼
}複製程式碼

注:Monitor.showDialog除了呼叫dialog.show()還進行一些資料收集邏輯

三、AOP實現概述

  第二章 (AOP應用情景)簡單地列舉了AOP在三種應用情景中達到的效果,下面介紹AOP的實現,實現的大致流程如下圖所示:

Android AOP之位元組碼插樁
圖3-1 Android AOP實現流程

關鍵有以下幾點:

A、位元組碼插樁入口(圖3-1 中1,3兩個環節)。
  我們知道Android程式從Java原始碼到可執行的Apk包,中間有(但不止有)兩個環節:

  • javac:將原始檔編譯成class格式的檔案
  • dex:將class格式的檔案彙總到dex格式的檔案中

  我們要想對位元組碼進行修改,只需要在javac之後,dex之前對class檔案進行位元組碼掃描,並按照一定規則進行過濾及修改就可以了,這樣修改過後的位元組碼就會在後續的dex打包環節被打到apk中,這就是我們的插樁入口(更具體的後面還會詳述)。

B、bytecode manipulate(上圖3-1 中第二個環節),這個環節主要做:

  1. 位元組碼掃描,並按照一定規則進行過濾出哪些類的class檔案需要進行位元組碼修改
  2. 對篩選出來的類進行位元組碼修改操作

  最後B步驟修改過位元組碼的class檔案,將連同資原始檔,一起打入Apk中,得到最終可以在Android平臺可以執行的APP。

  下面分別就插樁入口和ASM位元組碼操作兩個方面進行詳述。

四、插樁入口

  如 第三章(AOP實現概述)所述,我們在Android 打包流程的javac之後,dex之前獲得位元組碼插樁入口。

4.1 Android打包流程說明

  完整的Android 打包流程如下圖所示:

Android AOP之位元組碼插樁
圖4-1 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情景。

4.2 插樁入口

  那麼我們怎麼獲得插樁入口呢?

入口一:transform api

  對於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如何使用就不詳細介紹了,

  1. 可自行檢視API

  2. 參考熱修復專案Nuwa的gradle插樁外掛(使用transfrom api實現)

入口二:hook dx.jar

  那麼對於Android Build Gradle Plugin 版本在1.5.0以下的情況呢?
  下面我們介紹一種不依賴transform api而獲得插樁入口的方法,暫且稱為 hook dx.jar吧。

提示:具體使用可以考慮綜合這兩種方式,首先檢查build環境是否支援transform api(反射檢查類com.android.build.gradle.BaseExtension是否有registerTransform這個方法即可)然後決定使用哪種方式的插樁入口。

4.3 hook dx.jar獲得插樁入口

  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

  java Instrumentation指的是可以用獨立於應用程式之外的代理(agent)程式來監測和協助執行在JVM上的應用程式。這種監測和協助包括但不限於獲取JVM執行時狀態,替換和修改類定義等。
  Instrumentation 的最大作用就是類定義的動態改變和操作。

Java 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程式中對特定方法進行位元組碼插樁的目的。

hook dx.jar獲得插樁入口的完整流程

完整流程如下圖所示:

Android AOP之位元組碼插樁
圖4-2 hook dx.jar流程圖

注: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,對專案中的每一個類進行掃描,過濾,及位元組碼修改。

五、bytecode manipulation

  在這一部分我們以第二部分描述的情景二的應用場景為例,對View.OnClickListener的onClick方法進行位元組碼修改。在實踐bytecode manipulation時需要一些關於位元組碼以及ASM的基礎知識需要了解。因此本部分組織結構如下:

  • 首先介紹一下我們用來操縱位元組碼的類庫ASM
  • 然後介紹一些關於位元組碼的基本知識
  • 最後實踐對View.OnClickListener的onClick方法進行bytecode manipulation

5.1 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 API簡介

  ASM(core api) 按照visitor模式按照class檔案結構依次訪問class檔案的每一部分,有如下幾個重要的visitor。

ClassVisitor

按照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檔案格式為:

Android AOP之位元組碼插樁
圖5-1 class檔案格式

重點看ClassVisitor的如下幾個方法:

  • visit:按照圖5-1中描述的 class檔案格式,讀出“class類名”(this_class的指向),“父類名”(super_class的指向),“實現的介面(陣列)”(interfaces的指向)等資訊
  • visitField:訪問欄位,即訪問圖5-1 class檔案格式中的“field_info”,訪問字斷的邏輯委託給另外一種visitor(FieldVisitor)
  • visitField:訪問方法,即訪問圖5-1 class檔案格式中的“method_info”,訪問方法的邏輯委託給另外一種visitor(MethodVisitor)

其他方法可參考前面推薦的ASM手冊,下面介紹一下負責訪問方法的MethodVisitor。

MethodVisitor

按以下次序訪問一個方法:

visitAnnotationDefault?
( visitAnnotation | visitParameterAnnotation | visitAttribute )* 
  ( visitCode
    ( visitTryCatchBlock | visitLabel | visitFrame | visitXxxInsn | visitLocalVariable | visitLineNumber )*
  visitMaxs )? 
visitEnd複製程式碼

注:上述出現的“*”表示出現“0+”次,“?”表示出現“0/1”次。 含義可類比正則式元字元。

下面說明幾個比較關鍵的visit方法:

  • visitCode():開始訪問方法體內的程式碼
  • visitTryCatchBlock:訪問方法的try catch block
  • visitLocalVariable:指令,訪問區域性變數表裡面的某個區域性變數(關於區域性變數表後面會有介紹)
  • visitXxxInsn:指令,表示class檔案方法體裡面的位元組碼指令(如:IADD,ICONST_0,ARETURN等等位元組碼指令),完整的位元組碼指令表可參考維基百科
  • visitLabel(Label label):如果方法體中有跳轉指令,位元組碼指令中會出現label,所謂label可以近似看成行號的標記(並不是),指示跳轉指令將要跳轉到哪裡
  • visitFrame:記錄當前棧幀(棧幀結構將在後面有介紹)狀態,用於Class檔案載入時的校驗
  • visitMaxs:指定當前方法的棧幀中,區域性變數表和運算元棧的大小。(java棧大小是javac之後就確定了的)

簡單介紹了asm庫後,由於使用ASM還需要對位元組碼有一定的瞭解,故在實踐之前再介紹一些關於位元組碼的基礎知識:

5.2 位元組碼基礎

概念

關於位元組碼,有以下概念定義比較重要:

  • 全限定名(Internal names):
    全限定名即為全類名中的“.”,換為“/”,舉例:
    類android.widget.AdapterView.OnItemClickListener的全限定名為:
    android/widget/AdapterView$OnItemClickListener複製程式碼
  • 描述符(descriptors):
    1.型別描述符,如下圖所示:

Android AOP之位元組碼插樁
圖5-2 java型別描述符

如圖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執行引擎用於執行位元組碼,如下圖

Android AOP之位元組碼插樁
圖5-3 位元組碼執行引擎棧幀結構

  如圖5-3所示,縱向來看有三個執行緒,其中每一個執行緒內部都有一個棧結構(即通常所說的“堆疊”中的虛擬機器棧),棧中的每一個元素(一幀)稱為一個棧幀(stack frame)。棧幀與我們寫的方法一一對應,每個方法的呼叫/return對應執行緒中的一個棧幀的入棧/出棧。

  方法體中各種位元組碼指令的執行都在棧幀中完成,下面介紹下棧幀中兩個比較重要的部分:

  • 區域性變數表:
    故名思義,儲存當前方法中的區域性變數,包括方法的入參。值得注意的是區域性變數表的第一個槽位存放的是this。還拿方法onGroupClick舉例:
    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
  • 運算元棧:
    位元組碼指令執行的工作臺。下面用指令iadd(int型別加)執行時運算元棧的變化進行舉例:

Android AOP之位元組碼插樁
圖5-4 執行iadd指令時運算元棧的狀態變化

例如,方法體中有語句如下:

1+1複製程式碼
  • 在執行iadd之前需要先壓兩個“1”到運算元棧(因為iadd指令需要兩個運算元,執行後產生一個運算元)
  • 從常量池中(“1”為int常量)經過兩個iconst_1後運算元棧的狀態如圖5-4中所示“運算元棧狀態1”
  • 執行iadd,將兩個“1”彈出,交給ALU相加,把結果“2”入棧,運算元棧的狀態如圖5-4中所示“運算元棧狀態2”

##

5.3 bytecode manipulation實踐


我們來實踐第二部分情景二描述的AOP,即修改所有View.OnClickListener的OnClick方法的位元組碼。流程如下圖所示:

Android AOP之位元組碼插樁
圖5-5 AOP 控制元件點選實現流程

對上圖中三個步驟的詳細說明:

步驟一:

ASM的ClassVisitor對所有類的class檔案進行掃描,在visit方法中得到當前類實現了哪些介面,判斷這些介面中是否包含全限定名為“android/view/View$OnClickListener”的介面。如果有,證明當前類是View.OnClickListener,進行步驟二,否則終止掃描;

步驟二:

ClassVisitor每掃描到一個方法時,在visitMethod中進行如下判定:

  1. 此方法的名字是否為"onClick"
  2. 此方法的描述符是否為"(Landroid/view/View;)V"

如果全部判定通過,則證明本次掃描到的方法是View.OnClickListener的onClick方法,然後將
將掃描邏輯交給MethodVisitor,進行位元組碼的修改(步驟三)。

步驟三:修改onClick方法的位元組碼

假設待修改的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編譯時確定)

具體插入的程式碼是位元組碼程式碼的前四行,邏輯比較簡單:

  1. 進入方法之後先執行Monitor.onViewClick(v)
    ALOAD 1:將index為1的區域性變數(入參v)壓入運算元棧
    INVOKESTATIC com/netease/lede/bytecode/monitor/Monitor.onViewClick (Landroid/view/View;)Z:
    呼叫方法Monitor.onViewClick(v)(消耗ALOAD 1壓入的運算元),並將返回值(true/false)壓入運算元棧
  2. 根據返回值決定跳轉
    IFEQ L0:
    如果運算元棧棧頂為0(if條件為false),則跳轉到lable L0,執行原有邏輯
    RETURN:上條指令判斷不滿足(即運算元棧棧頂為1(true)),直接返回

注:值得注意的是MAXSTACK,MAXLOCALS 兩個值在javac生成的class檔案就已經固定,即,棧記憶體大小已經確定(有別於堆記憶體可以在執行時動態申請/釋放)。

如此,經過上述三個步驟,我們完成了第二部分情景二描述的AOP實踐。

六、總結

文章寫的比較長,下面對主要的幾點進行總結:

  首先介紹了AOP的概念,已及在Android平臺的主流框架,面對無埋點資料收集的需求,這些現有的都不太合適因此需要自己動手實現,
  然後,簡單列舉了無埋點資料收集SDK中需要AOP的應用情景
  最後介紹了實現的技術細節,主要有兩點:

  1. 通過hook dx.jar的方式獲得插樁入口(可以和transfrom api配合使用)
  2. 使用ASM庫修改位元組碼,此部分簡要介紹了關於位元組碼的一些基本概念以及執行引擎,最後以View.OnClickListener為例進行了實踐。

相關文章