恍惚间从毕业到工作都一年整了,依然是咸鱼一条。想着先了解了解 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/…