和我一塊兒用 ASM 實現編譯期字節碼織入

你好,我是 N0tExpectErr0r,一名熱愛技術的 Android 開發html

個人我的博客:blog.N0tExpectErr0r.cnjava

本文 Demo 地址:github.com/N0tExpectEr…android

本文已受權公衆號『郭霖』發佈:mp.weixin.qq.com/s/aBYA1mwUa…git

原由

這兩天摸魚的時候,忽然發現 Jake Wharton 大神寫的 Hugo 很是有意思,經過這個庫能夠實現對方法調用的一些相關數據進行記錄。好比它能夠經過在方法前加上 DebugLog 註解使得該方法執行時在 Logcat 中打印這個方法的入參、耗時時間、返回值等等。github

好比在代碼中加入下面這樣一個簡單的註解:web

@DebugLog
public String getName(String first, String last) {
  SystemClock.sleep(15);
  return first + " " + last;
}
複製代碼

就能夠實如今 Logcat 中打印以下的日誌:bash

V/Example: ⇢ getName(first="Jake", last="Wharton")
V/Example: ⇠ getName [16ms] = "Jake Wharton"
複製代碼

這個庫的設計思路很是有趣,經過這樣一種註解的形式能夠很方便地打印調試信息,相比直接修改代碼實現來講極大地下降了侵入性。通過查閱資料瞭解到 Hugo 是基於 AspectJ 所實現的,其核心原理就是編譯期對字節碼的插樁。恰好筆者前兩天在項目中經過 ASM 字節碼插樁實現了對 View 的點擊事件的無痕埋點,所以突發奇想,想經過 ASM 實現一個相似功能的庫。多線程

但 Hugo 僅僅提供了打印方法執行相關信息的功能,所以就開始思考是否可以基於它的思路進行一些擴展,實如今方法調用先後執行指定邏輯的功能呢?app

若是能實現這樣一個庫,那對於 Hugo 的功能,咱們就只須要在方法調用前記錄時間,在方法調用後計算時間差便可。maven

同時若是還須要一個統計應用中某個方法調用次數的功能,也只須要在方法調用時執行計數的邏輯便可。

這樣的實現好處就在於便於擴展,對方法調用的先後進行了監聽,而具體的執行邏輯能夠由使用者來本身決定。若是對這個功能的實現感興趣,就請跟着我繼續看下去吧。

基本原理

首先,咱們須要瞭解一下什麼是 ASM,ASM 是一個 Java 字節碼層面的代碼分析及修改工具,它有一套很是易用的 API,經過它能夠實現對現有 class 文件的操縱,從而實現動態生成類,或者基於現有的類進行功能擴展。

這時候可能有讀者會問了,ASM 是操縱 class 文件的,但 Apk 裏面的不都是 dex 文件麼?這不就沒辦法應用到安卓中了麼?

其實在 Android 的編譯過程當中,首先會將 java 文件編譯爲 class 文件,以後會將編譯後的 class 文件打包爲 dex 文件,咱們能夠利用 class 被打包爲 dex 前的間隙,插入 ASM 相關的邏輯對 class 文件進行操縱。

前面的思路很簡單,但該如何才能作到在 class 文件被打包前執行咱們 ASM 相關的代碼呢?

Google 在 Gradle 1.5.0 後提供了一個叫 Transform 的 API,它的出現使得第三方的 Gradle Plugin 能夠在打包 dex 以前對 class 文件進行進行一些操縱。咱們本次就是要利用 Transform API 來實現這樣一個 Gradle Plugin。

實現思路

有了前面提到的基本原理,讓咱們來思考一下具體的實現思路。

思路其實很是簡單,這就是一種典型的觀察者模式。咱們的用戶對某個方法的調用事件進行訂閱,當方法被調用時,就會通知用戶,從而執行指定的邏輯。

咱們須要一個方法調用事件的調度中心,訂閱者能夠向該調度中心訂閱某類型的方法的調用事件,每當帶有指定註解的方法有調用事件產生時,都會通知該調度中心,而後由調度中心通知對應類型的訂閱者。

