In the traditional Android development model, because the interface is overly dependent on Activity
such Fragment
components as , there are often a large number of Activity
classes in a business module, so many plug-in frameworks have been born, and these plug-in frameworks basically try to use various A Hook/reflection method to solve the problem of using unregistered components. After entering the world of Jetpack Compose, Activity
the role of is downplayed. Since a Composable
component can undertake a screen-level display, we no longer need so many Activity
classes in our application. As long as you like, you can even create a Activity
single Pure Compose application.
This article mainly tries to explore several feasible solutions that can implement plug-in/dynamic loading in Jetpack Compose.
Access the Composable component in the plug-in in the way of Activity occupation
In fact, this method can also be done in traditional View development, but since we can only use one Activity in Compose, and the rest of the pages are implemented using Composable components, it feels more suitable for it. Therefore, the main idea is AndroidManifest.xml
to register a pit-occupied Activity
class in the host application, which Activity
actually exists in the plug-in, and then load the Activity
Class in the plug-in in the host, start the class in the plug-in Activity
and pass different parameters to display different Composable components. To put it bluntly, it is to use an empty shell Activity as a springboard to display different Composables.
First, create a new module module in the project, and 'com.android.library'
change the plugins configuration in build.gradle 'com.android.application'
, because this module is developed as an application module, and finally provide plugins in the form of apk. Then create a new PluginActivity
Activity as a springboard, and create two Composable pages for testing.
PluginActivity
The content is as follows:
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()
}
}
}
}
}
Here is a simple judgment based on the type read by the intent. If it is NewsList, a composable page of news list will be displayed. If it is NewsDetail, a composable page of news details will be displayed.
NewsList
The content is as follows:
@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
The content is as follows:
@Composable
fun NewsDetail() {
Column {
Text(text = "我是插件中的新闻详情页面".repeat(100))
}
}
Execute assembleDebug, copy the generated apk file to the assets directory of the host app module, so that it can be copied to the memory card after the application starts (it should be downloaded from the server in the actual project).
AndroidManifest.xml
Then register the defined in the plug-in in the host app module PluginActivity
to occupy the pit. It doesn't matter if it becomes popular here, and it will not affect the packaging.
Then define a class in the app module PluginManager
, which is mainly responsible for loading the plug-in 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
}
}
}
I won’t introduce much about the principle here. There are already many related articles on the Internet. If you don’t understand it, you can search it yourself. The code I use here is basically moved from other places for reference. The above PluginManager
class defines three methods: loadPlugin()
the method is responsible for copying assets
the in the external memory card to the cache directory of the application, and defines a for loading the apk
in the plug-in ; the method is to use the ClassLoader to load and return according to the specified ; The method is to merge the array in the plug -in into the array of the host, so that the loaded plug-in can be recognized by the host application.Class
DexClassLoader
loadClass()
className
Class
mergeDexElement()
dexElements
dexElements
Class
Next, define one , call and process the three methods in PluginViewModel
the above respectively , and expose the corresponding state to :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)
}
}
}
}
Finally, there is a test page for use, which defines a HostScreen
page to be displayed as a page in the host:
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 页面")
}
}
}
}
}
}
running result:
It can be seen that this method is completely feasible and almost stress-free.
For this way of occupying pits, its advantage is that each plug-in only needs to provide one for occupying a place in the host PluginActivity
, which is compared with the previous traditional View development, because View could not bear the screen in the past. Level content display and no independent navigation function, so you need to use a lot of Activity
classes. If you were only allowed to use one class in the traditional development before Activity
, and then the page was switched between different Views, I am afraid it will be crazy. Lost. But now it is different. Composable components can be independently responsible for the display of screen-level content and also have a navigation function independent of the Activity. It can be the leader alone, so basically there is no need for too many Activity classes, and the number of Activities that need to occupy space in the host Naturally, there are very few.
However, this method does not necessarily satisfy all scenarios. Its advantages are also its disadvantages. Just imagine that each plug-in needs to provide a placeholder Activity. When there are many plug-ins, there may still be a large number of Activity classes. There are also A serious problem is that this method can only be displayed by opening a new page in the form of "jump", because it uses an Activity as a Composable container, that is, if I want to display in a certain page of the current page The area display comes from a Composable component in the plugin, which is not possible in this way.
Directly load Composable components in plugins
In order to display the components from the plug-in in a local area of the current page in the host Composable
, we cannot take Activity
the method of occupying the pit as a springboard. We can consider removing the Activity in the plug-in, that is to say, only Keep the pure Composable component code (pure kotlin
code), and then make it into apk
a plug-in for the host to load. Since the classes in the plug-in can be loaded in the host, it should be easy to directly call the Composable function in the plug-in through reflection.
Because the code must be translated into the corresponding code before kotlin
it is finally compiled into a file , and we know that there is no such concept as a top-level function , each file must correspond to an independent class and the name of the file must be the same as that of the class The name remains the same. So no matter which file our Composable component is written in , it will eventually be translated into a class, and then we load the class in the host and call the method in the class .DEX
Java
Java
Class
Java
Class
Java
xx.kt
Java
Java
Composable
This idea seems to be perfect, but things are not as simple as imagined, and soon I discovered a cruel reality, we know that the Compose compiler will apply some "black magic" to the Composable function during the compilation process, it will Tampering with the IR at compile time, so some extra parameters will be added to the final Composable function. NewsList.kt
For example, and in the previous code NewsDetail.kt
, use the decompilation tool to view their final form as follows:
Here you can see that the Compose compiler injects a $composer
parameter (for reorganization) and a $changed
parameter (for parameter comparison and skip reorganization) for each Composable function, that is to say, even a Composable function without parameters will be injected into this Two parameters, then there is a problem, even if we can load the class in the host and get the handle reference of the Composable function through reflection, but we can't call them, because we can't provide and parameters, only the Compose $composer
runtime $changed
knows How to provide these parameters.
This is embarrassing, and it's equivalent to wanting to call a method that only God knows how to call.
So is there no way to do this? In fact, what we want is to call the Composable function in the host. We can change the way of thinking, since the direct call is not possible, then call it indirectly.
First, we can store Composable functions by defining properties of Composable lambda type in a class , that is, provide a property member of Composable function type . For example, you can write:
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)
}
}
}
Here the types of the member properties and ComposeProxy
in the class are both function types, that is , two are actually defined . The real Composable business component can be called in its lambda block, because this is essentially calling another Composable in a Composable. As for why you want to write it like this, you can see the compiled result below:content1
content2
Composable
@Composable () -> Unit
Composable lambda
It can be seen that after being translated into Java code, and ComposeProxy
in become two public methods and , and these two methods have no parameters, so we can call them by reflection after loading the class. Notice that the type they return is that it actually corresponds to the function type in Kotlin, because there is no so-called function type in the Java world , and instead, a function-like interface type is used to correspond to the function type in Kotlin ( , at most There are 22).content1
content2
getContent1()
getContent2()
Function2<Composer, Integer, Unit>
@Composable () -> Unit
Function0...Function22
So we can think that at compile time, Function2<Composer, Integer, Unit>
and @Composable () -> Unit
are equivalent, because the latter will be translated into the former.
In fact, we don't have to care about what is returned , because we will eventually call and method Function
through Java's reflection API , that is, execute , and it returns an object (if it is written in kotlin code, it returns an object of type) , so we can force this ( ) object into a function type when writing the code to load the plug-in . Then we get an object of function type in the host, then we can call the method of the function object (that is, call the Composable function).getContent1()
getContent2()
Method.invoke()
Object
Any
Object
Any
@Composable () -> Unit
@Composable () -> Unit
invoke
Modify it below PluginViewModel
and add the following code to it:
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 (@Composable () -> Unit)
pluginComposable2 = getContent2Method.invoke(obj) as (@Composable () -> Unit)
isLoadPluginComposablesSuccess = true
}
}
}
}
}
Modify HostScreen
the test code as follows:
@Composable
fun HostScreen(viewModel: PluginViewModel = viewModel()) {
CommonLayout(viewModel) {
Button(onClick = {
viewModel.loadPluginComposables() }) {
Text(text = "点击加载插件中的 Composables")
}
// 加载成功后调用插件中的Composable函数
if (viewModel.isLoadPluginComposablesSuccess) {
viewModel.pluginComposable1()
viewModel.pluginComposable2()
}
}
}
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
private fun CommonLayout(
viewModel: PluginViewModel = viewModel(),
content: @Composable () -> Unit
) {
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) {
content()
}
}
}
}
Repackage the news_lib module to generate apk, and copy it to the assets directory in the app module, then run the app to see the effect:
It can be seen that we have successfully loaded the Composable components from the plug-in directly in the Composable interface of the host, which is perfect. Activity/Fragment
This also means that we can call anything Composable
from the plugin in the host Composable
. That is to say, we do not need to keep any Activity class in our plug-in, but only keep Composable components and their related business logic codes.
Compared with the traditional plug-in method, this method no longer needs to consider how to bypass the verification of the Activity by AMS, does not need to occupy the Activity in the Manifest, and does not need to find various Hook points in the system source code through the dynamic proxy method Replace the Activity secretly, because the Activity is not needed in the plug-in at all. From then on, plug-in can take a very pure path.
The exploration of Compose's plug-in technology is not finished here, and finally there is another way to try it.
Load Composable components in plugins in Matryoshka mode
This approach was inspired by interoperability Composable
with Android . View
For example, to Composable
display an Android in View
can be done as follows:
@Composable
fun SomeComposable() {
AndroidView(factory = {
context ->
// android.webkit.WebView
WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = WebViewClient()
loadUrl("https://xxxx.com")
}
}, modifier = Modifier.fillMaxSize())
}
Among them AndroidView
is a Composable
function that can nest Android native components inside it View
.
On the other hand, you can set its content by calling of ComposeView
of , because the method of of actually creates a to execute :setContent{}
Composable
Activity
setContent{ }
ComposeView
setContent
ComposeView
is a public class, which means we can also call it like this:
ComposeView(context).apply {
setContent {
ComposableExample()
}
}
Since ComposeView
is a standard Android View
, we can call it like this:
@Composable
fun SomeComposable() {
AndroidView(factory = {
context ->
ComposeView(context).apply {
setContent {
ComposableExample()
}
}
}, modifier = Modifier.fillMaxSize())
}
So our Russian Matryoshka version of the plug-in solution was born:
The plug-in only needs to provide the host with the acquisition method, and the host can use this to embed into the host interface after ComposeView
obtaining the from the plug-in . The inside of the plug-in does not need to be exposed to the host, it can be wrapped inside of .ComposeView
AndroidView
Composable
Composable
ComposeView
Similar to the previous, define a class in the plug-in ComposeViewProxy
, and then define a member attribute of type function type in it Context.(String) -> ComposeView
:
class ComposeViewProxy {
val pluginView: (Context.(String) -> ComposeView) = {
name ->
ComposeView(this).apply {
setContent {
if (name == "content1") {
MyBox(Color.Red, "我是插件中的Composable组件1")
} else if (name == "content2") {
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)
}
}
}
Use the decompilation tool to view the generated plugin apk:
Next, you only need to load the class in the host ComposeViewProxy
, then reflectively call the method, and get the returned result and use it getPluginView()
in the host .AndroidView
Modify PluginViewModel
, add the following code in it:
class PluginViewModel: ViewModel() {
// ...省略其它无关代码
val composeViewProxyClassName = "com.fly.compose.plugin.news.ComposeViewProxy"
var pluginView by mutableStateOf<Context.(String) -> ComposeView>({
ComposeView(this)})
var isLoadPluginViewSuccess by mutableStateOf(false)
fun loadPluginView() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val composeViewProxyClass = PluginManager.loadClass(composeViewProxyClassName)
composeViewProxyClass?.let {
proxyClass ->
val getPluginViewMethod: Method = proxyClass.getDeclaredMethod("getPluginView")
val obj = proxyClass.newInstance()
pluginView = getPluginViewMethod.invoke(obj) as (Context.(String) -> ComposeView)
isLoadPluginViewSuccess = true
}
}
}
}
}
Modify HostScreen
the test code as follows:
@Composable
fun HostScreen(viewModel: PluginViewModel = viewModel()) {
CommonLayout(viewModel) {
Button(onClick = {
viewModel.loadPluginView() }) {
Text(text = "点击加载插件中的 ComposeView")
}
// 加载成功后调用插件中的ComposeView
if (viewModel.isLoadPluginViewSuccess) {
SimpleAndroidView {
context ->
viewModel.pluginView(context, "content1")
}
SimpleAndroidView {
context ->
viewModel.pluginView(context, "content2")
}
}
}
}
@Composable
private fun <T: View> SimpleAndroidView(factory: (Context) -> T) {
AndroidView(
factory = {
context -> factory(context) },
modifier = Modifier.wrapContentSize()
)
}
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
private fun CommonLayout(
viewModel: PluginViewModel = viewModel(),
content: @Composable () -> Unit
) {
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) {
content()
}
}
}
}
running result:
The running effect is the same as the previous solution, and the code logic of this method is almost the same as the previous one, but compared with the previous one, the plug-in of this solution does not need to directly expose Composable
components to the host, but only provides them ComposeView
.
In fact, the plug-ins developed in this way can be mixed with native View
and Composable
at the same time. For example, in the process of project transformation, it is possible that part of the interface needs to be released before the transformation is completed. At this time, this part can still use the original implementation View
. , and another part that uses Composable
the newly implemented part can be ComposeView
embedded into the entire page through , and then the entire page is View
provided to the host as a separate .