利用 Transform 解决模块化开发服务调用问题

4,306 阅读10分钟

前言

如果读者对模块化开发的服务调用具有一定的认识可以跳过下面一小节。

模块化开发中的服务调用概念

模块化开发现在对于 Android 开发者来说应该是一个耳熟能详的名词了,现在应该有许多应用的开发迭代都使用了模块化开发,模块化开发的意义是在于将 App 的业务细分成 N 个模块,利于开发人员的协作开发。模块化开发当中有一个需要解决的问题就是模块之间的服务调用——因为各个模块是以 library 形式存在,彼此之间不相互依赖,故使彼此之间实际上并不知道对方的存在,那么当 A 模块想要知道 B 模块中的某个信息,需要调用 B 中的某个方法时该怎么办呢?例如开发人员当前正在 main 模块开发,当前的一个 TextView 需要展示电影信息,但是很明显电影信息这块属于 movie 模块而并不是 main 模块,那么此时该如何解决呢?机智的 Android 开发人员创建了基础模块 service 并让所有的业务模块依赖 service 模块,service 模块的职责也很简单,只需要提供接口声明,具体的实现就交给具体的业务模块自己去实现了。例如 service 模块中提供一个 MovieService 类:

public interface MovieService {
  String movieName();
}

那么在 movie 模块中就可以创建一个 MovieServiceImpl 类去实现 MovieService 接口了——

public class MovieServiceImpl implements MovieService {
  @Override public String movieName() {
    return "一出好戏";
  }
}

而对于 main 模块来说,它应该调用 MovieService 实现类的 movieName() 方法就好了,但是事实上 main 模块又不可能知道 MovieService 的具体实现类是什么,所以看起来似乎问题又卡住了...

解决方案

实际上问题在于如何获取到接口实现类的路径,例如 renxuelong/ComponentDemo 中所提到的,反射调用所有模块的 application 的某个方法,在该方法中将接口与实现类映射起来,该方法的弊端很明显,开发者需要显示填写所有模块 application 的完全限定名,这在开发中应当是尽量避免的。

流行的解决方案就是 ARouter 的实现方式了——使用 APT—— build 时扫描所有的被 @Route 注解所修饰的类,判断该类是否实现了某个接口,如果是的话则创建相应的 xxx?app 类,读者可以下载 ARouter 的 demo 在 build 之后找到 ARouter?Providers?app 类 ——

i8UFCn.md.png

如上图所示,左侧是接口的完全限定名,右侧是具体的实现类,这样就将接口与实现类一一映射起来了,相比于上面所提到的方法,开发者并不需要手动地去填写类的完全限定名,因为在实际开发中类的路径是很可能被改变的,这种撰写类的完全限定名的操作应该避免由开发者去做,而应该去交给构建工具去完成。

实际上笔者本文所想要阐述的方案与 APT 的原理是一样的,通过扫描指定注解所修饰的类获取到所有的 service 接口的实现类,并用 Map 将其维护起来。

Transform API

结合官方文档文档上来说,Transform 是一个类,构建工具中自带诸如 ProGuardTransformDexTransform 等 Transform,一系列的 Transform 类将所有的 .class 文件转换为 .dex 文件,而官方允许开发者创建自定义的 Transform 来操作转换成 .dex 文件之前的所有 .class 文件,这意味着开发者可以对app 中所有的 .class 文件进行操作。开发者可以在插件中通过 android.registerTransform(theTransform) 或者 android.registerTransform(theTransform, dependencies) 来注册一个 Transform。

前面提到,Transform 实际上是一系列的操作,所以开发者应该很容易理解,前一个 Transform 的输出理应会是下一个 Transform 的输入—— i8NzDS.md.png

关于理解本文所需要的 Transform 知识先说到这,其他涉及的知识点会在后文的实操中提到。如果各位读者对 Transform 想要深一步了解,更多 Transform 使用姿势可参考官方文档

javassist

javassist 是一个字节码工具,简单来说可以利用它来增删改 .class 文件的代码,毕竟在构建时期的 .java 文件都编译成了 .class 文件了。

实操

在动手写代码前应该思考一下需要创建几个 lib 工程,对于模块化开发中的各个 module 来说,它们总共需要两个类,一个是注解,如果当前 module 有接口服务需要实现,那么得用这个注解来标记实现类;另一个就是 Map,需要通过它来获取其他 module 的实现类。当然,除了创建前面所提到的这个 lib 工程以外,还需要创建一个 plugin 供 app 模块使用。

新建一个 java 模块取名为 hunter,并创建 HunterRegistry 类和 Impl 注解如下:

public final class HunterRegistry {
  private static Map<Class<?>, Object> services;

  private HunterRegistry() {
  }
    
  @SuppressWarnings("unchecked") public static <T> T get(Class<T> key) {
    return (T) services.get(key);
  }
}
public @interface Impl {
  Class<?> service();
}

