#0 初識外掛化

boyan01發表於2019-07-26

恍惚間從畢業到工作都一年整了,依然是鹹魚一條。想著先了解了解 Android 外掛化相關的內容,也是為以後深入瞭解 Framework 作一個鋪墊。同時將探索的歷程寫出來,以激勵自己能夠好好探索下去,別半途而廢...

目標

這一篇文章的目標是編寫出一個簡單的應用,能夠實現從宿主應用跳轉到外掛的Activity。

1. 建立專案

使用 Android Studio - File - New - New Porject 建立好宿主專案之後,再在此專案中 new 一個 module,這就是我們的外掛專案了。

#0 初識外掛化

2. plugin module 配置

由於我是以 Android libary 方式建立的 Module,所以為了生成 apk 包,所以需要修改 module 構建指令碼。

apply plugin: 'com.android.application'
複製程式碼

3. “外掛”打包與“釋出”

在外掛中新建名ToastTest的java檔案,新增如下內容:

public class ToastTest {

    private final Context context;

    public ToastTest(Context context) {
        this.context = context;
    }

    public void call() {
        Toast.makeText(context, "call method", Toast.LENGTH_SHORT).show();
    }

    public String getData() {
        return "Haha";
    }
}
複製程式碼

然後就可以打包apk了,使用gradle執行編譯命令

 ./gradlew plugin:assemble
複製程式碼

成功之後,可以前往 plugin/build 目錄下檢視生成的 apk 檔案。

#0 初識外掛化

這就是我們生成的 “外掛包”了。我們可以先將其 push 到裝置的 sdcard 上。

 adb push ./plugin/build/outputs/apk/debug/plugin-debug.apk sdcard/
複製程式碼

4."宿主"呼叫外掛中的方法

眾所周知哈,java 提供了 ClassLoader 來載入來自檔案等其他的位元組碼檔案,而 Android 中也提供了 DexClassLoader 用於動態載入 dex。 於是我們們建立一個新的 DexClassLoader,用於載入 sdcard/plugin.apk 中的位元組碼檔案。

  buttonLoadClass.setOnClickListener {
      val file = File("sdcard/plugin-debug.apk")
  
      Log.i("MainActivity", "file exists: ${file.exists()}")
  
      val cache = File(cacheDir, "tmp").apply { mkdirs() }
      val loader = DexClassLoader(
          file.path, cache.path, null, classLoader
      )
      val clazz = loader.loadClass("com.example.plugin.ToastTest")
      val constructor = clazz.getConstructor(Context::class.java)
      val method = clazz.getMethod("call")
  
      val methodGetData = clazz.getMethod("getData")
  
      val instance = constructor.newInstance(this)
  
      Log.i("MainActivity", "data :   ${methodGetData.invoke(instance)}")
      //呼叫外掛中的方法,彈出 toast
      method.invoke(instance)

}
複製程式碼

#0 初識外掛化

5. Activity跳轉

經過以上步驟,已經實現動態呼叫外掛中的方法,並彈出了一個 Toast。於是順勢再嘗試實現 Activity 的跳轉吧。

首先在 plugin 專案中,建立一個名為 PluginActivity 的 Activity。

然後我們來嘗試啟動它...

在步驟4中,我們通過 DexClassLoader 來載入外掛中的位元組碼檔案,從而實現呼叫外掛中的方法。但是在 startActivity 時候,我們又該怎麼樣 new 出外掛中的 Activity 例項呢?

查詢了相關資料,找到了這麼一種實現方式。

通過 hack Instrumentation 類,在 Instrumentation#newActivity() 方法中使用 DexClassLoader 來載入外掛中的 Activity 類。

詳情參見 fashare2015.github.io/2018/01/24/…

另外我個人想出了一個更簡單的方法。由於 classLoader 載入類時,優先使用 parent 載入,所以我們可以嘗試將當前的 classLoader 的 parent 換成外掛 DexClassLoader。

文字寫這麼多,讀起來會有點繞。用程式碼實現的話,差不多邏輯如下:

val pluginClassLoader = DexClassLoader(/*...*/, parent = classLoader.parent);
classLoader.parent = pluginClassLoader
複製程式碼

當然,由於 ClassLoader 在 jdk 中是由 private final 修飾的, 我們還需要使用反射來修改它的值。

    buttonToPlugin.setOnClickListener {

        val file = File("sdcard/plugin-debug.apk")

        Log.i("MainActivity", "file exists: ${file.exists()}")

        val cache = File(cacheDir, "tmp").apply { mkdirs() }
        val loader = DexClassLoader(
            file.path, cache.path, null, classLoader.parent
        )
        
        classLoader.modifyFiled("parent", loader)

        val pluginCls = classLoader.loadClass("com.example.plugin.PluginActivity")

        Log.i("MainActivity", "class : $pluginCls")

        val intent = Intent().apply {
            setClassName(this@MainActivity, "com.example.plugin.PluginActivity")
        }

        startActivity(intent)

    }
    
    private fun Any.modifyFiled(filedName: String, value: Any?) {
        val field = this.javaClass.findFiled(filedName)
        if (!field.isAccessible) {
            field.isAccessible = true
        }
        field.set(this, value)
    }

    private fun Class<*>.findFiled(filedName: String): Field {
        return try {
            getDeclaredField(filedName)
        } catch (e: Exception) {
            superclass.findFiled(filedName)
        }
    }
複製程式碼

注意事項

  1. 外掛中的 activity 需要在宿主 app 中宣告,不然 startActivity 會丟擲異常。
 <activity android:name="com.example.plugin.PluginActivity"/>
複製程式碼
  1. 無法使用 R 檔案,即資原始檔暫不可用。
  2. PluginActivity 需繼承自 Activity 而非 AppCompatActivity,原因同2。
  3. 跳轉併成功開啟 PluginActivity,可能會報以下異常
    Caused by: java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
    複製程式碼
    這是由於某些類被重複載入導致的。

至此跳轉效果,如下圖GIF所示:

#0 初識外掛化

外掛中資源的使用

我們都知道:Android 中資原始檔是由 AssetManager 來管理的,AssetManager 一般有設定了兩個 path(Android系統自帶的資源和應用的資源)。所以如果我們新增外掛的 apk 地址到 AssetManager 中,那麼我們不就能訪問到外掛apk中資原始檔了嗎?經過驗證,是可以的,但是需要注意的點有點小多。

  1. 宿主apk 和 外掛 apk 的 AssetManager 最好隔離開,不然會有資源衝突的問題。

  2. AssetManager 隔離時,需要hock改寫 Theme context#baseContext 等一些未暴露的 api。

聽說騰訊開源的 Shadow 是零反射實現的外掛化,有時間再拜讀一下他們的原始碼,看看大佬們是如何解決外掛資源使用這一問題的。


文章就暫時寫到這裡吧,還有不少其他的方式來實現外掛化,而且業界也有很多的開源專案,等有機會熟讀之後再重作總結吧。

參考

huangtianyu.gitee.io/2017/12/27/…

fashare2015.github.io/2018/01/24/…

github.com/alibaba/atl…

相關文章