在Android中使用AspectJ进行AOP切面编程

2021-04-12  本文已影响0人  wayDevelop

最近有做用户行为统计的需求,为了尽可能使统计代码不侵入业务代码,就研究了下hook和Aop。研究了下AspectJ,虽然还是不能完美解决项目中的问题,不过确实是个好东西。

编译插桩是什么

顾名思义,所谓编译插桩就是在代码编译期间修改已有的代码或者生成新代码。实际上,我们项目中经常用到的 Dagger、ButterKnife 甚至是 Kotlin 语言,它们都用到了编译插桩的技术。

理解编译插桩之前,需要先回顾一下 Android 项目中 .java 文件的编译过程



从上图可以看出,我们可以在 1、2 两处对代码进行改造。
在 .java 文件编译成 .class 文件时,APT、AndroidAnnotation 等就是在此处触发代码生成。

在 .class 文件进一步优化成 .dex 文件时,也就是直接操作字节码文件,也是本课时主要介绍的内容。这种方式功能更加强大,应用场景也更多。但是门槛比较高,需要对字节码有一定的理解。
本课时主要介绍第 1种实现方式

一般情况下,我们经常会使用编译插桩实现如下几种功能:
常见AOP编程库

在Java中,常见的面向切面编程的开源库有:
AspectJ:和Java语言无缝衔接的面向切面的编程的扩展工具(可用于Android)。
Javassist for Android:一个移植到Android平台的非常知名的操纵字节码的java库。
DexMaker:用于在Dalvik VM编译时或运行时生成代码的基于java语言的一套API。
ASMDEX:一个字节码操作库(ASM),但它处理Android可执行文件(DEX字节码)。

Android集成AspectJ ,主要有两种方式:

1,插件的方式:网上有人在github上提供了集成的插件gradle-android-aspectj-plugin。这种方式配置简单方便,但经测试无法兼容databinding框架。

2,Gradle配置的方式:配置有点麻烦,不过国外一个大牛在build文件中添加了一些脚本,虽然有点难懂,但可以在AS中使用文章出处

方式一集成

首先,新建一个AS原工程,然后再创建一个aspectJLib module(Android Library) 。
项目根目录build文件添加
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.0'

项目 aspectJLib module里面 的build文件里面添加

apply plugin: 'com.android.library'
apply plugin: 'android-aspectjx'  //添加代码
android {
  def version = rootProject.ext
  compileSdkVersion version.compileSdkVersion
  defaultConfig {
    minSdkVersion version.minSdkVersion
    targetSdkVersion version.targetSdkVersion
    versionCode version.versionCode
    versionName version.versionName
  }
}

dependencies {
  compile 'org.aspectj:aspectjrt:1.8.7'//添加代码
}

然后在使用的app module 里面的build 文件添加插件
apply plugin: 'android-aspectjx'

方式二集成

参考文章
主要是编写build脚本,添加任务,使得IDE使用ajc作为编译器编译代码,然后把该Module添加至主工程Module中。

项目根build目录添加
classpath 'org.aspectj:aspectjtools:1.8.1'
项目 aspectJLib module里面 的build文件里面添加

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


apply plugin: 'android-library'




android {
  compileSdkVersion 19
  buildToolsVersion '19.1.0'

  lintOptions {
    abortOnError false
  }
}
dependencies {
  compile 'org.aspectj:aspectjrt:1.8.1'
}

//编写build脚本,添加任务
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;
      }
    }
  }
}

然后在主build.gradle(Module:app)中添加也要添加AspectJ依赖,同时编写build脚本,添加任务,目的就是为了建立两者的通信,使得IDE使用ajc编译代码。

apply plugin: 'com.android.application'import org.aspectj.bridge.IMessageimport org.aspectj.bridge.MessageHandlerimport org.aspectj.tools.ajc.Main

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.aspectj:aspectjtools:1.8.1'    }
}
repositories {
    mavenCentral()
}

