自定义gradle插件进阶

上次写过自定义gradle插件入门的博客, 介绍了最基本的gradle构建和简单的自定义gradle, 运用生命周期函数、task依赖和扩展配置等。在上篇博客的基础上, 这篇博客继续研究下自定义gradle插件。

动态编译技术在开源框架中的应用非常的广泛,现在市面上的插件化框架,热修复框架几乎都使用了动态编译技术,原理几乎都是在编译期间动态的在class文件中注入或者修改代码。

AOP技术应用

1、日志记录:业务埋点
2、持久化
3、性能监控:性能日志
4、数据校验:方法的参数校验
5、缓存:内存缓存和持久缓存
6、权限检查:业务权限(如登陆,或用户等级)、系统权限(如拍照定位)
7、异常处理

业内实现AOP的技术方案有APT,  AspectJ,  Javassist/Asm, 他们具体作用时机可以看下图描述

自定义gradle插件进阶_第1张图片

执行阶段:

APT在compile任务前,修改java文件;
AspectJ在java --> class阶段,修改java代码;
Javassist和ASM 在.class --> .dex阶段,都是修改的.class

发生的具体函数

APT                       Processor -> process {....}
AspectJ                javaCompile.doLast { ... }
Javassist和asm  MyTransform -> transform { ... }

Android官方从gradle 1.5版本开始,Android官方提供了Gradle Transform技术用于在项目构建阶段,即由class到dex转换期间修改class文件的一套api,借用这套api,开发者可以很容易的完成字节码插桩、代码注入技术等注入技术。
官方文档:http://google.github.io/android-gradle-dsl/javadoc/, 今天主要介绍基于javassist的插入和修改class字节码。

Gradle, Transfrom, Task, Plugin

看过我上一篇博文推荐的文章就知道,Gradle是通过一个个Task执行完成整个流程的,其中肯定也有将所有class打包成dex的task。 
(在gradle plugin 1.5 以上和以下版本有些不同)

1.5以下,preDex这个task会将依赖的module编译后的class打包成jar,然后dex这个task则会将所有class打包成dex
1.5以上,preDex和Dex这两个task已经消失,取而代之的是TransfromClassesWithDexForDebug

Plugin

Gradle中除了Task这个重要的api,还有一个就是Plugin。 
Plugin的作用是什么呢,这一两句话比较难以说明。

Gralde只能算是一个构建框架,里面的那么多Task是怎么来的呢,谁定义的呢? 
是Plugin,细心的网友会发现,在module下的build.gradle文件中的第一行,往往会有apply plugin : 'com.android.application'亦或者apply plugin : 'com.android.library'。

com.android.application:这是app module下Build.gradle的 
com.android.library:这是app依赖的module中的Builde.gradle的

就是这些Plugin为项目构建提供了Task,使用不同的plugin,module的功能也就不一样。 
可以简单的理解为: Gradle只是一个框架,真正起作用的是plugin。而plugin的主要作用是往Gradle脚本中添加Task。 
当然,实际上这些是很复杂的东西,plugin还有其他作用这里用不上。

 hook 字节码文件

要 hook 字节码文件,我们这边需要考虑以下几个事情。

  • 字节码文件怎么获取?
  • 如何改变字节码文件?

字节码文件怎么获取到?

引入Transform

Transfrom是Gradle 1.5以上新出的一个api,其实它也是Task,不过定义方式和Task有点区别。 对于热补丁来说,Transfrom反而比原先的Task更好用。

在Transfrom这个api出来之前,想要在项目被打包成dex之前对class进行操作,必须自定义一个Task,然后插入到predex或者dex之前,在自定义的Task中可以使用javassist或者asm对class进行操作。

而Transform则更为方便,Transfrom会有他自己的执行时机,不需要我们插入到某个Task前面。Tranfrom一经注册便会自动添加到Task执行序列中,并且正好是项目被打包成dex之前。

而本文就是使用Gradle1.5以上版本,下面则是Google对Transfrom的描述文档。 http://tools.android.com/tech-docs/new-build-system/transform-api 有时候会访问不了,你可能需要一把梯子……

Task的inputs和outputs

