在传统的 Android 开发模式中,由于界面过分依赖于 Activity
、Fragment
这样的组件,一个业务模块中往往会存在着大量的 Activity
类,因此诞生了很多的插件化框架,这些插件化框架基本都是想方设法的使用各种Hook/反射手段来解决使用未注册的组件问题。在进入 Jetpack Compose 的世界以后,Activity
的角色被淡化了,由于一个 Composable
组件就可以承担一个屏幕级的显示,因此我们的应用中不再需要那么多的 Activity
类,只要你喜欢,你甚至可以打造一个单 Activity
的纯 Compose 应用。
本文主要尝试探索几种可以在 Jetpack Compose 中实施插件化/动态加载的可行性方案。
以 Activity占坑的方式访问插件中的 Composable 组件
这种方式其实传统 View 开发也可以做,但是由于 Compose 中我们可以只使用一个Activity,而其余页面均使用 Composable 组件来实现,感觉更加适合它。因此主要的思路就是在宿主应用的 AndroidManifest.xml
中注册一个占坑的 Activity
类,该 Activity
实际存在于插件中,然后在宿主中加载插件中该 Activity
的Class,启动插件中的该Activity
并传递不同的参数,以显示不同的 Composable 组件。说白了就是借助一个空壳 Activity 来做跳板去展示不同的 Composable 。
首先在工程中新建一个 module 模块,将 build.gradle 中的 'com.android.library'
plugins配置改为 'com.android.application'
,因为这个模块是当成一个 application 模块开发的,最终以 apk 的形式提供插件。然后在其中新建一个 PluginActivity
作为跳板 Activity,并新建两个测试的 Composable 页面。
PluginActivity
的内容如下:
class PluginActivity: ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val type = intent.getStringExtra("type") ?: "NewsList"
setContent {
MaterialTheme {
if (type == "NewsList") {
NewsList()
} else if (type == "NewsDetail") {
NewsDetail()
}
}
}
}
}
这里就是简单的根据 intent 读取的 type 类型来判断,如果是 NewsList 就显示一个新闻列表的 Composable 页面, 如果是 NewsDetail 就显示一个新闻详情的 Composable 页面。
NewsList
内容如下:
@Composable
fun NewsList() {
LazyColumn(
Modifier.fillMaxSize().background(Color.Gray),
contentPadding = PaddingValues(15.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(50) {
index ->
NewsItem("我是第 $index 条新闻")
}
}
}
@Composable
private fun NewsItem(
text : String,
modifier: Modifier = Modifier,
bgColor: Color = Color.White,
fontColor: Color = Color.Black,
) {
Card(
elevation = 8.dp,
modifier = modifier.fillMaxWidth(),
backgroundColor = bgColor
) {
Box(
Modifier.fillMaxWidth().padding(15.dp),
contentAlignment = Alignment.Center
) {
Text(text = text, fontSize = 20.sp, color = fontColor)
}
}
}
NewsDetail
内容如下:
@Composable
fun NewsDetail() {
Column {
Text(text = "我是插件中的新闻详情页面".repeat(100))
}
}
执行 assembleDebug,将生成的 apk 文件拷贝到宿主 app 模块的 assets 目录下,以便在应用启动后从其中拷贝到存储卡(实际项目中应当从服务器下载)。
然后在宿主app模块的 AndroidManifest.xml
中注册插件中定义的 PluginActivity
进行占坑,这里爆红也没有关系,不会影响打包。
然后在app模块中定义一个 PluginManager
类,主要负责加载插件中的 Class
:
import android.annotation.SuppressLint
import android.content.Context
import dalvik.system.DexClassLoader
import java.io.File
import java.lang.reflect.Array.newInstance
import java.lang.reflect.Field
class PluginManager private constructor() {
companion object {
var pluginClassLoader : DexClassLoader? = null
fun loadPlugin(context: Context) {
val inputStream = context.assets.open("news_lib.apk")
val filesDir = context.externalCacheDir
val apkFile = File(filesDir?.absolutePath, "news_lib.apk")
apkFile.writeBytes(inputStream.readBytes())
val dexFile = File(filesDir, "dex")
if (!dexFile.exists()) dexFile.mkdirs()
println("输出dex路径: $dexFile")
pluginClassLoader = DexClassLoader(apkFile.absolutePath, dexFile.absolutePath, null, this.javaClass.classLoader)
}
fun loadClass(className: String): Class<*>? {
try {
if (pluginClassLoader == null) {
println("pluginClassLoader is null")
}
return pluginClassLoader?.loadClass(className)
} catch (e: ClassNotFoundException) {
println("loadClass ClassNotFoundException: $className")
}
return null
}
/**
* 合并DexElement数组: 宿主新dexElements = 宿主原始dexElements + 插件dexElements
* 1、创建插件的 DexClassLoader 类加载器,然后通过反射获取插件的 dexElements 值。
* 2、获取宿主的 PathClassLoader 类加载器,然后通过反射获取宿主的 dexElements 值。
* 3、合并宿主的 dexElements 与 插件的 dexElements,生成新的 Element[]。
* 4、最后通过反射将新的 Element[] 赋值给宿主的 dexElements。
*