這樣的話,咱們只須要在方法的調用先後,經過 ASM 織入通知調度中心的代碼便可。

Show me the code

有了思路,咱們能夠開始正式碼代碼了,這裏我創建了一個叫 Elapse 的項目。(不要問爲何,就是由於好看)

準備工做

咱們先進行一些準備工做——創建 ASM 插件的 module,清空自動生成的 gradle 代碼,將 gradle 按以下方式編寫:

apply plugin: 'groovy'

dependencies {
    implementation gradleApi()
    implementation localGroovy()

    implementation 'com.android.tools:gradle:3.1.2'
}

repositories {
    mavenCentral()
    jcenter()
    google()
}
複製代碼

同時咱們須要一個註解來標註須要被插樁的方法。咱們採用了以下的一個編譯期的註解,其含有一個 tag 參數用於表示該方法的 TAG,經過這個 TAG 咱們能夠實現針對不一樣方法的不一樣處理。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface TrackMethod {
    String tag();
}
複製代碼

以後咱們再建立一個 MethodEventManager,用於註冊及分發方法調用事件:

public class MethodEventManager {

    private static volatile MethodEventManager INSTANCE;
    private Map<String, List<MethodObserver>> mObserverMap = new HashMap<>();

    private MethodEventManager() {
    }

    public static MethodEventManager getInstance() {
        if (INSTANCE == null) {
            synchronized (MethodEventManager.class) {
                if (INSTANCE == null) {
                    INSTANCE = new MethodEventManager();
                }
            }
        }
        return INSTANCE;
    }

    public void registerMethodObserver(String tag, MethodObserver listener) {
        if (listener == null) {
            return;
        }

        List<MethodObserver> listeners = mObserverMap.get(tag);
        if (listeners == null) {
            listeners = new ArrayList<>();
        }
        listeners.add(listener);
        mObserverMap.put(tag, listeners);
    }

    public void notifyMethodEnter(String tag, String methodName) {
        List<MethodObserver> listeners = mObserverMap.get(tag);
        if (listeners == null) {
            return;
        }
        for (MethodObserver listener : listeners) {
            listener.onMethodEnter(tag, methodName);
        }
    }

    public void notifyMethodExit(String tag, String methodName) {
        List<MethodObserver> listeners = mObserverMap.get(tag);
        if (listeners == null) {
            return;
        }
        for (MethodObserver listener : listeners) {
            listener.onMethodExit(tag, methodName);
        }
    }
}
複製代碼

這裏代碼不是很複雜,主要對外暴露了三個方法:

  • registerMethodObserver:用於向其註冊某個 TAG 對應的監聽
  • notifyMethodEnter:用於通知對應 TAG 的監聽該方法調用
  • notifyMethodExit:用於通知對應 TAG 的監聽該方法退出

有了這樣一個類,咱們就只須要在代碼編輯的時候向包含註解的方法的開始與結束處織入對應的代碼就好,就像下面這樣:

public void method(String param) {
	MethodEventManager.getInstance().notifyMethodEnter(tag, methodName);
	// 原來的代碼
	MethodEventManager.getInstance().notifyMethodExit(tag, methodName);
}
複製代碼

Transform 的編寫

以後咱們創建一個繼承自 Transform 的類 ElapseTransform

public class ElapseTransform extends Transform {

    @Override
    public String getName() {
        return ElapseTransform.class.getSimpleName();
    }

    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }

    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }

    @Override
    public boolean isIncremental() {
        return false;
    }

    @Override
    public void transform(TransformInvocation transformInvocation)
            throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
    	// ...查找class文件並對其處理
    }
}
複製代碼