Gradle可以看做是一个脚本,包含一系列的Task,依次执行这些task后,项目就打包成功了。 而Task有一个重要的概念,那就是inputs和outputs。 Task通过inputs拿到一些东西,处理完毕之后就输出outputs,而下一个Task的inputs则是上一个Task的outputs。

例如:一个Task的作用是将java编译成class字节码,这个Task的inputs就是java文件的保存目录,outputs就是编译后的class的输出目录,它的下一个Task的inputs就会是编译后的class的输出目录(outputs)了。

Transform的原理与应用

介绍如何应用Transform之前,我们先介绍Transform的原理

每个Transform其实都是一个gradle task,Android编译器中的TaskManager将每个Transform串连起来,第一个Transform接收来自javac编译的结果,以及已经拉取到在本地的第三方依赖(jar, aar),还有resource资源,注意这里的resource并非android项目中的res资源,而是asset目录下的资源。这些编译的中间产物,在Transform组成的链条上流动,每个Transform节点可以对class进行处理再传递给下一个Transform。我们常见的混淆,Desugar等逻辑,它们的实现如今都是封装在一个个Transform中,而我们自定义的Transform,会插入到这个Transform链条的最前面。

但其实,上面这幅图,只是展示Transform的其中一种情况。而Transform其实可以有两种输入,一种是消费型的,当前Transform需要将消费型型输出给下一个Transform,另一种是引用型的,当前Transform可以读取这些输入,而不需要输出给下一个Transform,比如Instant Run就是通过这种方式,检查两次编译之间的diff的。至于怎么在一个Transform中声明两种输入,以及怎么处理两种输入,后面将有示例代码。

如何改变字节码文件?

这边引入了一个第三方库 javassist 去改变字节码文件。

我们写一个简单的自定义Transform,让我们对Transform可以有一个更具体的认识:

class OkHttpTransform extends Transform {

  Project mProject
  OkHttpTransform(Project project) {
    mProject = project
    InjectorUtils.getInstance().init(project)
  }

  @Override
  String getName() {
    return "OkHttpTransform"
  }

  /**
   * Returns the type(s) of data that is consumed by the Transform. This may be more than one type.
   * This must be of type {@link QualifiedContent.DefaultContentType}
   */
  @Override
  Set getInputTypes() {
    return TransformManager.CONTENT_CLASS
  }

  /**
   * Returns the scope(s) of the Transform. This indicates which scopes the transform consumes.
   */
  @Override
  Set getScopes() {
    return TransformManager.SCOPE_FULL_PROJECT
  }

