01 Introduction We have previously discussed KMM, Kotlin Multiplatform Mobile, a cross-platform framework for mobile devices released by Kotlin. The conclusion at the time was that KMM advocates extracting the shared logic, encapsulating it into an Android (Kotlin/JVM) aar and an iOS (Kotlin/Native) framework by KMM, and then providing it to the View layer for calling, thereby saving some workload. What is shared is the logic, not the UI. (1) In fact, at this time we know that Kotlin's cross-platform on mobile terminals is definitely not limited to sharing the logic layer. As Compose matures, JetBrains launched Compose-Multiplatform to achieve cross-platform on mobile, Web, and desktop terminals from the UI level. Considering the differences in screen size and interaction methods, sharing between Android and iOS will greatly promote development efficiency. For example, Flutter is now very mature. What's exciting is that Compose-Multiplatform has currently released an alpha version that supports the iOS system. Although it is still in the experimental development stage, we have already started trying to use it. 02 Jetpack-Compose and Compose-Multiplatform As an Android developer, we are all very familiar with Jetpack-Compose, which is a new generation of declarative UI toolkit launched by Google for Android. It is completely based on Kotlin and naturally has a cross-platform basis for use. Based on Jetpack-Compose, JetBrains has successively released compose-desktop, compose-web and compose-iOS, so that Compose can run on more different platforms, which is what we are going to talk about today, Compose-Multiplatform. In terms of general APIs, Compose-Multiplatform is always consistent with Jetpack-Compose, the only difference is that the package name has changed. Therefore, as an Android developer, when using Compose-Multiplatform, we can migrate the Jetpack-Compose code to Compose-Multiplatform at a low cost: picture 03Use Since it is a UI framework, let's implement a simple and very common business requirement on the mobile terminal: Request data from the server and display it in the UI in a list format. What we want to explain here is that Compose-Multiplatform is to be used in conjunction with KMM, where KMM is responsible for compiling shared modules into Android aar and iOS framework, and Compose-Multiplatform is responsible for the implementation of UI-level interaction and drawing. First, let's review the organizational structure of the KMM project: picture AndroidApp and iosApp are the main engineering modules for Android and iOS platforms respectively, and shared is a shared logic module for AndroidApp and iosApp to call. In the shared module: - commonMain is a common module. The code of this module is independent of the platform. It declares some APIs through the expected keyword (the implementation of the declaration is in the platform module);
- androidMain and iosMain represent the Android and iOS platforms respectively, and are implemented in the platform module through the actual keyword.
For details on the configuration and usage of the kmm project, how to run it, and how to compile it, please refer to the previous article. I will not go into details here. (2) Next, let's see how Compose-Multiplatform is implemented based on the kmm project. 1. Add configuration Declare the compose plugin in the settings.gradle file: plugins{ //... val composeVersion = extra["compose.version"] as String id("org.jetbrains.compose").version(composeVersion) } The compose.version is declared in gradle.properties. It should be noted that there are requirements for the current Compose-Multiplatform version. You can refer to the official specific configuration. (3) #Versions kotlin.versinotallow=1.8.20 agp.versinotallow=7.4.2 compose.versinotallow=1.4.0 Then reference the declared plug-in in the build.gradle file of the shared module as follows: plugins { //... id("org.jetbrains.compose") } At the same time, we need to configure the directory of the compose static resource file in the build.gradle file as follows: android { //... sourceSets["main"].resources.srcDirs("src/commonMain/resources") } cocoapods { //... extraSpecAttributes["resources"] = "['src/commonMain/resources/**', 'src/iosMain/resources/**']" } This means that when looking for resource files such as images, they will be searched from the src/commonMain/resources/ directory, as shown below: Since compose-iOS is still in the experimental stage, we need to add the following code to the gradle.properties file to enable UIKit: org.jetbrains.compose.experimental.uikit.enabled=true Finally, we need to add compose dependencies to commonMain: val commonMain by getting { dependencies { //... implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) // //implementation(compose.materialIconsExtended) // TODO not working on iOS for now @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) implementation(compose.components.resources) implementation(compose.ui) } } Well, our configuration is complete, and we will start writing business code. Since we are getting data from the server, we must encapsulate a network module. Next, we will use ktor to encapsulate a simple network module. 2. Network module First, let's add dependencies to the build.gradle file of the shared module as follows: val commonMain by getting { dependencies { implementation("io.ktor:ktor-client-core:$ktor_version")//core implementation("io.ktor:ktor-client-cio:$ktor_version")//CIO implementation("io.ktor:ktor-client-logging:$ktor_version")//Logging implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")//Json格式化//... } } Next, we encapsulate the simplest HttpUtil, including post and get requests; package com.example.sharesample import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* import io.ktor.client.plugins.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.coroutines.* import kotlinx.serialization.json.Json class HttpUtil{ companion object{ val client: HttpClient = HttpClient(CIO) { expectSuccess = true engine { maxConnectionsCount = 1000 requestTimeout = 30000 endpoint { maxConnectionsPerRoute = 100 pipelineMaxSize = 20 keepAliveTime = 30000 connectTimeout = 30000 } } install(Logging) { logger = Logger.DEFAULT level = LogLevel.HEADERS } install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = false }) } } suspend inline fun <reified T> get( url: String,//请求地址): T? { return try { val response: HttpResponse = client.get(url) {//GET请求contentType(ContentType.Application.Json)//content-type } val data: T = response.body() data } catch (e: ResponseException) { print(e.response) null } catch (e: Exception) { print(e.message) null } } suspend inline fun <reified T> post( url: String, ): T? {//coroutines 中的IO线程return try { val response: HttpResponse = client.post(url) {//POST请求contentType(ContentType.Application.Json)//content-type } val data: T = response.body() data } catch (e: ResponseException) { print(e.response) null } catch (e: Exception) { print(e.message) null } } } } The code is very straightforward, defining the HttpClient object and making basic settings to implement network requests. Let's define the data structure returned by the interface request. 3. Returned data structure package com.example.sharesample.bean @kotlinx.serialization.Serializable class SearchResult { var count: Int? = null var resInfos: List<ResInfoBean>? = null } package com.example.sharesample.bean @kotlinx.serialization.Serializable class ResInfoBean { var name: String? = null var desc: String? = null } Next, let's see how the request is sent. 4. Send a request Then we define a SearchApi: package com.example.sharesample import androidx.compose.material.Text import androidx.compose.runtime.* import com.example.sharesample.bean.SearchResult import io.ktor.client.plugins.logging.* import kotlinx.coroutines.* class SearchApi { suspend fun search(): SearchResult { Logger.SIMPLE.log("search2") var result: SearchResult? = HttpUtil.get(url = "http://h5-yapi.sns.sohuno.com/mock/229/api/v1/resInfo/search") if (result == null) { result = SearchResult() } return result } } The search() method is implemented. Next, let's see how the view layer is implemented and how the data is bound. 5. Implementation of View layer We create a SearchCompose: package com.example.sharesample import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.sharesample.bean.SearchResult import io.ktor.client.plugins.logging.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.job import kotlinx.coroutines.launch import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.resource class SearchCompose { private val searchApi = SearchApi() private var isInit = false @OptIn(ExperimentalResourceApi::class) @Composable fun searchCompose() { var searchResult by remember { mutableStateOf<SearchResult>(SearchResult()) } if (!isInit) { scope().launch { val result = async { searchApi.search() } searchResult = result.await() } isInit = true } Column { Text( "Total: ${searchResult.count ?: 0}", style = TextStyle(fontSize = 20.sp), modifier = Modifier.padding(start = 20.dp, top = 20.dp) ) val scrollState = rememberLazyListState() if (searchResult.resInfos != null) { LazyColumn( state = scrollState, modifier = Modifier.padding( top = 14.dp, bottom = 50.dp, end = 14.dp, start = 14.dp ) ) { items(searchResult.resInfos!!) { item -> Box( modifier = Modifier.padding(top = 20.dp).fillMaxWidth() .background(color = Color.LightGray, shape = RoundedCornerShape(10.dp)) .padding(all = 20.dp) ) { Column { Row(verticalAlignment = Alignment.CenterVertically) { val picture = "1.jpg" var imageBitmap: ImageBitmap? by remember(picture) { mutableStateOf( null ) } LaunchedEffect(picture) { try { imageBitmap = resource(picture).readBytes().toImageBitmap() } catch (e: Exception) { } } if (imageBitmap != null) { Image( bitmap = imageBitmap!!, "", modifier = Modifier .size(60.dp) .clip(RoundedCornerShape(10.dp)) ) } Text( item.name ?: "name", style = TextStyle(color = Color.Yellow), modifier = Modifier.padding(start = 10.dp) ) } Text(item.desc ?: "desc", style = TextStyle(color = Color.White)) } } } } } } } } @Composable fun scope(): CoroutineScope { var viewScope = rememberCoroutineScope() return remember { CoroutineScope(SupervisorJob(viewScope.coroutineContext.job) + ioDispatcher) } } In searchCompose(), we see that a coroutine is started when sending a request. The scope() method specifies the scope. In addition, we also define the implementation of ioDispatcher on different platforms. The specific declarations are as follows: expect val ioDispatcher: CoroutineDispatcher Implementation on Android: actual val ioDispatcher = Dispatchers.IO Implementation on iOS: actual val ioDispatcher = Dispatchers.IO It should be noted that on the Android platform, Dispatchers.IO is in jvmMain/Dispatchers, and on the iOS platform, Dispatchers.IO is in nativeMain/Dispatchers. The two are different. After obtaining the server data, we use LazyColumn to implement the list. There are pictures and texts displayed. For the convenience of explanation, we use the pictures in the local resources directory for the picture data, and the text displays the data returned by the server. Let me explain the loading of pictures. 6. Image loading The specific implementation is as follows: val picture = "1.jpg" var imageBitmap: ImageBitmap? by remember(picture) { mutableStateOf( null ) } LaunchedEffect(picture) { try { imageBitmap = resource(picture).readBytes().toImageBitmap() } catch (e: Exception) { } } if (imageBitmap != null) { Image( bitmap = imageBitmap!!, "", modifier = Modifier .size(60.dp) .clip(RoundedCornerShape(10.dp)) ) } First, we create a remember object of ImageBitmap. Since resource(picture).readBytes() is a suspend function, we need to use LaunchedEffect to execute it. The purpose of this code is to read resources from the resources directory into memory, and then we implement toImageBitmap() on different platforms to convert it into Bitmap. - Declaration of toImageBitmap():
expect fun ByteArray.toImageBitmap(): ImageBitmap fun ByteArray.toAndroidBitmap(): Bitmap { return BitmapFactory.decodeByteArray(this, 0, size) } actual fun ByteArray.toImageBitmap(): ImageBitmap = Image.makeFromEncoded(this).toComposeImageBitmap() Well, through the above method, we can load local images. So far, the corresponding implementation of Compose is complete. So how is it referenced by the views of Android and iOS? We are already very familiar with the Android side. Just like the calling method of Jetpack-Compose, you can directly call it in MainActivity: class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyApplicationTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { SearchCompose().searchCompose() } } } } } The iOS side is a little more complicated. Let's first look at the implementation of iOSApp.swift under the iosApp module: import UIKit import shared @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) let mainViewController = Main_iosKt.MainViewController() window?.rootViewController = mainViewController window?.makeKeyAndVisible() return true } } The key code is these two lines: let mainViewController = Main_iosKt.MainViewController() window?.rootViewController = mainViewController A MainViewController object is created and assigned to the rootViewController of the window. Where and how is this MainViewController defined? Let's go back to the shared module and define a main.ios file, which will be compiled into the Main_iosKt file in the framework. The implementation of main.ios is as follows: package com.example.sharesample import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.window.ComposeUIViewController import platform.UIKit.UIViewController @Suppress("FunctionName", "unused") fun MainViewController(): UIViewController = ComposeUIViewController { MaterialTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { SearchCompose().searchCompose() } } } We can see that a UIViewController object MainViewController is created here. This is the bridge between the iOS side and Compose. Next, let's take a look at the effects on Android and iOS. picture picture Well, so far, we have seen how to implement a simple list business logic. Since Compose-Multiplatform is not yet mature, there are bound to be many things that need to be reinvented in business implementation. 04 Compose drawing principle on Android Since there are already many related drawing principles of Compose on the Internet, in the next chapter we will just perform a simple source code analysis to illustrate how it generates the UI tree and draws itself. 1. Compose drawing principle on Android The Android side starts by implementing setContent() in onCreate(): setContent { MyApplicationTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background ) { SearchCompose().searchCompose() } } } The implementation of setContent() is as follows: public fun ComponentActivity.setContent( parent: CompositionContext? = null, content: @Composable () -> Unit ) { val existingComposeView = window.decorView .findViewById<ViewGroup>(android.R.id.content) .getChildAt(0) as? ComposeView if (existingComposeView != null) with(existingComposeView) { setParentCompositionContext(parent) setContent(content) } else ComposeView(this).apply { // Set content and parent **before** setContentView // to have ComposeView create the composition on attach setParentCompositionContext(parent) setContent(content) // Set the view tree owners before setting the content view so that the inflation process // and attach listeners will see them already present setOwners() setContentView(this, DefaultActivityContentLayoutParams) } } We can see that it mainly generates ComposeView and then registers the compose content to ComposeView through setContent(content), where ComposeView inherits ViewGroup, and then calls the setContentView() method of ComponentActivity to add ComposeView to the corresponding sub-View in DecorView. By tracing the setContent method of ComposeView: private fun doSetContent( owner: AndroidComposeView, parent: CompositionContext, content: @Composable () -> Unit ): Composition { if (inspectionWanted(owner)) { owner.setTag( R.id.inspection_slot_table_set, Collections.newSetFromMap(WeakHashMap<CompositionData, Boolean>()) ) enableDebugInspectorInfo() } // 创建Composition对象,传入UiApplier val original = Composition(UiApplier(owner.root), parent) val wrapped = owner.view.getTag(R.id.wrapped_composition_tag) as? WrappedComposition ?: WrappedComposition(owner, original).also { owner.view.setTag(R.id.wrapped_composition_tag, it) } // 传入content函数wrapped.setContent(content) return wrapped } We found that there were two main things we did: - Create a Composition object and pass it to UiApplier
- Pass in the content function
The definition of UiApplier is as follows: internal class UiApplier( root: LayoutNode ) : AbstractApplier<LayoutNode>(root) Holds a LayoutNode object, which is described as follows: An element in the layout hierarchy, built with compose UI You can see that when Compose is rendered, each component is a LayoutNode, and finally a LayoutNode tree is formed to describe the UI interface. How is LayoutNode created? 1) LayoutNode Let's assume that we create an Image and take a look at the implementation of Image: fun Image( painter: Painter, contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null ) { //... Layout( {}, modifier.then(semantics).clipToBounds().paint( painter, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter ) ) { _, constraints -> layout(constraints.minWidth, constraints.minHeight) {} } } Continue to track the implementation of Layout(): @Composable inline fun Layout( content: @Composable @UiComposable () -> Unit, modifier: Modifier = Modifier, measurePolicy: MeasurePolicy ) { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val viewConfiguration = LocalViewConfiguration.current ReusableComposeNode<ComposeUiNode, Applier<Any>>( factory = ComposeUiNode.Constructor, update = { set(measurePolicy, ComposeUiNode.SetMeasurePolicy) set(density, ComposeUiNode.SetDensity) set(layoutDirection, ComposeUiNode.SetLayoutDirection) set(viewConfiguration, ComposeUiNode.SetViewConfiguration) }, skippableUpdate = materializerOf(modifier), content = content ) } @Composable @ExplicitGroupsComposable inline fun <T, reified E : Applier<*>> ReusableComposeNode( noinline factory: () -> T, update: @DisallowComposableCalls Updater<T>.() -> Unit, noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit, content: @Composable () -> Unit ) { if (currentComposer.applier !is E) invalidApplier() currentComposer.startReusableNode() if (currentComposer.inserting) { currentComposer.createNode(factory) } else { currentComposer.useNode() } Updater<T>(currentComposer).update() SkippableUpdater<T>(currentComposer).skippableUpdate() currentComposer.startReplaceableGroup(0x7ab4aae9) content() currentComposer.endReplaceableGroup() currentComposer.endNode() } The ComposeUiNode object is created here, and LayoutNode is the implementation class of ComposeUiNode. Let's take a look at Composition. 2) Composition From the name, the role of Composition is to combine LayoutNode. WrappedComposition inherits Composition: private class WrappedComposition( val owner: AndroidComposeView, val original: Composition ) : Composition, LifecycleEventObserver Let's trace its implementation of setContent(): override fun setContent(content: @Composable () -> Unit) { owner.setOnViewTreeOwnersAvailable { if (!disposed) { val lifecycle = it.lifecycleOwner.lifecycle lastContent = content if (addedToLifecycle == null) { addedToLifecycle = lifecycle // this will call ON_CREATE synchronously if we already created lifecycle.addObserver(this) } else if (lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) { original.setContent { @Suppress("UNCHECKED_CAST") val inspectionTable = owner.getTag(R.id.inspection_slot_table_set) as? MutableSet<CompositionData> ?: (owner.parent as? View)?.getTag(R.id.inspection_slot_table_set) as? MutableSet<CompositionData> if (inspectionTable != null) { inspectionTable.add(currentComposer.compositionData) currentComposer.collectParameterInformation() } LaunchedEffect(owner) { owner.boundsUpdatesEventLoop() } CompositionLocalProvider(LocalInspectionTables provides inspectionTable) { ProvideAndroidCompositionLocals(owner, content) } } } } } } When the page lifecycle is in the CREATED state, execute original.setContent(): override fun setContent(content: @Composable () -> Unit) { check(!disposed) { "The composition is disposed" } this.composable = content parent.composeInitial(this, composable) } Call the composeInitial() method of parent. We will not continue to track this code. Its ultimate function is to combine the layout and create a parent-child dependency relationship. 3) Measure and Layout In AndroidComposeView, dispatchDraw() implements the measureAndLayout() method: override fun measureAndLayout(sendPointerUpdate: Boolean) { trace("AndroidOwner:measureAndLayout") { val resend = if (sendPointerUpdate) resendMotionEventOnLayout else null val rootNodeResized = measureAndLayoutDelegate.measureAndLayout(resend) if (rootNodeResized) { requestLayout() } measureAndLayoutDelegate.dispatchOnPositionedCallbacks() } } fun measureAndLayout(onLayout: (() -> Unit)? = null): Boolean { var rootNodeResized = false performMeasureAndLayout { if (relayoutNodes.isNotEmpty()) { relayoutNodes.popEach { layoutNode -> val sizeChanged = remeasureAndRelayoutIfNeeded(layoutNode) if (layoutNode === root && sizeChanged) { rootNodeResized = true } } onLayout?.invoke() } } callOnLayoutCompletedListeners() return rootNodeResized } Call remeasureAndRelayoutIfNeeded, traverse relayoutNodes, and perform measure and layout for each LayoutNode. The specific implementation is not analyzed. 4) Drawing Let's take Image as an example: fun Image( bitmap: ImageBitmap, contentDescription: String?, modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, contentScale: ContentScale = ContentScale.Fit, alpha: Float = DefaultAlpha, colorFilter: ColorFilter? = null, filterQuality: FilterQuality = DefaultFilterQuality ) { val bitmapPainter = remember(bitmap) { BitmapPainter(bitmap, filterQuality = filterQuality) } Image( painter = bitmapPainter, contentDescription = contentDescription, modifier = modifier, alignment = alignment, contentScale = contentScale, alpha = alpha, colorFilter = colorFilter ) } The main drawing work is done by BitmapPainter, which inherits from Painter. override fun DrawScope.onDraw() { drawImage( image, srcOffset, srcSize, dstSize = IntSize( [email protected](), [email protected]() ), alpha = alpha, colorFilter = colorFilter, filterQuality = filterQuality ) } Implement drawImage() in the onDraw() method: override fun drawImage( image: ImageBitmap, srcOffset: IntOffset, srcSize: IntSize, dstOffset: IntOffset, dstSize: IntSize, /*FloatRange(from = 0.0, to = 1.0)*/ alpha: Float, style: DrawStyle, colorFilter: ColorFilter?, blendMode: BlendMode, filterQuality: FilterQuality ) = drawParams.canvas.drawImageRect( image, srcOffset, srcSize, dstOffset, dstSize, configurePaint(null, style, alpha, colorFilter, blendMode, filterQuality) ) And finally, it is drawn on Canvas. Through the above analysis, we know that Compose is not mapped one-to-one with native controls, but like Flutter, it has its own UI organization method, and finally calls the self-drawing engine to draw directly on Canvas. The self-drawing engine used on Android and iOS is skiko. What is this skiko? It is actually the abbreviation of Skia for Kotlin (Flutter also uses the Skia engine for drawing on the mobile side). In fact, not only on the mobile side, we can see from the following screenshots that Compose's desktop and web drawing actually use skiko: picture For more information about Skiko, please refer to the official link at the end of the article. (4) That's it, we have finished talking about the drawing principle of Compose on the Android side. Students who are interested in drawing on other sides can check the corresponding source code by themselves. The details are different, but the concept is the same: create your own Compose tree, and finally call the self-drawing engine to draw on the Canvas. 05 Compose-Multiplatform and Flutter Why do we single them out? Because when we were researching Compose-Multiplatform, we found that it has similar principles to Flutter, so there may be competition in the future. Competition means that developers need to choose if they want to use cross-platform frameworks in their projects. So let's compare these two frameworks: In the previous KMM article, we compared KMM and Flutter, and the conclusion is: - KMM mainly implements shared logic, and it is recommended that each platform handle the implementation of the UI layer.
- Flutter is a shared UI layer.
At that time, although both were cross-platform, they had different goals and did not seem to form competition. After Compose-Multiplatform joined, combined with KMM, both logic and UI could be shared. And from the drawing principle, both Compose and Flutter create their own View tree and render it through their own drawing engine, so there is not much difference in principle. In addition, Kotlin and Compose are officially recommended by Android, so for Android students, there is basically no learning cost. Personally, I think that if Compose-Multiplatform is more mature and the stable version is released, the competition with Flutter will be very fierce. 06 Conclusion Although Compose-Multiplatform is not mature yet, through the analysis of its principles, we can foresee that it will become a strong cross-platform competitor in the future when combined with KMM. Especially for Android developers, you can use KMM first and combine it with Compose to implement some low-coupling businesses. After the stable version of Compose-iOS is released in the future, you can happily carry out dual-end development and save development costs. refer to: (1) https://www.jianshu.com/p/e1ae5eaa894e (2) https://www.jianshu.com/p/e1ae5eaa894e (3) https://github.com/JetBrains/compose-multiplatform-ios-android-template (4) https://github.com/JetBrains/skiko |