对于 main 模块来说,如果它想要获取 movie 模块的电影信息,它仅需调用 HunterRegistry.get(MovieService.class).movieName() 即可获得 MovieService 实现类的具体方法实现,HunterRegistry 类看起来有些匪夷所思,services 对象甚至都没有初始化,所以调用 get() 方法一定会报错,从现有代码看起来确实是这样但是实际上在 Transform 中获取到所有的接口-实现类的映射关系之后将会通过 javassist 插入静态代码初始化 services 对象并向 services 对象中 put 键值对,最终生成 .class 文件类似如下:

public final class HunterRegistry {
  private static Map<Class<?>, Object> services = new HashMap();
  
  static {
      services.put(MovieService.class, new MovieServiceImpl());
  }

  private HunterRegistry() {
  }

  @SuppressWarnings("unchecked") public static <T> T get(Class<T> key) {
    return (T) services.get(key);
  }
}

而对于 movie 模块来说,它需要创建 MovieService 的具体实现类,并用 @Impl 注解标记以便 Transform 可以找到它与接口的映射关系,例如:

@Impl(service = MovieService.class)
public class MovieServiceImpl implements MovieService {
  @Override public String movieName() {
    return "一出好戏";
  }
}

接下来就是创建 gradle plugin 了:

创建 plugin 的基本过程本文就不提及了,如果读者不太清楚的话,可以参考笔者之前写的写给 Android 开发者的 Gradle 系列(三)撰写 plugin

创建一个 plugin 类,plugin 的内容很简单:

class HunterPlugin implements Plugin<Project> {
  @Override
  void apply(Project project) {
    project.plugins.withId('com.android.application') {
      project.android.registerTransform(new HunterTransform())
    }
  }
}

所以可以看得出来所有的重点就是在这个 HunterTransform 身上了——

class HunterTransform extends Transform {
  private static final String CLASS_REGISTRY = 'com.joker.hunter.HunterRegistry'
  private static final String CLASS_REGISTRY_PATH = 'com/joker/hunter/HunterRegistry.class'
  private static final String ANNOTATION_IMPL = 'com.joker.hunter.Impl'
  private static final Logger LOG = Logging.getLogger(HunterTransform.class)

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

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

  @Override
  Set<? super QualifiedContent.Scope> getScopes() {
    return Collections.singleton(QualifiedContent.Scope.SUB_PROJECTS)
  }

  @Override
  boolean isIncremental() {
    return false
  }

  @Override
  void transform(TransformInvocation transformInvocation)
      throws TransformException, InterruptedException, IOException {
    // 1
    transformInvocation.outputProvider.deleteAll()

    def pool = ClassPool.getDefault()

    JarInput registryJarInput
    def impls = []

    // 2
    transformInvocation.inputs.each { input ->

      input.jarInputs.each { JarInput jarInput ->
        pool.appendClassPath(jarInput.file.absolutePath)

        if (new JarFile(jarInput.file).getEntry(CLASS_REGISTRY_PATH) != null) {
          registryJarInput = jarInput
          LOG.info("registryJarInput.file.path is ${registryJarInput.file.absolutePath}")
        } else {
          def jarFile = new JarFile(jarInput.file)
          jarFile.entries().grep { entry -> entry.name.endsWith(".class") }.each { entry ->
            InputStream stream = jarFile.getInputStream(entry)
            if (stream != null) {
              CtClass ctClass = pool.makeClass(stream)
              if (ctClass.hasAnnotation(ANNOTATION_IMPL)) {
                impls.add(ctClass)
              }
              ctClass.detach()
            }
          }

          FileUtils.copyFile(jarInput.file,
              transformInvocation.outputProvider.getContentLocation(jarInput.name,
                  jarInput.contentTypes, jarInput.scopes, Format.JAR))
          LOG.info("jarInput.file.path is $jarInput.file.absolutePath")
        }
      }
    }
    if (registryJarInput == null) {
      return
    }

    // 3
    def stringBuilder = new StringBuilder()
    stringBuilder.append('{\n')
    stringBuilder.append('services = new java.util.HashMap();')
    impls.each { CtClass ctClass ->
      ClassFile classFile = ctClass.getClassFile()
      AnnotationsAttribute attr = (AnnotationsAttribute) classFile.getAttribute(
          AnnotationsAttribute.invisibleTag)
      Annotation annotation = attr.getAnnotation(ANNOTATION_IMPL)
      def value = annotation.getMemberValue('service')
      stringBuilder.append('services.put(')
          .append(value)
          .append(', new ')
          .append(ctClass.name)
          .append('());\n')
    }
    stringBuilder.append('}\n')
    LOG.info(stringBuilder.toString())

    def registryClz = pool.get(CLASS_REGISTRY)
    registryClz.makeClassInitializer().setBody(stringBuilder.toString())

    // 4
    def outDir = transformInvocation.outputProvider.getContentLocation(registryJarInput.name,
        registryJarInput.contentTypes, registryJarInput.scopes, Format.JAR)

    copyJar(registryJarInput.file, outDir, CLASS_REGISTRY_PATH, registryClz.toBytecode())
  }