android {


    compileSdkVersion 21    buildToolsVersion '21.1.2'
    defaultConfig {
        applicationId 'com.example.myaspectjapplication'        minSdkVersion 15        targetSdkVersion 21    }

    lintOptions {
        abortOnError true    }
}
final def log = project.loggerfinal def variants = project.android.applicationVariants

variants.all { variant ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }

    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", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        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:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}
dependencies {
 compile fileTree(include: ['*.jar'], dir: 'libs')
 compile project(':aspectJLib')
 compile 'org.aspectj:aspectjrt:1.8.1'
}

需要注意的是,由于不同版本的gradle在获取编译时获取类的路径等信息Api不同,所以以上groovy配置语句仅在Gradle Version高于3.3的版本上生效。

开始使用 本例子是按照方式一集成的

AspectJ 的两种用法
(1)用自定义注解修饰切入点,精确控制切入点,属于侵入式;
(2)不需要在切入点代码中做任何修改,属于非侵入式。

侵入式

新增注解

@Retention(RetentionPolicy.CLASS)
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
public @interface DebugTrace {
}

新建一个Java类Aspect

打上@Aspect注解,则该类可以被ajc编译器识别为一个Asepct,在工程项目编译时便能非常方便地实现代码织入。看到AspectJ的三个要素,Join Point、Advice和Aspect。好像少了Join Point?Join Point早已定义在Pointcut的字符串常量中(即execution),即MainActivity的onCreate方法。Pointcut以注解的形式定义,注解了timeWatch方法,从而timeWatch就是这个Pointcut的名称,注解参数则使用定义好的字符串常量,作为Join Point的过滤规则。同样,Advice也是将类型关键字(此处为Around)注解在特定的方法saveJoinPoint之上,注解的参数为具名的Pointcut,即timeWatch。上文提到Around类型即用该方法替换原Join Point的实现,Object result = joinPoint.proceed()等价于原有的被Hook方法,即MainActivity的onCreate()。在该语句的前后,是性能统计的代码片段。


/**
 * Aspect representing the cross cutting-concern: Method and Constructor Tracing.
 * 侵入式的编译注解
 * 侵入式用法,一般会使用自定义注解,以此作为选择切入点的 Pointcut 规则。
 */


@Aspect
public class TraceAspect {
    /**
     * 针对所有继承 Activity 类的 onCreate 方法
     */
    @Pointcut("execution(* android.app.Activity+.onCreate(..))")
    public void activityOnCreatePointcut() {

    }


    //被"org.android10.gintonic.annotation.DebugTrace"标记的方法。
    //针对带有DebugTrace注解的方法
    @Pointcut("execution(@org.android10.gintonic.annotation.DebugTrace * *(..))")
    public void methodAnnotatedWithDebugTrace() {


    }

    //被"org.android10.gintonic.annotation.DebugTrace"标记的构造器。
    @Pointcut("execution(@org.android10.gintonic.annotation.DebugTrace *.new(..))")
    public void constructorAnnotatedDebugTrace() {

    }

    /**
     * 我们定义的 "weaveJointPoint(ProceedingJoinPoint joinPoint)"
     * 这个方法被添加了"@Around"注解,这意味着我们的代码注入将发生在被
     * "@DebugTrace"注解标记的方法前后。
     * 在用DebugTrace注解修饰的方法或者构造函数里面注入如下代码。
     */
    @Around("methodAnnotatedWithDebugTrace() || constructorAnnotatedDebugTrace() || activityOnCreatePointcut()")
    public Object saveJoinPoint(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();
        Log.d("TAG", 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();
    }
}

添加注解检测运行时间

   @DebugTrace
    private void testAnnotatedMethod() {
        SystemClock.sleep(100);
    }

  @DebugTrace
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_relative_layout_test);
  }

此时运行代码 便可以看到已经打印了时间

