利用 Transform 解決模塊化開發服務調用問題

如果你對本文感興趣,也許你對我的公衆號也會有興趣,可掃下方二維碼或搜索公衆微信號:mxszgg

前言

如果讀者對模塊化開發的服務調用具有一定的認識可以跳過下面一小節。

模塊化開發中的服務調用概念

模塊化開發現在對於 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() 方法分成了5個部分,首先第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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章