這裏須要咱們實現四個方法,咱們分別介紹一下:

  • getName:當前 Transform 的名稱
  • getInputTypes:Transform 要處理的數據類型,是一個 ContentType 的 Set,其中 ContentType 有下列取值:
    • DefaultContentType.CLASSES:要處理編譯後的字節碼文件(jar 包或目錄)
    • DefaultContentType.RESOURCES:要處理標準的 Java 資源
  • getScopes:Transform 的做用範圍,是一個 Scope 的 Set,其中 Scope 有如下取值:
    • PROJECT:只處理當前項目
    • SUB_PROJECTS:只處理子項目
    • PROJECT_LOCAL_DEPS:只處理當前項目的本地依賴,例如 jar, aar
    • EXTERNAL_LIBRARIES:只處理外部的依賴庫
    • PROVIDED_ONLY:只處理本地或遠程以 provided 形式引入的依賴庫
    • TESTED_CODE:只處理測試代碼
  • isIncremental:是否支持增量編譯

這裏咱們指定的 TransformManager.CONTENT_CLASS 表示處理編譯後的字節碼文件,而 TransformManager.SCOPE_FULL_PROJECT 表示做用於整個項目,它們都是 TransformManager 預置好的 Set。

當調用該 Transform 時,會調用其 transform 方法,咱們在裏面就能夠進行 class 文件的查找,而後對 class 文件進行處理:

@Override
public void transform(TransformInvocation transformInvocation)
        throws TransformException, InterruptedException, IOException {
    super.transform(transformInvocation);
    // 獲取輸入(消費型輸入,須要傳遞給下一個Transform)
    Collection<TransformInput> inputs = transformInvocation.getInputs();
    for (TransformInput input : inputs) {
        // 遍歷輸入,分別遍歷其中的jar以及directory
        for (JarInput jarInput : input.getJarInputs()) {
            // 對jar文件進行處理
            transformJar(jarInput);
        }
        for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
            // 對directory進行處理
            transformDirectory(directoryInput);
        }
    }
}
複製代碼

這裏我先經過 transformInvocation.getInputs 獲取到了輸入,這種輸入是消費型輸入,須要傳遞給下一個 Transform,其中包含了 jar 文件與 directory 文件。

而後對 inputs 進行遍歷,分別獲取其中的 jar 列表以及 directory 列表,再對其進行遍歷,分別對 jar 文件及 directory 調用了 transformJartransformDirectory 方法。

class 文件的尋找

jar

對於 jar 文件來講,咱們須要遍歷其中的 JarEntry,尋找 class 文件,對 class 文件修改後寫入一個新的臨時 jar 文件,編輯完成後再將其複製到輸出路徑中。

private void transformJar(TransformInvocation invocation, JarInput input) throws IOException {
    File tempDir = invocation.getContext().getTemporaryDir();
    String destName = input.getFile().getName();
    String hexName = DigestUtils.md5Hex(input.getFile().getAbsolutePath()).substring(0, 8);
    if (destName.endsWith(".jar")) {
        destName = destName.substring(0, destName.length() - 4);
    }
    // 獲取輸出路徑
    File dest = invocation.getOutputProvider()
            .getContentLocation(destName + "_" + hexName, input.getContentTypes(), input.getScopes(), Format.JAR);
    JarFile originJar = new JarFile(input.getFile());
    File outputJar = new File(tempDir, "temp_"+input.getFile().getName());
    JarOutputStream output = new JarOutputStream(new FileOutputStream(outputJar));
    // 遍歷原jar文件尋找class文件
    Enumeration<JarEntry> enumeration = originJar.entries();
    while (enumeration.hasMoreElements()) {
        JarEntry originEntry = enumeration.nextElement();
        InputStream inputStream = originJar.getInputStream(originEntry);
        String entryName = originEntry.getName();
        if (entryName.endsWith(".class")) {
            JarEntry destEntry = new JarEntry(entryName);
            output.putNextEntry(destEntry);
            byte[] sourceBytes = IOUtils.toByteArray(inputStream);
            // 修改class文件內容
            byte[] modifiedBytes = modifyClass(sourceBytes);
            if (modifiedBytes == null) {
                modifiedBytes = sourceBytes;
            }
            output.write(modifiedBytes);
            output.closeEntry();
        }
    }
    output.close();
    originJar.close();
    // 複製修改後jar到輸出路徑
    FileUtils.copyFile(outputJar, dest);
}
複製代碼