D/TAG: MainActivity--Gintonic --> testAnnotatedMethod --> [100ms]
D/TAG: MainActivity--Gintonic --> onCreate --> [187ms]

源代码与反编译后的代码
反编译项目生成的apk后可以看到,ajc在Join Point处织入了代码,用TimeWatchAspect.aspectOf().saveJoinPoint()实现了替换。

//源代码
  @DebugTrace
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_relative_layout_test);
  }
//反编译后的代码

  protected void onCreate(Bundle paramBundle)
  {
    JoinPoint localJoinPoint = Factory.makeJP(ajc$tjp_0, this, this, paramBundle);
    TimeWatchAspect.aspectOf().saveJoinPoint(new MainActivity.AjcClosure1(new Object[] { this, paramBundle, localJoinPoint }).linkClosureAndJoinPoint(69648));
  }


  public class MainActivity$AjcClosure1 extends AroundClosure
{
  public MainActivity$AjcClosure1(Object[] paramArrayOfObject)
  {
    super(paramArrayOfObject);
  }

  public Object run(Object[] paramArrayOfObject)
  {
    Object[] arrayOfObject = this.state;
    MainActivity.onCreate_aroundBody0((MainActivity)arrayOfObject[0], (Bundle)arrayOfObject[1], (JoinPoint)arrayOfObject[2]);
    return null;
  }
}

非侵入式

检测View 的点击花事件


import android.util.Log;
import android.view.View;

import org.android10.gintonic.TrackPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;

/**
 * @author wangwei
 * @date 2021/4/12.
 * 非侵入式  检测onClick
 * 非侵入式,就是不需要使用额外的注解来修饰切入点,不用修改切入点的代码。
 */

@Aspect
public class ViewAspect {

    @Pointcut("execution(void android.view.View.OnClickListener.onClick(..))")
    public void onClickPointcut() {
    }


    @Around("onClickPointcut()")
    public void aroundJoinClickPoint(final ProceedingJoinPoint joinPoint) throws Throwable {
        Object target = joinPoint.getTarget();
        String className = "";
        if (target != null) {
            className = target.getClass().getName();
        }
        //获取点击事件view对象及名称,可以对不同按钮的点击事件进行统计
        Object[] args = joinPoint.getArgs();
        if (args.length >= 1 && args[0] instanceof View) {
            View view = (View) args[0];
            int id = view.getId();
            String entryName = view.getResources().getResourceEntryName(id);

          //获取点击事件对不同按钮的点击事件进行统计
            TrackPoint.onClick(className, entryName);
        }
        joinPoint.proceed();//执行原来的代码
    }


}

在Activity的所有生命周期的方法中打印log

    /**
     *
     * @param joinPoint
     * @throws Throwable
     */
    @Before("execution(* android.app.Activity.**(..))")
    public void method(JoinPoint joinPoint) throws Throwable {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        String className = joinPoint.getThis().getClass().getSimpleName();
        Log.e("TAG", "class:" + className + " method:" + methodSignature.getName());
    }

点击一个按钮可以看到打印日志
onClick: org.android10.viewgroupperformance.activity.MainActivity$1-btnRelativeLayout

原理及其重点

AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。如果说,OOP如果是把问题划分到单个模块的话,那么AOP就是把涉及到众多模块的某一类问题进行统一管理。比如我们可以设计两个Aspects,一个是管理某个软件中所有模块的日志输出的功能,另外一个是管理该软件中一些特殊函数调用的权限检查。

非侵入式监控 可以在不修监控目标的情况下监控其运行截获某类方法甚至可以修改其参数和运行轨迹

基本原理

横向的切割某一类方法和属性,我们不需要显式的修改就可以向代码中添加可执行的代码块
它在编译期将开发者编写的Aspect程序编织到目标程序(PointCut定义的位置)中,对目标程序作了重构,以达到非侵入代码监控的目的

