恍惚間從畢業到工作都一年整了,依然是鹹魚一條。想著先了解了解 Android 外掛化相關的內容,也是為以後深入瞭解 Framework 作一個鋪墊。同時將探索的歷程寫出來,以激勵自己能夠好好探索下去,別半途而廢...
目標
這一篇文章的目標是編寫出一個簡單的應用,能夠實現從宿主應用跳轉到外掛的Activity。
1. 建立專案
使用 Android Studio - File - New - New Porject 建立好宿主專案之後,再在此專案中 new 一個 module,這就是我們的外掛專案了。
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 檔案。
這就是我們生成的 “外掛包”了。我們可以先將其 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)
}
複製程式碼
5. Activity跳轉
經過以上步驟,已經實現動態呼叫外掛中的方法,並彈出了一個 Toast。於是順勢再嘗試實現 Activity 的跳轉吧。
首先在 plugin 專案中,建立一個名為 PluginActivity 的 Activity。
然後我們來嘗試啟動它...
在步驟4中,我們通過 DexClassLoader 來載入外掛中的位元組碼檔案,從而實現呼叫外掛中的方法。但是在 startActivity 時候,我們又該怎麼樣 new 出外掛中的 Activity 例項呢?
查詢了相關資料,找到了這麼一種實現方式。
通過 hack
Instrumentation
類,在Instrumentation#newActivity()
方法中使用 DexClassLoader 來載入外掛中的 Activity 類。
另外我個人想出了一個更簡單的方法。由於 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)
}
}
複製程式碼
注意事項
- 外掛中的 activity 需要在宿主 app 中宣告,不然 startActivity 會丟擲異常。
<activity android:name="com.example.plugin.PluginActivity"/>
複製程式碼
- 無法使用 R 檔案,即資原始檔暫不可用。
- PluginActivity 需繼承自 Activity 而非 AppCompatActivity,原因同2。
- 跳轉併成功開啟 PluginActivity,可能會報以下異常
這是由於某些類被重複載入導致的。Caused by: java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation 複製程式碼
至此跳轉效果,如下圖GIF所示:
外掛中資源的使用
我們都知道:Android 中資原始檔是由 AssetManager 來管理的,AssetManager 一般有設定了兩個 path(Android系統自帶的資源和應用的資源)。所以如果我們新增外掛的 apk 地址到 AssetManager 中,那麼我們不就能訪問到外掛apk中資原始檔了嗎?經過驗證,是可以的,但是需要注意的點有點小多。
-
宿主apk 和 外掛 apk 的 AssetManager 最好隔離開,不然會有資源衝突的問題。
-
AssetManager 隔離時,需要hock改寫
Theme
context#baseContext
等一些未暴露的 api。
聽說騰訊開源的 Shadow 是零反射實現的外掛化,有時間再拜讀一下他們的原始碼,看看大佬們是如何解決外掛資源使用這一問題的。
文章就暫時寫到這裡吧,還有不少其他的方式來實現外掛化,而且業界也有很多的開源專案,等有機會熟讀之後再重作總結吧。
參考
huangtianyu.gitee.io/2017/12/27/…