能夠看到,這裏主要是如下幾步:

  1. 經過 getContentLocation 方法獲取到了輸出路徑,
  2. 構建了一個臨時的輸出 jar 文件
  3. 遍歷原 jar 文件的 entry,將其中的 class 文件調用 modifyClass 進行修改,而後放入該臨時 jar 文件
  4. 將該臨時 jar 文件複製到輸出路徑。

這樣就對 jar 文件中的全部 class 文件進行了修改。

directory

對於 directory 來講,咱們對其中的文件進行了遞歸遍歷,找到 class 文件則將其修改後放入 Map 中,最後將 Map 中的元素複製到了輸出路徑下。

private void transformDirectory(TransformInvocation invocation, DirectoryInput input) throws IOException {
    File tempDir = invocation.getContext().getTemporaryDir();
    // 獲取輸出路徑
    File dest = invocation.getOutputProvider()
            .getContentLocation(input.getName(), input.getContentTypes(), input.getScopes(), Format.DIRECTORY);
    File dir = input.getFile();
    if (dir != null && dir.exists()) {
    	// 遍歷目錄尋找並處理class文件
        traverseDirectory(tempDir, dir);
        // 複製目錄
        FileUtils.copyDirectory(input.getFile(), dest);
        for (Map.Entry<String, File> entry : modifyMap.entrySet()) {
            File target = new File(dest.getAbsolutePath() + entry.getKey());
            if (target.exists()) {
                target.delete();
            }
            // 複製class文件
            FileUtils.copyFile(entry.getValue(), target);
            entry.getValue().delete();
        }
    }
}

private void handleDirectory(File tempDir, File dir) throws IOException {
    for (File file : Objects.requireNonNull(dir.listFiles())) {
        if (file.isDirectory()) {
            // 如果目錄,遞歸遍歷
            traverseDirectory(tempDir, dir);
        } else if (file.getAbsolutePath().endsWith(".class")) {
            String className = path2ClassName(file.getAbsolutePath()
                    .replace(dir.getAbsolutePath() + File.separator, ""));
            byte[] sourceBytes = IOUtils.toByteArray(new FileInputStream(file));
            // 對class文件進行處理
            byte[] modifiedBytes = modifyClass(sourceBytes);
            File modified = new File(tempDir, className.replace(".", "") + ".class");
            if (modified.exists()) {
                modified.delete();
            }
            modified.createNewFile();
            new FileOutputStream(modified).write(modifiedBytes);
            String key = file.getAbsolutePath().replace(dir.getAbsolutePath(), "");
            modifyMap.put(key, modified);
        }
    }

複製代碼

具體邏輯不是很複雜,主要就是找出 class 文件並調用 modifyClass 文件對其進行操做。若是對具體代碼感興趣的讀者能夠到 GitHub 查看源碼。

經過 ASM 織入代碼

下面就到了咱們最關鍵的地方,須要咱們經過 ASM 來對指定類進行修改了。真正對 class 進行處理的邏輯在 modifyClass 方法中。

private byte[] modifyClass(byte[] classBytes) {
    ClassReader classReader = new ClassReader(classBytes);
    ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    ClassVisitor classVisitor = new ElapseClassVisitor(classWriter);
    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
    return classWriter.toByteArray();
}
複製代碼

咱們首先須要用到 ASM 中的 ClassReader,經過它來解析一些咱們 class 文件中所包含的信息。

以後咱們須要一個 ClassWriter 類,經過它能夠實現 class 文件中字節碼的寫入。

以後,咱們自定義了一個 ElapseClassVisitor,經過 ClassReader.accept 方法使用前面的自定義 ClassVisitor 對這個 class 文件進行『拜訪』,在拜訪的過程當中,咱們就能夠插入一些邏輯從而實現對 class 文件的編輯。

其實 ClassWriter 也是 ClassVisitor 的實現類,咱們只是經過 ElapseClassVisitor 代理了 ClassWriter 而已。

因爲咱們主要是要對方法進行織入代碼,所以在該 ClassVisitor 中咱們不須要作太多的事情,只須要在 visitMethod 方法調用也就是方法被調用的時候,返回咱們本身實現的 ElapseMethodVisitor 從而實現對方法的織入便可:

這裏實際上 ElapseMethodVisitor 並非 MethodVisitor 的子類,而是 ASM 提供的一個繼承自 MethodVisitor 的類 AdviceAdapter 的子類,經過它能夠在方法的開始、結尾等地方插入本身須要的代碼。

class ElapseMethodVisitor extends AdviceAdapter {
    private final MethodVisitor methodVisitor;
    private final String methodName;
	// ...

    public ElapseMethodVisitor(MethodVisitor methodVisitor, int access, String name, String desc) {
        super(Opcodes.ASM6, methodVisitor, access, name, desc);
        this.methodVisitor = methodVisitor;
        this.methodName = name;
    }
    // ...其餘代碼
}
複製代碼

這裏咱們保存了 methodVisitormethodName,前者是爲了後期經過它來對 class 文件進行織入代碼,然後者是爲了在後期將其傳遞給 MethodEventManager 從而進行通知。

註解處理

接下來,咱們能夠經過重寫 visitAnnotation 方法來在訪問方法的註解時進行處理,從而判斷該方法是否須要織入,同時獲取註解中的 tag。

private static final String ANNOTATION_TRACK_METHOD = "Lcom/n0texpecterr0r/elapse/TrackMethod;";
private boolean needInject;
private String tag;

@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
    AnnotationVisitor annotationVisitor = super.visitAnnotation(desc, visible);
    if (desc.equals(ANNOTATION_TRACK_METHOD)) {
        needInject = true;
        return new AnnotationVisitor(Opcodes.ASM6, annotationVisitor) {
            @Override
            public void visit(String name, Object value) {
                super.visit(name, value);
                if (name.equals("tag") && value instanceof String) {
                    tag = (String) value;
                }
            }
        };
    }
    return annotationVisitor;
}
複製代碼

