利用 Transform 解決模組化開發服務呼叫問題

jokermonn發表於2018-10-05

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

利用 Transform 解決模組化開發服務呼叫問題

前言

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

模組化開發中的服務呼叫概念

模組化開發現在對於 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

相關文章