编写Aspect声明Aspect、PointCut和Advise。
ajc编织 AspectJ编译器在编译期间对所切点所在的目标类进行了重构在编译层将AspectJ程序与目标程序进行双向关联生成新的目标字节码即将AspectJ的切点和其余辅助的信息类段插入目标方法和目标类中同时也传回了目标类以及其实例引用。这样便能够在AspectJ程序里对目标程序进行监听甚至操控。

AspectJ概念

AspectJ向Java引入了一个新的概念:join point,它包括几个新的结构: pointcuts,advice,inter-type declarations 和 aspects。

一些概念详解:

注意 @Around 环绕通知 里面是ProceedingJoinPoint 可用proceed()调用自身方法

Weaving:织入,就是通过动态代理,在目标对象方法中执行处理内容的过程。
网络上有张图,我觉得非常传神,贴在这里供大家观详:


execution表达式

我们使用最多的就是execution表示了,下面就从execution表达式开始介绍吧。

开发中常用到的pointCut 解释:更多使用方式可參考底部链接 Pointcut语法详解

返回值为void的点击事件  参数为任意类型
@Pointcut("execution(void android.view.View.OnClickListener.onClick(..))")

androidx.appcompat.app.AppCompatActivity包下AppCompatActivity类及子类型的任何方法  参数为任意类型
@Pointcut("execution(* androidx.appcompat.app.AppCompatActivity+.*(..))")

 任何持有com.aspectj.lib.annotation.DebugTrace注解的方法
 @Pointcut("execution(@com.aspectj.lib.annotation.DebugTrace * *(..))")

定义任何一个以"set"开始的方法的执行
@Pointcut(execution(* set*(..)) )

定义AccountService 的任意方法的执行
@Pointcut(execution(* com.xyz.service.AccountService.*(..))) 

定义在service包里的任意类名的任意方法的执行
@Pointcut(execution(* com.xyz.service.*.*(..))) 

定义在service包和所有子包里的任意类的任意方法的执行
@Pointcut(execution(* com.xyz.service ..*.*(..))) 

在MainActivity 且自带有一个String 参数的方法
@Pointcut( "execution(* com.aspectj.example.activity.MainActivity.*(..)) && args(java.lang.String)) ";
   

execution(
   modifier-pattern?  修饰符部分 例如 public private...
   ret-type-pattern  返回值部分 例如 return String;
   declaring-type-pattern?  描述包名 例如 cn.evchar....
   name-pattern(param-pattern)  描述方法名,描述方法参数
   throws-pattern?  匹配抛出的异常
)
修饰符是可以省略的 ,返回值类型就是普通的函数的返回值类型。如果不限定类型的话就用*通配符表示  
说明:最靠近(..)的为方法名,靠近.∗(..))的为类名或者接口名

如:
例如定义切入点表达式 execution(* com.sample.service.impl..*.*(..))
execution()是最常用的切点函数,其语法如下所示:

 整个表达式可以分为五个部分:
 1、execution(): 表达式主体。
 2、第一个*号:表示返回类型,*号表示所有的类型。
 3、包名:表示需要拦截的包名,后面的两个句点表示当前包和当前包的所有子包,com.sample.service.impl包、子孙包下所有类的方法。
 4、第二个*号:表示类名,*号表示所有的类。
 5、*(..):最后这个星号表示方法名,*号表示所有的方法,后面括弧里面表示方法的参数,两个句点表示任何参数。


通配符意思:
   ..*  :表示包、子孙包下的所有类
   .*  :表示包下的所有类
   *  :匹配任何数量字符;
   ..  :匹配任何数量字符的重复,如在类型模式中匹配任何数量子包;而在方法参数模式中匹配任何数量参数。
   +  :匹配指定类型的子类型;仅能作为后缀放在类型模式后边。

 比如:
     java.*.Date:可以表示java.sql.Date,也可以表示java.util.Date
     Test*:可以表示TestBase,也可以表示TestDervied
     java..*:表示java任意子类
     java..*Model+:表示Java任意package中名字以Model结尾的子类,比如TabelModel,TreeModel等