這裏首先判斷了註解的簽名是否與咱們須要的註解 TrackMethod 相同(具體簽名規則這裏再也不介紹,能夠自行百度,其實就是方法簽名那一套,注意裏面的分號)

若該註解是咱們所須要的註解,則將 needInject 置爲 true,同時從該註解中獲取 tag 的值,

這樣咱們在後續就只須要判斷是否 needInject 就能知道哪些方法須要被織入了。

代碼的織入

接下來咱們就能夠正式開始織入工做了,咱們能夠經過重寫 onMethodEnter 以及 onMethodExit 來監聽方法的進入及退出:

@Override
protected void onMethodEnter() {
    super.onMethodEnter();
    handleMethodEnter();
}

@Override
protected void onMethodExit(int opcode) {
    super.onMethodExit(opcode);
    handleMethodExit();
}
複製代碼

兩段代碼及其類似,只是最後調用的方法名不一樣,因此這裏僅僅以 handleMethodEnter 舉例。

在 ASM 中,經過 MethodWriter.visitMethodInsn 方法能夠調用相似字節碼的指令來調用方法。好比

visitMethodInsn(INVOKESTATIC, 類簽名, 方法名, 方法簽名);

這樣的方式就能夠調用一個類下的 static 方法。若是這個方法須要參數,咱們能夠經過 visitVarInsn 方法來調用如 ALOAD 等指令將變量入棧。整個過程實際上是與字節碼中的調用形式比較相似的。

若是隻是調用一個 static 方法還好,但咱們這裏是須要調用一個單例類下的具體方法,如

MethodEventManager.getInstance().notifyMethodEnter(tag, methodName);

這樣的代碼恐怕除了對字節碼很熟悉的人很難有人能直接想到它用字節碼如何表示了。咱們能夠經過如下的兩種方法來解決:

1. 經過 javap 查看字節碼

所以咱們能夠寫個單例的調用 Demo,以後經過 javap -v 來查看其生成的字節碼,從而瞭解到調用的字節碼大概是一個怎樣的順序:

能夠很明顯的看到,這裏先經過 INVOKESTATIC 調用了 getInstance 方法,而後經過 LDC 將兩個字符串常量放置到了棧頂,最後經過 INVOKEVIRTUAL 調用 notify 方法進行最後的調用。

那咱們能夠模仿這個過程,調用 ASM 中的對應方法來完成相似的過程,因而寫出了以下的代碼,其中 visitLdcInsn 的效果相似於字節碼中的 LDC。

private void handleMethodEnter() {
    if (needInject && tag != null) {
        methodVisitor.visitMethodInsn(INVOKESTATIC, METHOD_EVENT_MANAGER, 
                "getInstance", "()L"+METHOD_EVENT_MANAGER+";");
        methodVisitor.visitLdcInsn(tag);
        methodVisitor.visitLdcInsn(methodName);
        methodVisitor.visitMethodInsn(INVOKEVIRTUAL, METHOD_EVENT_MANAGER, 
                "notifyMethodEnter", "(Ljava/lang/String;Ljava/lang/String;)V");
    }
}
複製代碼

這樣,就能夠織入咱們想要的代碼了。

2. 經過 ASM Bytecode 插件查看

前面這種經過字節碼查看的過程確實比較麻煩,所以咱們還有另外的一種方法來簡化這個步驟,有大神寫了一個名爲 「ASM Bytecode outline」的 IDEA 插件,咱們能夠經過它直接查看對應的 ASM 代碼。

安裝該插件後,在須要查看的代碼上 點擊右鍵->Show ByteCode 便可查看對應的 ASM 代碼,效果以下:

咱們從中提煉出本身須要的代碼便可。

兩種方法各有優劣,讀者能夠根據本身的需求使用不一樣的方式實現。

經過前面的一系列步驟,這個 ASM 織入的核心功能咱們就已經實現了,若是還須要獲取函數的參數等擴展,只須要知道對應的字節碼實現,剩下的都很容易實現,這裏因爲篇幅有限就不細講了。

打包爲 Gradle 插件

接下來咱們來進行最後的一步,將這個庫打包爲一個 Gradle Plugin,咱們新建一個 ElapsePlugin 類,繼承自 Plugin<Project>,並在其中註冊咱們的 ElapseTransform

public class ElapsePlugin implements Plugin<Project> {
    @Override
    public void apply(@NotNull Project project) {
        AppExtension appExtension = project.getExtensions().findByType(AppExtension.class);
        assert appExtension != null;
        appExtension.registerTransform(new ElapseTransform(project));
    }
}
複製代碼

以後咱們在 build.gradle 中加入以下的 gradle 代碼,描述咱們 pom 的信息:

apply plugin: 'maven'

uploadArchives {
    repositories.mavenDeployer {
        repository(url: uri('../repo'))
        pom.groupId = 'com.n0texpecterr0r.build'
        pom.artifactId = 'elapse-asm'
        pom.version = '1.0.0'
    }
}
複製代碼

最後咱們在 src/main 下新建一個 resources/META-INF/gradle-plugins 文件夾,在該文件夾下創建 <插件名>.properties 文件。

在該文件中,按以下的方式填寫:

implementation-class = <Plugin所在目錄>,好比我這裏就是 implementation-class = com.n0texpecterr0r.elapseasm.ElapsePlugin

這樣,咱們就可以經過運行 uploadArchives 這個 Gradle 腳原本生成對應的 jar 包了。到此爲止,咱們的函數調用插樁的 Gradle Plugin 就開發完成了。

效果展現

咱們能夠在須要使用的項目中將其添加到 classpath 中:

repositories {
    //...
    maven {
        url uri("repo")
    }
}

dependencies {
    // ...
    classpath 'com.n0texpecterr0r.build:elapse-asm:1.0.0'
}
複製代碼

