利用AspectJ實現Android端非侵入式埋點

Doctor明發表於2017-04-16

前言

最近在專案中遇到通過埋點對使用者行為進行收集的需求,由於專案執行在區域網,而且有一些很細化的需求,比較幾種技術方案之後,選擇了通過AspectJ進行埋點。本文主要介紹筆者對學習和使用AspectJ的總結。

AspectJ是什麼

正如物件導向程式設計是對常見問題的模組化一樣,面向切面程式設計是對橫向的同一問題進行模組化,比如在某個包下的所有類中的某一類方法中都需要解決一個相似的問題,可以通過AOP的程式設計方式對此進行模組化封裝,統一解決。關於AOP的具體解釋,可以參照維基百科。而AspectJ就是面向切面程式設計在Java中的一種具體實現。

AspectJ向Java引入了一個新的概念——join point,它包括幾個新的結構: pointcuts,advice,inter-type declarations 和 aspects。

join point是在程式流中被定義好的點。pointcut在那些點上選出特定的join point和值。advice是到達join point時被執行的程式碼。

AspectJ還具有不同型別的型別間宣告(inter-type declarations),允許程式設計師修改程式的靜態結構,即其類的成員和類之間的關係。

AspectJ中的幾個名詞術語解釋

  • Cross-cutting concerns:即使在物件導向程式設計中大多數類都是執行一個單一的、特定的功能,它們也有時候需要共享一些通用的輔助功能。比如我們想要在一個執行緒進入和退出一個方法時,在資料層和UI層加上輸出log的功能。儘管每一個類的主要功能時不同的,但是它們所需要執行的輔助功能是相似的。

  • Advice:需要被注入到.class位元組碼檔案的程式碼。通常有三種:before,after和around,分別是在目標方法執行前,執行後以及替換目的碼執行。除了注入程式碼到方法中外,更進一步的,你還可以做一些別的修改,例如新增成員變數和介面到一個類中。

  • Join point:程式中執行程式碼插入的點,例如方法呼叫時或者方法執行時。

  • Pointcut:告訴程式碼注入工具在哪裡注入特定程式碼的表示式(即需要在哪些Joint point應用特定的Advice)。它可以選擇一個這樣的點(例如,一個單一方法的執行)或者許多相似的點(例如,所有被自定義註解@DebugTrace標記的方法)。

  • Aspect: Aspect將pointcut和advice 聯絡在一起。例如,我們通過定義一個pointcut和給出一個準確的advice實現向我們的程式中新增一個列印日誌功能的aspect。

  • Weaving:向目標位置(join point)注入程式碼(advice)的過程。

上面幾個名詞間的關係的示意圖如下:

利用AspectJ實現Android端非侵入式埋點

AOP程式設計的具體使用場景

  • 日誌記錄
  • 持久化
  • 行為監測
  • 資料驗證
  • 快取
    ...

注入程式碼的時機

  • 執行時:你的程式碼對增強程式碼的需求很明確,比如,必須使用動態代理(這可以說並不是真正的程式碼注入)。

  • 載入時:當目標類被Dalvik或者ART載入的時候修改才會被執行。這是對Java位元組碼檔案或者Android的dex檔案進行的注入操作。

  • 編譯時:在打包釋出程式之前,通過向編譯過程新增額外的步驟來修改被編譯的類。

具體使用哪一種方式視使用情況而定。

幾個常用的工具和類庫

  • AspectJ:和Java語言無縫銜接的面向切面的程式設計的擴充套件工具(可用於Android)。

  • Javassist for Android:一個移植到Android平臺的非常知名的操縱位元組碼的java庫。

  • DexMaker:用於在Dalvik VM編譯時或執行時生成程式碼的基於java語言的一套API。

  • ASMDEX:一個位元組碼操作庫(ASM),但它處理Android可執行檔案(DEX位元組碼)。

為什麼選擇AspectJ

  • 非常強大

  • 易於使用

  • 支援編譯時和載入時的程式碼注入

舉個栗子

利用AspectJ實現Android端非侵入式埋點

現在有一個需求,我們需要計算一個方法的執行時間,我們想通過給這個方法加上我們自定義的註解@DebugTrace來實現這個需求,而不是在業務程式碼中很生硬地插入計算時間的程式碼。這裡我們就可以通過AspectJ來實現我們的目的。

這裡我們有兩點需要知道:

  • 註解將在我們編譯過程中的一個新步驟中被處理。

  • 必要的模板程式碼將會被生成和注入到被註解的方法中。

這個過程可以通過下面的示意圖理解:

利用AspectJ實現Android端非侵入式埋點

在這個例項中,我們將分出兩個module,一個用於業務程式碼,一個用於利用AspectJ進行程式碼注入。(這裡要說明一下,AspectJ本身是一套java library,為了讓AspectJ在Android上正確執行,我們使用了android library,因為我們必須在編譯應用程式時使用一些鉤子,只能使用android-library gradle外掛。)

建立註解
@Retention(RetentionPolicy.CLASS)
@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD })
public @interface DebugTrace {}複製程式碼
建立用於控制監聽的類
/**
 * Class representing a StopWatch for measuring time.
 */
public class StopWatch {
  private long startTime;
  private long endTime;
  private long elapsedTime;

  public StopWatch() {
    //empty
  }

  private void reset() {
    startTime = 0;
    endTime = 0;
    elapsedTime = 0;
  }

  public void start() {
    reset();
    startTime = System.nanoTime();
  }

  public void stop() {
    if (startTime != 0) {
      endTime = System.nanoTime();
      elapsedTime = endTime - startTime;
    } else {
      reset();
    }
  }