函数的参数,参数匹配比较简单,主要是参数类型,比如:
     (int, char):表示参数只有两个,并且第一个参数类型是int,第二个参数类型是char
     (String, ..):表示至少有一个参数。并且第一个参数类型是String,后面参数类型不限。在参数匹配中,
     (..) 代表任意参数个数和类型
     (Object ...):表示不定个数的参数,且类型都是Object,这里的...不是通配符,而是Java中代表不定参数的意思

Spring AOP支持的AspectJ切入点指示符如下:由下列方式来定义或者通过 &&、 ||、 !、 的方式进行组合: 如:@Around(value= "methodPointcut2() && (args(request, ..) || args(.., request))")

("execution(* com.zx.aop1.Person.eat())") 精确地匹配到Person类里的eat()方法

//对于Call来说:
Call(Before)
Pointcut{
    Pointcut Method
}
Call(After)
//对于Execution来说:
Pointcut{
  execution(Before)
    Pointcut Method
  execution(After)
}

1、target指代的是切点方法的所有者,而this指代的是被织入代码所属类的实例对象。
2、如果当前要代理的类没有实现某个接口就用 this;如果实现了某个接口,就使用 target
target() 与 this() 很容易混淆,target() 是指 Pointcut 选取的 Join Point 的所有者;this() 是指 Pointcut 选取的 Join Point 的调用的所有者。简单地说就是,PointcutA 选取的是 methodA,那么 target 就是 methodA() 这个方法的对象,而 this 就是 methodA 被调用时所在类的对象

AspectJ切入点支持的切入点指示符还有: call、get、set、preinitialization、staticinitialization、initialization、handler、adviceexecution、withincode、cflow、cflowbelow、if、@this、@withincode;但Spring AOP目前不支持这些指示符,使用这些指示符将抛出IllegalArgumentException异常。这些指示符Spring AOP可能会在以后进行扩展。

踩过的坑

由于AspectJ在字节码层面将功能性代码织入业务代码中,源码层面无法看到变化,且无法在功能性代码中进行断点调试。所以一旦出错,调试成本相对较高。如果项目运行结果与预期不符,首先检查编译问题,能否正常实现代码织入(可以看apk中的class文件树结构),再检查Join Point、Pointcut和Advice是否符合AspectJ语法,Hook是否正确。

如果Android Studio中的Instant Run开启,则在编译时可能会影响代码的正常织入,所以建议关闭Instant Run。

另外,一般初级阶段会选择日志打印的方式验证AspectJ接入的可行性。如果测试机是魅族系列手机,则注意把项目中Log等级提升到D以上,或者在手机的开发者选项中选择显示所有等级的日志,否则默认情况下你看不到D及D以下等级日志的输出(惨痛的教训,浪费了两天时间排查问题)。
每次改变aspect代码需要clean项目

Execution failed for task ':app:transformClassesWithDexBuilderForDebug'.
> com.android.build.api.transform.TransformException: java.util.zip.ZipException: zip file is empty

对应版本:
aspectjx:2.0.4
androidstudio3.2.1
android tools 3.2.1
gradle4.6
导致原因:
新写的Pointcut有问题
缓存问题

解决方法:
修改用问题的Pointcut
清除app内的build文件
清除C:\Users\用户名.AndroidStudio3.2.1\system\caches中的内容

参考文章
AOP原理1
Pointcut语法详解
Pointcut 切面函数的过滤规则
深入理解Android之AOP

Android Studio 中自定义 Gradle 插件
看AspectJ在Android中的强势插入
Aspect Oriented Programming in Android
AspectJX与第三方库冲突的解决方案

上一篇下一篇

猜你喜欢

热点阅读