以後在 app module 下將其 apply 進來:

apply plugin: 'com.n0texpecterr0r.elapse-asm'
複製代碼

咱們能夠寫一個 Demo 測試一下效果:

public class MainActivity extends AppCompatActivity {

    private static final String TAG_TEST = "test";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        MethodEventManager.getInstance().registerMethodObserver(TAG_TEST, new MethodObserver() {
            @Override
            public void onMethodEnter(String tag, String methodName) {
                Log.d("MethodEvent", "method "+ methodName + " enter at time " + System.currentTimeMillis());
            }

            @Override
            public void onMethodExit(String tag, String methodName) {
                Log.d("MethodEvent", "method "+ methodName + " exit at time " + System.currentTimeMillis());
            }
        });
        test();
    }

    @TrackMethod(tag = TAG_TEST)
    public void test() {
        try {
            Thread.sleep(1200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製代碼

運行程序,能夠發現,Logcat 中成功打印了咱們須要的信息:

也就是說,咱們的代碼被成功到字節碼中了。讓咱們看看編譯後生成的字節碼,咱們能夠打開 elapse-demo/build/intermediates/transforms/ElapseTransform/debug/33/MainActivitiy.class

看得出來,咱們的代碼被成功地插入了字節碼中。

實現 Hugo

咱們接下來經過它來嘗試實現 Hugo 的打印方法耗時功能,能夠新建一個 TimeObserver

public class TimeObserver implements MethodObserver {
    private static final String TAG_METHOD_TIME = "MethodCost";
    private Map<String, Long> enterTimeMap = new HashMap<>();
    @Override
    public void onMethodEnter(String tag, String methodName) {
        String key = generateKey(tag, methodName);
        Long time = System.currentTimeMillis();
        enterTimeMap.put(key, time);
    }
    @Override
    public void onMethodExit(String tag, String methodName) {
        String key = generateKey(tag, methodName);
        Long enterTime = enterTimeMap.get(key);
        if (enterTime == null) {
            throw new IllegalStateException("method exit without enter");
        }
        long cost = System.currentTimeMillis() - enterTime;
        Log.d(TAG_METHOD_TIME, "method " + methodName + " cost "
                + (double)cost/1000 + "s" + " in thread " + Thread.currentThread().getName());
		enterTimeMap.remove(key);
    }
    private String generateKey(String tag, String methodName) {
        return tag + methodName + Thread.currentThread().getName();
    }
}
複製代碼

這裏咱們以 tag + methodName + currentThread.name 來做爲 key,避免了多線程下的調用致使的干擾,在方法進入時記錄下開始時間,退出時計算時間差便可獲得方法的耗時時間。

咱們在 Application 中對其進行註冊後,就能夠在運行後看到效果了:

咱們開 10 個線程,來分別運行 test ,咱們能夠看看效果:

private ExecutorService mExecutor = Executors.newFixedThreadPool(10);

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    for (int i = 0; i < 10; i++) {
        mExecutor.execute(this::test);
    }
}
複製代碼

能夠看到,仍然能夠正常統計方法的調用時間:

總結

經過 ASM + Transform API,咱們能夠很方便地在 class 被打包爲 dex 文件以前對字節碼進行編輯,從而在代碼的任意位置插入咱們須要的邏輯,本文只是一個小 Demo 的演示,從而讓讀者們可以瞭解到 ASM 的強大。經過 ASM 可以實現的功能其實更加豐富。目前在國內關於 ASM 的相關文章還比較匱乏,若是想要進一步瞭解 ASM 的功能,讀者們能夠到這裏查看 ASM 的官方文檔。

其實本文的 Demo 還有更多功能能夠擴展,好比函數參數及返回值的信息的攜帶,對整個類的方法進行插樁等等,讀者能夠根據已有知識,嘗試對這些功能進行擴展,因爲篇幅有限這裏就再也不贅述了,本質上都是插入對應的字節碼指令。

相關文章
相關標籤/搜索