  public long getTotalTimeMillis() {
    return (elapsedTime != 0) ? TimeUnit.NANOSECONDS.toMillis(endTime - startTime) : 0;
  }
}複製程式碼
封裝一下android.util.Log
/**
 * Wrapper around {@link android.util.Log}
 */
public class DebugLog {

  private DebugLog() {}

  /**
   * Send a debug log message
   *
   * @param tag Source of a log message.
   * @param message The message you would like logged.
   */
  public static void log(String tag, String message) {
    Log.d(tag, message);
  }
}複製程式碼
關鍵的Aspect類的實現
/**
 * Aspect representing the cross cutting-concern: Method and Constructor Tracing.
 */
@Aspect
public class TraceAspect {

  private static final String POINTCUT_METHOD =
      "execution(@org.android10.gintonic.annotation.DebugTrace * *(..))";

  private static final String POINTCUT_CONSTRUCTOR =
      "execution(@org.android10.gintonic.annotation.DebugTrace *.new(..))";

  @Pointcut(POINTCUT_METHOD)
  public void methodAnnotatedWithDebugTrace() {}

  @Pointcut(POINTCUT_CONSTRUCTOR)
  public void constructorAnnotatedDebugTrace() {}

  @Around("methodAnnotatedWithDebugTrace() || constructorAnnotatedDebugTrace()")
  public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
    MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
    String className = methodSignature.getDeclaringType().getSimpleName();
    String methodName = methodSignature.getName();

    final StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    // 被註解的方法在這一行程式碼被執行
    Object result = joinPoint.proceed();
    stopWatch.stop();

    DebugLog.log(className, buildLogMessage(methodName, stopWatch.getTotalTimeMillis()));

    return result;
  }

  /**
   * Create a log message.
   *
   * @param methodName A string with the method name.
   * @param methodDuration Duration of the method in milliseconds.
   * @return A string representing message.
   */
  private static String buildLogMessage(String methodName, long methodDuration) {
    StringBuilder message = new StringBuilder();
    message.append("Gintonic --> ");
    message.append(methodName);
    message.append(" --> ");
    message.append("[");
    message.append(methodDuration);
    message.append("ms");
    message.append("]");

    return message.toString();
  }
}複製程式碼

關於上面這段程式碼這裡提兩點:

  • 我們宣告瞭兩個公共方法和兩個pointcut用於過濾所有被"org.android10.gintonic.annotation.DebugTrace"標記的方法和構造器。

  • 我們定義的 "weaveJointPoint(ProceedingJoinPoint joinPoint)" 這個方法被新增了"@Around"註解,這意味著我們的程式碼注入將發生在被"@DebugTrace"註解標記的方法前後。

下面的一張圖將有助於理解pointcut的構成:

利用AspectJ實現Android端非侵入式埋點

在build.gradle檔案中的一些必要的配置

要是AspectJ在Android上正確執行,還需要在build.gradle檔案中進行一些必要的配置,如下:

import com.android.build.gradle.LibraryPlugin
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath 'com.android.tools.build:gradle:0.12.+'
    classpath 'org.aspectj:aspectjtools:1.8.1'
  }
}

apply plugin: 'android-library'

repositories {
  mavenCentral()
}

dependencies {
  compile 'org.aspectj:aspectjrt:1.8.1'
}

android {
  compileSdkVersion 19
  buildToolsVersion '19.1.0'

  lintOptions {
    abortOnError false
  }
}

android.libraryVariants.all { variant ->
  LibraryPlugin plugin = project.plugins.getPlugin(LibraryPlugin)
  JavaCompile javaCompile = variant.javaCompile
  javaCompile.doLast {
    String[] args = ["-showWeaveInfo",
                     "-1.5",
                     "-inpath", javaCompile.destinationDir.toString(),
                     "-aspectpath", javaCompile.classpath.asPath,
                     "-d", javaCompile.destinationDir.toString(),
                     "-classpath", javaCompile.classpath.asPath,
                     "-bootclasspath", plugin.project.android.bootClasspath.join(
        File.pathSeparator)]

    MessageHandler handler = new MessageHandler(true);
    new Main().run(args, handler)

    def log = project.logger
    for (IMessage message : handler.getMessages(null, true)) {
      switch (message.getKind()) {
        case IMessage.ABORT:
        case IMessage.ERROR:
        case IMessage.FAIL:
          log.error message.message, message.thrown
          break;
        case IMessage.WARNING:
        case IMessage.INFO:
          log.info message.message, message.thrown
          break;
        case IMessage.DEBUG:
          log.debug message.message, message.thrown
          break;
      }
    }
  }
}複製程式碼
測試方法
@DebugTrace
  private void testAnnotatedMethod() {
    try {
      Thread.sleep(10);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }複製程式碼

執行結果:

Gintonic --> testAnnotatedMethod --> [10ms]複製程式碼

我們可以通過對apk檔案進行反編譯來檢視被注入後的程式碼。

總結

AOP程式設計在進行使用者行為統計是是一種非常可靠的解決方案,避免了直接在業務程式碼中進行埋點,而AOP程式設計的應用還不僅於此,它在效能監控,資料採集等方面也有著廣泛的應用,後續將繼續研究,並整理髮布。AspectJ是一個很強大的用於AOP程式設計的庫,使用AspectJ關鍵在於掌握它的pointcut的語法,這裡給一個AspectJ的官方的doc連結,需要注意的是,經過實際測試,有一些語法在Android中是無法使用的,需要在實際使用過程中進行總結。

相關文章