  private void copyJar(File srcFile, File outDir, String fileName, byte[] bytes) {
    outDir.getParentFile().mkdirs()

    def jarOutputStream = new JarOutputStream(new FileOutputStream(outDir))
    def buffer = new byte[1024]
    int read = 0

    def jarFile = new JarFile(srcFile)
    jarFile.entries().each { JarEntry jarEntry ->
      if (jarEntry.name == fileName) {
        jarOutputStream.putNextEntry(new JarEntry(fileName))
        jarOutputStream.write(bytes)
      } else {
        jarOutputStream.putNextEntry(jarEntry)
        def inputStream = jarFile.getInputStream(jarEntry)
        while ((read = inputStream.read(buffer)) != -1) {
          jarOutputStream.write(buffer, 0, read)
        }
      }
    }
    jarOutputStream.close()
  }
}

这里简单提一下前三个方法,首先是 getInputTypes(),它表示输入该 Transform 的文件类型是什么,从 QualifiedContent.ContentType 的实现类中可以看到还是有很多种输入文件类型的,然并卵,前文提到,官方只允许开发者对 .class 文件操作,当然,这里我们也只需要对 .class 文件操作就好了,所以这里得填 TransformManager.CONTENT_CLASS;接着是 getScopes() 方法,它表示开发者需要从哪些地方获取这些输入文件,而 QualifiedContent.Scope.SUB_PROJECTS 就是代表各个 module,因为我们也只需要获取各个 module 的 .class 文件就好了;最后是 isIncremental() 方法,它代表当前 Transform 是否支持增量编译,为了使得本文所谈到的内容更简单一些,笔者选择了 return false 代表当前 Transform 不支持增量编译,各位读者后期可以参考官方文档优化这个 Transform 使其支持增量编译。接下来就是核心的 transform() 方法了——为了方便解释代码,笔者将 transform() 方法分成了4个部分,首先第1部分为了避免上一次构建对本次构建的影响,需要调用 transformInvocation.outputProvider.deleteAll() 删除上一次构建的产物,以及一些初始化的操作;第2部分就是对 Transform 输入产物的操作了,也就是所有的 .class 文件,input 除了 jarInputs 之外还有 dirInputs,但是对于输入范围为 QualifiedContent.Scope.SUB_PROJECTS 的 Transform 来说输入类型只有 jarInputs,而这里的 jarInputs.file 实际上是当前项目中所有 module:

可通过 ./gradlew assDebug --info 查看输出结果

在这一步中,我们要区分出两类 jar,一类是包含 HunterRegistry.class 的 jar 包,通过 new JarFile(jarInput.file).getEntry(CLASS_REGISTRY_PATH) != null 即可判断当前 jar 包是否包含 HunterRegistry.class 也就是上面截图的 hunter.jar;而另一类就是 module 的 jar 包,通过 groovy 的 api 筛选出 jar 包中所有的 .class 文件,再依靠 javassist 提供的 api 判断当前 .class 是否是被 @Impl 注解所修饰的,如果是的话就将它添加到 impls 里面,前文提到前一个 Transform 的输出会是下一个 Transform 的输入,所以需要通过 transformInvocation.outputProvider.getContentLocation(jarInput.name, jarInput.contentTypes, jarInput.scopes, Format.JAR) 获取该 jar 包应该移动到的路径下,因为它还要作为下一个 Transform 的输入;第3步就是利用 impls 获取具体实现类,利用 javassist api 获取 @Impl 注解中的 service 方法的返回值,也就是接口类,再将它们拼接成字符串,最终再通过 registryClz.makeClassInitializer().setBody(stringBuilder.toString()) 即可将这段字符串注入到 HunterRegistry.class 文件中了;第4步就是将上一步获取到的新 HunterRegistry.class 文件的字节码替换掉原先的字节码并最后打入指定的路径下就好了。

通过 jadx 工具打开 debug.apk 再找到 HunterRegistry.class 文件,字节码如下:

i8U9EQ.png

可以看到 MovieService 和它的实现类 MovieServiceImpl 被 put 进了 services 当中。运行 debug.apk 跳转到 main 模块下 HomeActivity 就可以看到屏幕上的输出值了:

i8dQ6x.jpg

尾语

无论是 APT 方案还是 Transform 方案,它们所解决模块化开发中的服务调用核心思想都是在于找到接口与实现类的映射关系,只要解决了映射关系,问题也就迎刃而解了。如果是暂不了解 Transform 的读者,笔者认为在了解完本文的知识后,可以更深一步的去了解 Transform,例如优化 HunterTransform,使其支持增量编译;例如尝试改变输入范围后,输入的文件会有什么不一样?

当输入范围为 QualifiedContent.Scope.PROJECT 时输入的文件中将会有 directoryInput 类型,其文件夹路径实际上就是 ../app/build/intermediates/classes/debug,实际上里面就是 app 模块的所有 .class 文件: i8U7rT.md.png 而当输入范围为 QualifiedContent.Scope.EXTERNAL_LIBRARIES 时输入的 jar 包全部都是第三方库: i8Ud5d.png

所以如果将插件传到 maven,以第三方形式以来进工程的话,那么输入范围就不能仅仅是上文提到的 QualifiedContent.Scope.SUB_PROJECTS 了,因为插件的 jar 包将会找不到。

最后是项目地址:jokermonn/transformSample