Jetpack Compose 中的动态加载插件化技术探索
Posted 川峰
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose 中的动态加载插件化技术探索相关的知识,希望对你有一定的参考价值。
在传统的 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。
*/
@SuppressLint("DiscouragedPrivateApi")
fun mergeDexElement(context: Context) : Boolean
try
val clazz = Class.forName("dalvik.system.BaseDexClassLoader")
val pathListField: Field = clazz.getDeclaredField("pathList")
pathListField.isAccessible = true
val dexPathListClass = Class.forName("dalvik.system.DexPathList")
val dexElementsField = dexPathListClass.getDeclaredField("dexElements")
dexElementsField.isAccessible = true
// 宿主的 类加载器
val pathClassLoader: ClassLoader = context.classLoader
// DexPathList类的对象
val hostPathListObj = pathListField[pathClassLoader]
// 宿主的 dexElements
val hostDexElements = dexElementsField[hostPathListObj] as Array<*>
// 插件的 类加载器
val dexClassLoader = pluginClassLoader ?: return false
// DexPathList类的对象
val pluginPathListObj = pathListField[dexClassLoader]
// 插件的 dexElements
val pluginDexElements = dexElementsField[pluginPathListObj] as Array<*>
val hostDexSize = hostDexElements.size
val pluginDexSize = pluginDexElements.size
// 宿主dexElements = 宿主dexElements + 插件dexElements
// 创建一个新数组
val newDexElements = hostDexElements.javaClass.componentType?.let
newInstance(it, hostDexSize + pluginDexSize)
as Array<*>
System.arraycopy(hostDexElements, 0, newDexElements, 0, hostDexSize)
System.arraycopy(pluginDexElements, 0, newDexElements, hostDexSize, pluginDexSize)
// 赋值 hostDexElements = newDexElements
dexElementsField[hostPathListObj] = newDexElements
return true
catch (e: Exception)
println("mergeDexElement: $e")
return false
这里的原理就不多介绍了,网上相关的文章已经有很多了,如有不了解的可以自行搜索。这里我使用的代码基本上也是从其他地方搬过来参考的。上面 PluginManager
类中定义了三个方法:loadPlugin()
方法负责将 assets
中的 apk
拷贝到外置存储卡中应用的缓存目录,并定义一个用于加载插件中的 Class
的 DexClassLoader
;loadClass()
方法就是使用该ClassLoader根据指定的className
进行加载并返回 Class
的;mergeDexElement()
方法则是将插件中的dexElements
数组合并到宿主的dexElements
数组中,以便加载的插件Class
能被宿主应用识别。
接下来定义一个 PluginViewModel
,分别针对上面 PluginManager
中的三个方法进行调用处理,并向 Composable
公开相应的状态:
class PluginViewModel: ViewModel()
private val _isPluginLoadSuccess = MutableStateFlow(false)
val isPluginLoadSuccess = _isPluginLoadSuccess.asStateFlow()
private val _isMergeDexSuccess = MutableStateFlow(false)
val isMergeDexSuccess = _isMergeDexSuccess.asStateFlow()
var pluginActivityClass by mutableStateOf<Class<*>?>(null)
private set
fun loadPlugin(context: Context)
viewModelScope.launch
withContext(Dispatchers.IO)
PluginManager.loadPlugin(context)
if (PluginManager.pluginClassLoader != null)
_isPluginLoadSuccess.value = true
fun mergeDex(context: Context)
viewModelScope.launch
withContext(Dispatchers.IO)
if (PluginManager.mergeDexElement(context))
_isMergeDexSuccess.value = true
fun loadClass(name: String)
viewModelScope.launch
withContext(Dispatchers.IO)
pluginActivityClass = PluginManager.loadClass(name)
最后就是一个用于的测试页面,定义一个 HostScreen
作为宿主中的页面进行展示:
const val PluginActivityClassName = "com.fly.compose.plugin.news.PluginActivity"
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun HostScreen(viewModel: PluginViewModel = viewModel())
val context = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
)
Text(text = "当前是宿主中的Composable页面")
Button(onClick = viewModel.loadPlugin(context) )
Text(text = "点击加载插件Classloader")
val isLoadSuccess = viewModel.isPluginLoadSuccess.collectAsStateWithLifecycle()
Text(text = "插件Classloader是否加载成功:$isLoadSuccess.value")
if (isLoadSuccess.value)
Button(onClick = viewModel.mergeDex(context) )
Text(text = "点击合并插件Dex到宿主中")
val isMergeDexSuccess = viewModel.isMergeDexSuccess.collectAsStateWithLifecycle()
Text(text = "合并插件Dex到宿主是否成功:$isMergeDexSuccess.value")
if (isMergeDexSuccess.value)
Button(onClick = viewModel.loadClass(PluginActivityClassName) )
Text(text = "点击加载插件中的 PluginActivity.Class")
if (viewModel.pluginActivityClass != null)
Text(text = "加载插件中的 PluginActivity.Class 的结果:\\n$viewModel.pluginActivityClass?.canonicalName")
val intent = Intent(context, viewModel.pluginActivityClass)
Button(onClick =
context.startActivity(intent.apply putExtra("type", "NewsList") )
)
Text(text = "点击显示插件中的 NewsList 页面")
Button(onClick =
context.startActivity(intent.apply putExtra("type", "NewsDetail") )
)
Text(text = "点击显示插件中的 NewsDetail 页面")
运行效果:
可以看到,这种方式是完全可行的,几乎毫无压力。
对于这种占坑的方式,它的好处是每个插件只需要提供一个用于在宿主中占位的 PluginActivity
即可,这是相对于以前传统的View开发而言的,因为以前 View 并不能承担屏幕级内容展示的角色而且也没有独立的导航功能,所以需要借助大量的 Activity
类,如果在以前传统的开发中只允许你用一个Activity
类,然后页面在不同的View之间切来切去,恐怕要疯掉了。但是现在不同了,Composable 组件可独立负责屏幕级内容的展示而且也具备独立于Activity的导航功能,它可以独挑大梁,所以说基本不需要太多的 Activity 类,需要在宿主中占位的 Activity 数量自然也就很少。
但是这种方式并不一定能满足所有的场景,它的优点也是它的缺点,试想每一个插件都需要提供一个占位的Activity,插件多的情况下还是有可能出现大量的Activity类,还有一个严重的问题是,这种方式只能以 “跳转” 的形式打开新的页面来展示,因为借助的是一个Activity来当做 Composable的容器,也就是说,假如我想在当前页面的某个区域显示来自插件中的某个Composable组件,这种方式就无法实现。
直接加载插件中的 Composable 组件
为了能在宿主中当前页面的某个局部区域显示来自插件中的Composable
组件,就不能采取占坑Activity
做跳板的这种方式了,我们可以考虑去掉插件中的这个Activity,也就是说每个插件中只保留纯 Composable 的组件代码(纯kotlin
代码),然后打成apk
插件给宿主加载,既然宿主中都可以加载插件中的类了,那应该可以很轻松地通过反射直接调用插件中的Composable函数。
因为 kotlin
代码在最终被编译成DEX
文件之前,要先翻译成对应的Java
代码,而我们知道在Java
当中是没有顶层函数这种概念的,每一个Class
文件都必须对应一个独立的Java
类且Class
文件的名称必须和Java
类的名称保持一致。因此不管我们的 Composable 组件是写在哪个 xx.kt
文件当中,它最终都会被翻译成一个 Java
类,然后我们在宿主中加载该 Java
类并调用该类中的 Composable
方法不就可以了。
这个想法似乎很完美,但是事情并没有想象中的那样简单,很快我就发现了一个残酷的现实,我们知道,Compose 编译器会在编译过程中对 Composable 函数施加一些 “黑魔法”,它会篡改编译期的 IR,因此最终的 Composable 函数会被添加一些额外的参数。例如,前面代码中的 NewsList.kt
和 NewsDetail.kt
,使用反编译工具查看它们最终的形态是长下面这样:
这里可以看到 Compose 编译器为每个 Composable 函数注入了一个 $composer
参数(用于重组)和一个 $changed
参数(用于参数比较和跳过重组),也就是说即便一个无参的 Composable 函数也会被注入这两个参数,那么这就有问题了,即便我们能在宿主中加载该类并通过反射获取了 Composable 函数的句柄引用,但是我们却无法调用它们,因为我们无法提供 $composer
和 $changed
参数,只有 Compose runtime 才知道如何提供这些参数。
这就很尴尬了,这相当于我们想调用一个只有上帝才知道该如何去调用的方法。
那么这样难道就没有办法了吗?其实我们想要的就是在宿主中调用 Composable 函数而已,可以换一种思路,既然直接调用不行,那就间接地调用。
首先,我们可以通过在一个类中定义 Composable lambda 类型的属性来存储 Composable 函数,也就是提供一个 Composable 函数类型的属性成员。例如可以这样写:
class ComposeProxy
val content1 : (@Composable () -> Unit) =
MyBox(Color.Red, "我是插件中的Composable组件1")
val content2 : (@Composable () -> Unit) =
MyBox(Color.Magenta, "我是插件中的Composable组件2")
@Composable
fun MyBox(color: Color, text: String)
Box(
modifier = Modifier.requiredSize(150.dp).background(color).padding(10.dp),
contentAlignment = Alignment.Center
)
Text(text = text, color = Color.White, fontSize = 15.sp)
这里 ComposeProxy
类中的成员属性 content1
和 content2
的类型都是 Composable
函数类型,即 @Composable () -> Unit
,实际上就是定义了两个 Composable lambda
。在其 lambda block 块中可以调用真正的 Composable 业务组件,因为这本质上还是在Composable中调用另一个Composable。至于为什么要这样写,可看下面编译后的结果:
可以看到,翻译成 Java 代码之后,ComposeProxy
中的 content1
和 content2
变成了两个 public 方法 getContent1()
和 getContent2()
,而这两个方法是没有参数的,因此我们就可以通过加载类后反射调用它们。注意到它们返回的类型是 Function2<Composer, Integer, Unit>
,它其实对应的就是 Kotlin 中的 @Composable () -> Unit
函数类型,因为在 Java 的世界中没有所谓的函数类型,取而代之的是使用类似函数的接口类型来对应Kotlin 中的函数类型(Function0...Function22
,最多有22个)。
因此我们可以认为在编译期, Function2<Composer, Integer, Unit>
和 @Composable () -> Unit
二者是等价的,因为后者会被翻译成前者。
其实我们不必关心返回的到底是 Function
几,因为我们最终会通过 Java 的反射API来调用 getContent1()
和 getContent2()
方法,也就是执行 Method.invoke()
,它返回的是一个 Object
对象(如果是用 kotlin 代码来写那就是返回一个 Any
类型的对象),因此我们可以在编写加载插件代码的时候将这个 Object
(Any
) 对象强转成 @Composable () -> Unit
函数类型。然后我们就在宿主中得到了一个 @Composable () -> Unit
函数类型的对象,那么我们就可以调用该函数对象的 invoke
方法了(即调用了 Composable 函数)。
下面修改一下 PluginViewModel
,在其中添加如下代码:
class PluginViewModel: ViewModel()
// ...省略其它无关代码
val composeProxyClassName = "com.fly.compose.plugin.news.ComposeProxy"
var pluginComposable1 by mutableStateOf<@Composable () -> Unit>()
var pluginComposable2 by mutableStateOf<@Composable () -> Unit>()
var isLoadPluginComposablesSuccess by mutableStateOf(false)
fun loadPluginComposables()
viewModelScope.launch
withContext(Dispatchers.IO)
val composeProxyClass = PluginManager.loadClass(composeProxyClassName)
composeProxyClass?.let proxyClass ->
val getContent1Method: Method = proxyClass.getDeclaredMethod("getContent1")
val getContent2Method: Method = proxyClass.getDeclaredMethod("getContent2")
val obj = proxyClass.newInstance()
pluginComposable1 = getContent1Method.invoke(obj) as以上是关于Jetpack Compose 中的动态加载插件化技术探索的主要内容,如果未能解决你的问题,请参考以下文章
如何从 Jetpack Compose 中的 URL 加载图像? [复制]
Jetpack Compose 无限加载列表(滚到底部自动加载更多)