  /**
   * Returns whether the Transform can perform incremental work.
   * 

If it does, then the TransformInput may contain a list of changed/removed/added files, unless * something else triggers a non incremental run. */ @Override boolean isIncremental() { return false } void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException { // Just delegate to old method, for code that uses the old API. // noinspection deprecation def inputs = transformInvocation.getInputs() inputs.each { TransformInput input -> input.directoryInputs.each { DirectoryInput directoryInput-> // TODO 这里可以对input的文件做处理,比如代码注入! // 获取output目录 def outputProvider = transformInvocation.outputProvider def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY) CLog.info("directoryInput.file:" + directoryInput.file) // 将input的目录复制到output指定目录 FileUtils.copyDirectory(directoryInput.file, dest) } input.jarInputs.each {JarInput jarInput-> if (jarInput != null) { // CLog.info("jarInput.file:" + jarInput.file) // 保存 目标jar和 将被植入的内容jar 到ClassPool,后续操作该class if (InjectorUtils.instance.needInsertClassPool(jarInput.file)) { InjectorUtils.instance.insertClassPath(jarInput.file) } } } // 连续两个遍历不能合并 input.jarInputs.each { JarInput jarInput-> if (jarInput != null) { // TODO 这里可以对input的文件做处理,比如代码注入! // 重命名输出文件(同目录copyFile会冲突) def jarName = jarInput.name def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath()) if(jarName.endsWith(".jar")) { jarName = jarName.substring(0,jarName.length()-4) } def outputProvider = transformInvocation.outputProvider def dest = outputProvider.getContentLocation(jarName + md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR) if (dest.parentFile && !dest.parentFile.exists()) { dest.parentFile.mkdirs() } if (!dest.exists()) { dest.createNewFile() } // 对jar文件进行修改 File injectJarFile = InjectorUtils.getInstance().inject(jarInput.file) File inputFile = injectJarFile != null ? injectJarFile : jarInput.file FileUtils.copyFile(inputFile, dest) } } } } }

大体思路:

1. 遍历所有的jar或者aar, 将要操作的jar文件添加到ClassPool中;

2. 对将待操作的jar解压到unzipDir目录下,操作其字节码进行代码注入,完成后将unzipDir目录下的class重新打成jar包放在outputJar目录下。

class InjectorUtils {

  private static InjectorUtils sInstance
  private static final String KEY_INJECTOR = "okhttphooker"
  private static final String FILE_SUFFIX_JAR = ".jar"

  private static final String DEST_CLASS_LJINSPECTIONSIGN = "com/ke/okhttphelper/OkHttpHooker.class"
  // OkHttp3
  private static final String DEST_CLASS_OKHTTP3 = "okhttp3/OkHttpClient.class"
  private static List sValidJarList
  private static ClassPool sClassPool
  private Project mProject

  synchronized File inject(File jar) {
    File destFile = null
    
    String destType = getDestJarType(jar)
    if (destType == null) {
      return destType
    }
    /** 在主module的injector目录下生成解压注入后的classes文件和jar包 */
    def outJarName = jar.name.substring(0, jar.name.length() - FILE_SUFFIX_JAR.length())
    def injectorBaseDir = new StringBuilder().append(mProject.buildDir.toString())
        .append(File.separator).append(KEY_INJECTOR)
        .append(File.separator).append(outJarName).toString()

    File rootFile = new File(injectorBaseDir)
    rootFile.mkdirs()
    FileUtils.clearFile(rootFile)
    // 执行注入
    destFile = executeInject(destType,jar, rootFile, destFile)
    return destFile
  }

  private String getDestJarType(File jar) {
    String destType = null

    if (!isValidateJar(jar)) {
      return destType
    }

    // okhttp3
    JarFile jarFile = new JarFile(jar)
    def entry = jarFile.getJarEntry(DEST_CLASS_OKHTTP3)
    if (entry != null) {
      destType = DEST_CLASS_OKHTTP3
      return destType
    }
    return destType
  }

  private File executeInject(String destType, File jar, File rootFile, File destFile) {
    /** jar文件: 解压 -- 遍历注入代码 -- 重新打jar包 */
    /**
     * 将jar解压到unzipDir目录下,操作其字节码进行代码注入,完成后将unzipDir目录下的class打成jar包放在outJarDir目录下
     * */
    File unzipDir = new File(rootFile, "classes")
    File zipJarDir = new File(rootFile, "jar")

    JarFile jarFile = new JarFile(jar)
    // 找到了目标类
    // 1、解压jar
    if (!FileUtils.hasFiles(unzipDir)) {
      FileUtils.unzipJarFile(jarFile, unzipDir)
    }
    // 2、开始注入文件,需要注意,insertClassPath后边跟的根目录,没后缀,className后完整类路径,也没后缀
    insertClassPath(unzipDir)
    // 3、开始注入,去除.class后缀
    realInject(destType, unzipDir.absolutePath)

    // 4、判断classes文件夹下是否有文件
    if (FileUtils.hasFiles(unzipDir)) {
      /** 注入字节码后重新打jar包 */
      destFile = new File(zipJarDir, jar.name)
      FileUtils.clearFile(destFile)
      /** 将注入后的class打包成jar包 */
      FileUtils.zipJarFile(unzipDir, destFile)
    }

    FileUtils.clearFile(unzipDir)
    jarFile.close()
    return destFile
  }

  private void realInject(String destType, String unzipDirPath) {
    if (StringUtils.isEmpty(destType) || StringUtils.isEmpty(unzipDirPath)) {
      CLog.info("realInject >> destType: " + destType + "; unzipDirPath: " + unzipDirPath)
      return
    }

    if(DEST_CLASS_OKHTTP3.equals(destType)){
      try {
        CtClass ctClass = injectOkHttpClientBuilderConstructor()
        if (ctClass != null) {
          ctClass.writeFile(unzipDirPath)
          ctClass.detach()
        }
      } catch (NotFoundException e) {
        CLog.info("inject OkhttpClient\$Builder Constructor failure!!! " + e.toString())
      }

      try {
        CtClass ctClass2 = injectOkHttpRealCall()
        if (ctClass2 != null) {
          ctClass2.writeFile(unzipDirPath)
          ctClass2.detach()
        }
      } catch (NotFoundException e) {
        CLog.info("inject OkHttp Http1Codec readHeaderLine failure!!! " + e.toString())
      }
    }
  }

  private CtClass injectOkHttpClientBuilderConstructor() {
    CtClass ctBuilderClass = sClassPool.getCtClass("okhttp3.OkHttpClient\$Builder")
    if (ctBuilderClass == null) {
      CLog.info("not found okhttp3.OkHttpClient\$Builder !!!")
      return null
    }
    CtConstructor ctConstructor = ctBuilderClass.getDeclaredConstructor(null)
    if (ctConstructor == null) {
      CLog.info("not found okhttp3.OkHttpClient\$Builder Constructor !!!")
      return ctBuilderClass
    }
    String injectValue = "com.ke.okhttphelper.OkHttpHooker.headerInterceptor(this);"

    ctConstructor.insertAfter(injectValue)
    CLog.info("injected: " + ctBuilderClass.getName() + "; ctConstructor:" + ctConstructor.getName() + " succeed!")
    return ctBuilderClass
  }

  private CtClass injectOkHttpRealCall() {
    CtClass ctRealCallClass = sClassPool.getCtClass("okhttp3.RealCall")
    if (ctRealCallClass == null) {
      CLog.info("not found okhttp3.RealCall !!!")
      return null
    }

    CtMethod cm =  ctRealCallClass.getDeclaredMethod("getResponseWithInterceptorChain")
    cm.instrument(
        new ExprEditor() {
          public void edit(MethodCall m) throws CannotCompileException {
//            CLog.info(m.className + " , " + m.methodName)
            if ("okhttp3.Interceptor\$Chain".equals( m.className) && "proceed".equals(m.methodName)) {
              String replaceValue = "\$_ = \$proceed(\$\$);"
              replaceValue += "com.ke.okhttphelper.OkHttpHooker.hookResponseWithInterceptor(originalRequest, client.connectTimeoutMillis());"
              m.replace(replaceValue)
              CLog.info("injected: " + ctRealCallClass.getName() + "; " + cm.getName() + " succeed!")
            }
          }
        })

    CtClass[] param = new CtClass[1]
    param[0] = sClassPool.get("okhttp3.Callback")

    CtMethod cmEnqueue =  ctRealCallClass.getDeclaredMethod("enqueue", param)

    cmEnqueue.instrument(
        new ExprEditor() {
          public void edit(MethodCall m) throws CannotCompileException {
            //            CLog.info(m.className + " , " + m.methodName)
            if ("okhttp3.Dispatcher".equals( m.className) && "enqueue".equals(m.methodName)) {
              String replaceValue = "\$proceed(\$\$);"
              replaceValue += "com.ke.okhttphelper.OkHttpHooker.hookEnqueue(originalRequest, client.readTimeoutMillis());"
              m.replace(replaceValue)
              CLog.info("injected: " + ctRealCallClass.getName() + "; " + cm.getName() + " succeed!")
            }
          }
        })

    return ctRealCallClass
  }

  void insertClassPath(File path) {
    if (null != path) {
      CLog.info("insertClassPath: " + path.getAbsolutePath())
      if (path.directory) {
        sClassPool.appendPathList(path.absolutePath)
      } else {
        sClassPool.appendClassPath(path.absolutePath)
      }
    }
  }

void insertClassPath(File path) {
    if (null != path) {
      CLog.info("insertClassPath: " + path.getAbsolutePath())
      if (path.directory) {
        sClassPool.appendPathList(path.absolutePath)
      } else {
        sClassPool.appendClassPath(path.absolutePath)
      }
    }
  }

  boolean needInsertClassPool(File jar) {
    
    boolean validate = false
    if (!isValidateJar(jar)) {
      return validate
    }
    
    JarFile jarFile = new JarFile(jar)
    for (String key : sValidJarList) {
      def entry = jarFile.getJarEntry(key)
      if (entry != null) {
        validate = true
        break
      }
    }
    if (validate) {
      CLog.info("validateJar: " + jar.name)
    }
    return validate
  }
  
  private boolean isValidateJar(File jar) {
    boolean validate = false

    if (null == jar || !jar.exists()) {
      return validate
    }

    /** jar文件是否合法 */
    try {
      ZipFile zipFile = new ZipFile(jar)
      zipFile.close()
    } catch (Exception e) {
      return validate
    }
    return true
  }
}

库单独构建后会在build/outputs/aar目录下生成xxx.aar产物, jar包产物的位置在 build/intermediates/intermediate-jars/目录下。

  • 补充2个 Javassist 知识点:

  • 如何修改方法体?
    1.获得一个CtMethod实例,即class中的一个方法。
    2.调用CtMethod实例的instrument(ExprEditor editor)方法,并传递一个ExprEditor实例(A translator of method bodies.)
    3.在ExprEditor实例中覆盖edit(MethodCall m)方法,这里可以调用MethodCall的replace()方法来更改方法体内的代码。

  • 修改方法体的原理?
    调用CtMethod的instrument(),方法体会被逐行进行扫描,从第一行扫描到最后一行。发现有方法调用或表达式时(object creation),edit()会被调用,根据edit()内的replace()方法来修改这一行代码。

  • ctCls.getDeclaredMethods().each { },经过对修改方法体的背景知识的了解,我们再看这段插桩代码实现就能看懂了:

  • 遍历class中声明的全部方法

  • 调用每个方法的instrument方法

  • 扫描方法中的每一行表达式,如果这一行表达式的调用方为此类的super类,那么就分两种情况做处理:
    1.返回类型为void时,调用MethodCall的replace方法,替换这一行代码为super.' + call.getMethodName() + '($$);,其中$$是所有方法参数的简写,例如:m($$)等同于m($,$,...)。
    2.返回类型非void时,调用MethodCall的replace方法,替换这一行代码为$_ = super.' + call.getMethodName() + '($$);,其中特殊变量$_代表的是方法的返回值。因为方法调用是有返回值的,所以statement必须将返回值赋值给它,这是javassist.expr.MethodCall方法的明确要求。

  • 如果你想在某个方法的某个表达式前后插入方法,则修改的souce如下:

    { before-statements;

  •   $_ = $proceed($$);

      after-statements; }

    $_ = $proceed($$);  表示按原样调用

    $_ 代表方法的返回值

    $$  代表原始方法的所有入参

  • Javassist提供了一些特殊的变量来代表特定含义:

    自定义gradle插件进阶_第2张图片

    注:在不同的 javassist 方法中使用时,这些特殊变量代表的含义可能会略有不同。参见:javassist tutorial

     

  • 全部的类遍历完后,将ctCls对象写回到class文件中。这样就全部完成了class文件的Activity顶级父类动态注入。

  • CtClass.detach(),最后调用detach()方法,把CtClass object 从ClassPool中移除,避免当加载过多的CtClass object的时候,会造成OutOfMemory的异常。因为ClassPool是一个CtClass objects的装载容器。加载CtClass object后,默认是不释放的。

  • 关于Jar包中的class注入:在initClassPool时已经把Jar做了unzip,解压出也是一堆.class文件,其他处理逻辑同上。也就是说,你引用的第三方sdk中的jar,以及你依赖的库中的jar,都会被注入器撸一遍。

Plugin中的注册

boolean hasApp = project.getPlugins().hasPlugin(AppPlugin.class)
if (hasApp) {
  def appExtension = project.getExtensions().getByType(AppExtension.class)
  appExtension.registerTransform(new OkHttpTransform(project), Collections.EMPTY_LIST)
}

 

 

你可能感兴趣的:(项目实战)