当前位置: 首页 > news >正文

Compose-Multiplatform在Android和iOS上的实践

6d7a27dc0a61df5624d01a8ccdbc75b9.jpeg

cf7a58fb7a3abef5036d2faaf60c32ac.gif

本文字数:4680

预计阅读时间:30分钟

557978ff116427b2fa90a6ced1222e61.png

01

简介

之前我们探讨过KMM,即Kotlin Multiplatform Mobile,是Kotlin发布的移动端跨平台框架。当时的结论是KMM提倡将共有的逻辑部分抽出,由KMM封装成Android(Kotlin/JVM)的aar和iOS(Kotlin/Native)的framework,再提供给View层进行调用,从而节约一部分的工作量。共享的是逻辑而不是UI。(1)

其实在这个时候我们就知道Kotlin在移动端的跨平台绝对不是想止于逻辑层的共享,随着Compose的日渐成熟,JetBrains推出了Compose-Multiplatform,从UI层面上实现移动端,Web端,桌面端的跨平台。考虑到屏幕大小与交互方式的不同,Android和iOS之间的共享会极大的促进开发效率。比如现在已经非常成熟的Flutter。令人兴奋的是,Compose-Multiplatform目前已经发布了支持iOS系统的alpha版本,虽然还在开发实验阶段,但我们已经开始尝试用起来了。

02

Jetpack-Compose

Compose-Multiplatform

作为Android开发,Jetpack-Compose我们再熟悉不过了,是Google针对Android推出的新一代声明式UI工具包,完全基于Kotlin打造,天然具备了跨平台的使用基础。JetBrains以Jetpack-Compose为基础,相继发布了compose-desktop,compose-web和compose-iOS ,使Compose可以运行在更多不同平台,也就是我们今天要讲的Compose-Multiplatform。在通用的API上Compose-Multiplatform与Jetpack-Compose时刻保持一致,不同的只是包名发生了变化。因此作为Android开发,我们在使用Compose-Multiplatform时,可以将Jetpack-Compose代码低成本地迁移到Compose-Multiplatform:

1215f2fb6462a26774197cd85a689898.png

03

使用

既然是UI框架,那么我们就来实现一个简单的在移动端非常常规的业务需求:

从服务器请求数据,并以列表形式展现在UI上。

在此我们要说明的是,Compose-Multiplatform是要与KMM配合使用的,其中KMM负责把shared模块编译成Android的aar和iOS的framework,Compose-Multiplatform负责UI层面的交互与绘制的实现。

首先我们先回顾一下KMM工程的组织架构:

0c2f26024b2d8bdb5efeac946d652680.png

其中androidApp和iosApp分别为Android和iOS这两个平台的主工程模块,shared为共享逻辑模块,供androidApp和iosApp调用。shared模块中:

  • commonMain为公共模块,该模块的代码与平台无关,是通过expected关键字对一些api的声明(声明的实现在platform module中);

  • androidMain和iosMain分别Android和ios这两个平台,通过actual关键字在平台模块进行具体的实现。

关于kmm工程的配置与使用方式,运行方式,编译过程原理还是请回顾一下之前的文章,在此不做赘述。(2)

接下来我们看Compose-Multiplatform是怎么基于kmm工程进行的实现。 

1、添加配置

在settings.gradle文件中声明compose插件:

plugins{
//...val composeVersion = extra["compose.version"] as Stringid("org.jetbrains.compose").version(composeVersion)}

其中compose.version在gradle.properties进行了声明。需要注意的是目前Compose-Multiplatform的版本有要求,目前可以参考官方的具体配置。(3)

#Versions
kotlin.version=1.8.20
agp.version=7.4.2
compose.version=1.4.0

之后在shared模块的build.gradle文件中引用声明好的插件如下:

plugins {
//...id("org.jetbrains.compose")
}

同时我们需要在build.gradle文件中配置compose静态资源文件的目录,方式如下:

  • Android:

android {
//...sourceSets["main"].resources.srcDirs("src/commonMain/resources")
}
  • iOS:

cocoapods {
//...extraSpecAttributes["resources"] ="['src/commonMain/resources/**', 'src/iosMain/resources/**']"}

这意味着在寻找如图片等资源文件时,将从src/commonMain/resources/这个目录下寻找,如下图所示:

97f11e912dc417a293ed7365838be992.jpeg

由于目前compose-iOS还处于实验阶段,我们需要在gradle.properties文件中添加如下代码开启UIKit:

org.jetbrains.compose.experimental.uikit.enabled=true

最后我们需要在为commonMain添加compose依赖:

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)}}

好了到此为止我们的配置就完成了,接下来开始写业务代码了。既然是从服务器获取数据,我们肯定得封装一个网络模块,下面我们将使用ktor封装一个简单的网络模块。 

2、网络模块

先我们先在shared模块的build.gradle文件中添加依赖如下:

val commonMain by getting {dependencies {implementation("io.ktor:ktor-client-core:$ktor_version")//coreimplementation("io.ktor:ktor-client-cio:$ktor_version")//CIOimplementation("io.ktor:ktor-client-logging:$ktor_version")//Loggingimplementation("io.ktor:ktor-client-content-negotiation:$ktor_version")implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")//Json格式化
//...}}

接下来我们封装一个最简单的HttpUtil,包含post和get请求;

package com.example.sharesampleimport 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.Jsonclass HttpUtil{companion object{val client: HttpClient = HttpClient(CIO) {expectSuccess = trueengine {maxConnectionsCount = 1000requestTimeout = 30000endpoint {maxConnectionsPerRoute = 100pipelineMaxSize = 20keepAliveTime = 30000connectTimeout = 30000}}install(Logging) {logger = Logger.DEFAULTlevel = LogLevel.HEADERS}install(ContentNegotiation) {json(Json {ignoreUnknownKeys = trueisLenient = trueencodeDefaults = 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}}}
}

代码非常直观,定义了HttpClient对象,进行了基础的设置来实现网络请求。我们来定义一下接口请求返回的数据结构。

3、返回的数据结构

package com.example.sharesample.bean@kotlinx.serialization.Serializable
class SearchResult {var count: Int? = nullvar resInfos: List<ResInfoBean>? = null
}
package com.example.sharesample.bean@kotlinx.serialization.Serializable
class ResInfoBean {var name: String? = nullvar desc: String? = null
}

接下来我们看看是怎么发送的请求。

4、发送请求

然后我们定义个SearchApi:

package com.example.sharesampleimport 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}
}

实现了search()方法。接着我们来看view层的实现与数据的绑定是如何实现的。

5、View层的实现

我们创建一个SearchCompose:

package com.example.sharesampleimport 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.resourceclass SearchCompose {private val searchApi = SearchApi()private var isInit = false@OptIn(ExperimentalResourceApi::class)@Composablefun 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)}
}

在searchCompose()里我们看到了在发送请求时开启了一个协程,scope()方法指定了作用域,除此之外,我们还定义了ioDispatcher在不同平台下的实现,具体的声明如下:

expect val ioDispatcher: CoroutineDispatcher

在Android上的实现:

actual val ioDispatcher = Dispatchers.IO

在ios上的实现:

actual val ioDispatcher = Dispatchers.IO

需要注意的是,Android平台,Dispatchers.IO在jvmMain/Dispatchers,ios平台,Dispatchers.IO在nativeMain/Dispatchers下。两者是不一样的。在获取了服务端数据后,我们使用LazyColumn对列表进行实现。其中有图片和文本的展示。为了方便进行说明,图片数据我们使用本地resources目录下的图片,文本展示的是服务端返回的数据。下面我来说明一下图片的加载。

6、图片加载

具体的实现如下:

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)))
}

先创建了一个ImageBitmap的remember对象,由于resource(picture).readBytes()是个挂起函数,我们需要用LaunchedEffect来执行。这段代码的作用是从resources目录下读取资源到内存中,然后我们在不同平台实现了toImageBitmap()将它转换成Bitmap。

  • toImageBitmap()的声明:

expect fun ByteArray.toImageBitmap(): ImageBitmap
  • Android端的实现:

fun ByteArray.toAndroidBitmap(): Bitmap {return BitmapFactory.decodeByteArray(this, 0, size)
}
  • iOS端的实现:

actual fun ByteArray.toImageBitmap(): ImageBitmap =Image.makeFromEncoded(this).toComposeImageBitmap()

好了通过以上的方式我们就可以实现对本地图片的加载,到此为止,Compose的相应实现就完成了。那么它是怎么被Android和ios的view引用的呢?Android端我们已经非常熟悉了,和Jetpack-Compose的调用方式一样,在MainActivity中直接调用即可:

class MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {MyApplicationTheme {Surface(modifier = Modifier.fillMaxSize(),color = MaterialTheme.colors.background) {SearchCompose().searchCompose()}}}}
}

ios端会稍微麻烦一点。我们先来看一下iosApp模块下iOSApp.swift的实现:

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 = mainViewControllerwindow?.makeKeyAndVisible()return true}
}

关键代码是这两行:

let mainViewController = Main_iosKt.MainViewController()window?.rootViewController = mainViewController

创建了一个MainViewController对象,然后赋给window的rootViewController。这个MainViewController是在哪儿怎么定义的呢?我们回到shared模块,定义一个main.ios的文件,它会在framework编译成Main_iosKt文件。main.ios的实现如下:

package com.example.sharesampleimport 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()}}}

我们看到在这儿会创建一个UIViewController对象MainViewController。这个便是ios端和Compose链接的桥梁。接下来我们来看看在Android和ios上的效果。

  • Android端:

9bf3253a1b092defa9b0bdac068b8688.jpeg

  • iOS端:

eff1d4888796aabe9c0a67aec41bf7e4.png

好了到此为止,我们看到了一个简单的列表业务逻辑是怎样实现的了。由于Compose-Multiplatform还未成熟,在业务实现上势必有很多内容需要自己造轮子。 

04

Android端的compose绘制原理

由于网上已经有很多Compose的相关绘制原理,下一章我们只是进行简单的源码解析,来说明它是如何生成UI树并进行自绘的。

1、Android端的compose绘制原理

Android端是在从onCreate()里实现setContent()开始的:

setContent {MyApplicationTheme {Surface(modifier = Modifier.fillMaxSize(),color = MaterialTheme.colors.background) {SearchCompose().searchCompose()}}}

setContent()的实现如下:

public fun ComponentActivity.setContent(parent: CompositionContext? = null,content: @Composable () -> Unit
) {val existingComposeView = window.decorView.findViewById<ViewGroup>(android.R.id.content).getChildAt(0) as? ComposeViewif (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 attachsetParentCompositionContext(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 presentsetOwners()setContentView(this, DefaultActivityContentLayoutParams)}
}

我们看到它主要是生成了ComposeView然后通过setContent(content)将compose的内容注册到ComposeView里,其中ComposeView继承ViewGroup,然后调用ComponentActivity的setContentView()方法将ComposeView添加到DecorView中相应的子View中。通过追踪ComposeView的setContent方法:

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对象,传入UiApplierval 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
}

我们发现主要做了两件事情:

  • 1.创建Composition对象,传入UiApplier

  • 2.传入content函数

其中UiApplier的定义如下:

internal class UiApplier(root: LayoutNode
) : AbstractApplier<LayoutNode>(root)

持有一个LayoutNode对象,它的说明如下:

An element in the layout hierarchy, built with compose UI

可以看到LayoutNode就是在Compose渲染的时候,每一个组件就是一个LayoutNode,最终组成一个LayoutNode树,来描述UI界面。LayoutNode是怎么创建的呢?

1)LayoutNode

我们假设创建一个Image,来看看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) {}}
}

继续追踪Layout()的实现:

@Composable inline fun Layout(content: @Composable @UiComposable () -> Unit,modifier: Modifier = Modifier,measurePolicy: MeasurePolicy
) {val density = LocalDensity.currentval layoutDirection = LocalLayoutDirection.currentval viewConfiguration = LocalViewConfiguration.currentReusableComposeNode<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()
}

在这里创建了ComposeUiNode对象,而LayoutNode就是ComposeUiNode的实现类。我们再来看看Composition。

2)Composition

从命名来看,Composition的作用就是将LayoutNode组合起来。其中WrappedComposition继承Composition:

private class WrappedComposition(val owner: AndroidComposeView,val original: Composition
) : Composition, LifecycleEventObserver

我们来追踪一下它的setContent()的实现:

override fun setContent(content: @Composable () -> Unit) {owner.setOnViewTreeOwnersAvailable {if (!disposed) {val lifecycle = it.lifecycleOwner.lifecyclelastContent = contentif (addedToLifecycle == null) {addedToLifecycle = lifecycle// this will call ON_CREATE synchronously if we already createdlifecycle.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)}}}}}}

在页面的生命周期是CREATED的状态下,执行original.setContent():

override fun setContent(content: @Composable () -> Unit) {check(!disposed) { "The composition is disposed" }this.composable = contentparent.composeInitial(this, composable)}

调用parent的composeInitial()方法,这段代码我们就不再继续追踪下去了,它最终的作用就是对布局进行组合,创建父子依赖关系。 

3)Measure和Layout

在AndroidComposeView中的dispatchDraw()实现了measureAndLayout()方法:

override fun measureAndLayout(sendPointerUpdate: Boolean) {trace("AndroidOwner:measureAndLayout") {val resend = if (sendPointerUpdate) resendMotionEventOnLayout else nullval rootNodeResized = measureAndLayoutDelegate.measureAndLayout(resend)if (rootNodeResized) {requestLayout()}measureAndLayoutDelegate.dispatchOnPositionedCallbacks()}}fun measureAndLayout(onLayout: (() -> Unit)? = null): Boolean {var rootNodeResized = falseperformMeasureAndLayout {if (relayoutNodes.isNotEmpty()) {relayoutNodes.popEach { layoutNode ->val sizeChanged = remeasureAndRelayoutIfNeeded(layoutNode)if (layoutNode === root && sizeChanged) {rootNodeResized = true}}onLayout?.invoke()}}callOnLayoutCompletedListeners()return rootNodeResized}

调用remeasureAndRelayoutIfNeeded,遍历relayoutNodes,为每一个LayoutNode去进行measure和layout。具体的实现不分析了。

4)绘制

我们还是以Image举例:

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)
}

主要的绘制工作是由BitmapPainter完成的,它继承自Painter。

override fun DrawScope.onDraw() {drawImage(image,srcOffset,srcSize,dstSize = IntSize(this@onDraw.size.width.roundToInt(),this@onDraw.size.height.roundToInt()),alpha = alpha,colorFilter = colorFilter,filterQuality = filterQuality)}

在onDraw()方法里实现了drawImage():

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))

而最终也是在Canvas上进行了绘制。通过以上的分析,我们了解到Compose并不是和原生控件一一映射的关系,而是像Flutter一样,有自己的UI组织方式,并最终调用自绘引擎直接在Canvas上进行绘制的。在Android和iOS端使用的自绘引擎是skiko。这个skiko是什么呢?它其实是Skia for Kotlin的缩写(Flutter在移动端也是用的Skia引擎进行的绘制)。事实上不止是在移动端,我们可以通过以下的截图看到,Compose的桌面端和Web端的绘制实际上也是用了skiko:

c01b350e45a530c449edefe60d6f580a.png

关于skiko的更多信息,还请查阅文末的官方链接。(4)

好了到此为止,Compose的在Android端的绘制原理我们就讲完了。对其他端绘制感兴趣的同学可自行查看相应的源码,细节有不同,但理念都是一致的:创建自己的Compose树,并最终调用自绘引擎在Canvas上进行绘制。

05

Compose-Multiplatform与Flutter 

为什么要单拿它俩出来说一下呢?是因为在调研Compose-Multiplatform的过程中,我们发现它跟Flutter的原理类似,那未来可能就会有竞争,有竞争就意味着开发同学若在自己的项目中使用跨平台框架需要选择。那么我们来对比一下这两个框架:在之前KMM的文章中,我们比较过KMM和Flutter,结论是:

  • KMM主要实现的是共享逻辑,UI层的实现还是建议平台各自去处理;

  • Flutter是UI层的共享。

当时看来两者虽然都是跨平台,但目标不同,看上去并没有形成竞争。而在Compose-Multiplatform加入之后,结合KMM,成为了逻辑和UI都可以实现共享的结果。而且从绘制原理上来说,Compose和Flutter都是创建自己的View树,在通过自绘引擎进行渲染,原理上差异不大。再加上Kotlin和Compose作为Android的官方推荐,对于Android同学来说,基本上是没有什么学习成本的。个人认为若Compose-Multiplatform更加成熟,发布稳定版后与Flutter的竞争会非常大。 

06

总结

Compose-Multiplatform目前虽然还不成熟,但通过对其原理的分析,我们可以预见的是,结合KMM,未来将成为跨平台的有力竞争者。特别对于Android开发同学来说,可以把KMM先用起来,结合Compose去实现一些低耦合的业务,待未来Compose-iOS发布稳定版后,可以愉快的进行双端开发,节约开发成本。

参考:

(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

相关文章:

Compose-Multiplatform在Android和iOS上的实践

本文字数&#xff1a;4680字 预计阅读时间&#xff1a;30分钟 01 简介 之前我们探讨过KMM&#xff0c;即Kotlin Multiplatform Mobile&#xff0c;是Kotlin发布的移动端跨平台框架。当时的结论是KMM提倡将共有的逻辑部分抽出&#xff0c;由KMM封装成Android(Kotlin/JVM)的aar和…...

XXL-JOB 默认 accessToken 身份绕过导致 RCE

文章目录 0x01 漏洞介绍0x02 影响版本0x03 环境搭建0x04 漏洞复现第一步 访问页面返回报错信息第二步 执行POC,进行反弹shell第三步 获取shell0x05 修复建议摘抄免责声明0x01 漏洞介绍 XXL-JOB 是一款开源的分布式任务调度平台,用于实现大规模任务的调度和执行。 XXL-JOB 默…...

7 库函数之复位和时钟设置(RCC)所有函数的介绍及使用

7 库函数之复位和时钟设置(RCC)所有函数的介绍及使用的介绍及使用 1. 图片有格式二、RCC库函数固件库函数预览2.1 函数RCC_DeInit2.2 函数RCC_HSEConfig2.3 函数RCC_WaitForHSEStartUp2.4 函数RCC_AdjustHSICalibrationValue2.5 函数RCC_HSICmd2.6 函数RCC_PLLConfig2.7 函数…...

第十七节——指令

一、概念 在Vue.js中&#xff0c;指令&#xff08;Directives&#xff09;是一种特殊的语法&#xff0c;用于为HTML元素添加特定的行为和功能。指令以v-作为前缀&#xff0c;通过在HTML标签中使用这些指令来操作DOM&#xff0c;修改元素的属性、样式或行为。 Vue.js提供了一组…...

优雅的 Dockerfile 是怎样炼成的?

Docker 简介 目前&#xff0c;Docker 主要有两个形态&#xff1a;Docker Desktop 和 Docker Engine。 Docker Desktop 是专门针对个人使用而设计的&#xff0c;支持 Mac&#xff08;已支持arm架构的M系芯片&#xff09; 和 Windows 快速安装&#xff0c;具有直观的图形界面&a…...

2023-2024 中国科学引文数据库来源期刊列表(CSCD)

文章目录 CSCD来源期刊遴选报告2023-2024 中国科学引文数据库来源期刊列表&#xff08;CSCD&#xff09; CSCD来源期刊遴选报告 2023-2024 中国科学引文数据库来源期刊列表&#xff08;CSCD&#xff09;...

【3D图像分割】基于Pytorch的VNet 3D图像分割5(改写数据流篇)

在这篇文章&#xff1a;【3D 图像分割】基于 Pytorch 的 VNet 3D 图像分割2&#xff08;基础数据流篇&#xff09; 的最后&#xff0c;我们提到了&#xff1a; 在采用vent模型进行3d数据的分割训练任务中&#xff0c;输入大小是16*96*96&#xff0c;这个的裁剪是放到Dataset类…...

WebSocket Day02 : 握手连接

前言 握手连接是WebSocket建立通信的第一步&#xff0c;通过客户端和服务器之间的一系列握手操作&#xff0c;确保了双方都支持WebSocket协议&#xff0c;并达成一致的通信参数。握手连接的过程包括客户端发起握手请求、服务器响应握手请求以及双方完成握手连接。完成握手连接后…...

c#的反编译工具ISPY和net reflector 使用比较

我有一份Asp.net程序需要修改&#xff0c;但没有源码&#xff0c;只有dll&#xff0c;需要使用反编译工具回复源码&#xff0c;尝试使用了市面上的两种主流的工具ISPY和net reflector &#xff0c;最终用ISPY恢复了源码。 比较 ISPY 恢复的代码和实际有差距&#xff0c;但还能…...

基于LDA主题+协同过滤+矩阵分解算法的智能电影推荐系统——机器学习算法应用(含python、JavaScript工程源码)+MovieLens数据集(四)

目录 前言总体设计系统整体结构图系统流程图 运行环境模块实现1. 数据爬取及处理2. 模型训练及保存3. 接口实现4. 收集数据5. 界面设计 系统测试相关其它博客工程源代码下载其它资料下载 前言 前段时间&#xff0c;博主分享过关于一篇使用协同过滤算法进行智能电影推荐系统的博…...

方阵行列式与转置矩阵

1.转置矩阵&#xff1a;格式规定&#xff1a;如果矩阵A为n阶方阵&#xff0c;那么A的T次方为矩阵A的转置矩阵&#xff0c;即将矩阵A的行与列互换。 2.转置矩阵的运算性质&#xff1a; 1.任何方阵的转置矩阵的转置矩阵为方阵自身。 2.多个矩阵的和的转置矩阵等于多个转置矩阵的…...

【Java 进阶篇】Java Cookie共享:让数据穿越不同应用的时空隧道

在Web开发中&#xff0c;Cookie是一种常见的会话管理技术&#xff0c;用于存储和传递用户相关的信息。通常&#xff0c;每个Web应用都会在用户的浏览器中设置自己的Cookie&#xff0c;以便在用户与应用之间保持状态。然而&#xff0c;有时我们需要在不同的应用之间共享Cookie数…...

甘特图组件DHTMLX Gantt用例 - 如何拆分任务和里程碑项目路线图

创建一致且引人注意的视觉样式是任何项目管理应用程序的重要要求&#xff0c;这就是为什么我们会在这个系列中继续探索DHTMLX Gantt图库的自定义。在本文中我们将考虑一个新的甘特图定制场景&#xff0c;DHTMLX Gantt组件如何创建一个项目路线图。 DHTMLX Gantt正式版下载 用…...

克里金插值matlab代码

% 克里金插值示例 clc; clear; % 生成模拟数据 x linspace(0, 10, 11); y linspace(0, 10, 11); [X, Y] meshgrid(x, y); Z sin(sqrt(X.^2 Y.^2)) 0.1 * randn(size(X)); % 设置克里金参数 nugget 0.1; % 块金值 range 1; % 范围 sill 1; % 基台值 azimuth …...

【LeetCode】23. 合并 K 个升序链表

题目链接&#xff1a;23. 合并 K 个升序链表 题目描述&#xff1a; 数据范围&#xff1a; **思考&#xff1a;**这题实际上就是合并两个有序列表的进阶版&#xff0c;只不过这里变成了合并K个&#xff0c;那么这里我们显然就知道&#xff0c;核心的合并两个有序列表的思路不…...

2023年【熔化焊接与热切割】免费试题及熔化焊接与热切割考试总结

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 熔化焊接与热切割免费试题参考答案及熔化焊接与热切割考试试题解析是安全生产模拟考试一点通题库老师及熔化焊接与热切割操作证已考过的学员汇总&#xff0c;相对有效帮助熔化焊接与热切割考试总结学员顺利通过考试。…...

为什么要学中文编程?它能有哪些益处?免费版编程工具怎么下载?系统化的编程教程课程怎么学习

一、为什么要学习这个编程工具&#xff1f;能给自己带来什么益处&#xff1f; 1、不论在哪里上班&#xff0c;都不是铁饭碗&#xff1a;现在全球经济低迷&#xff0c;使得很多企业倒闭&#xff0c; 大到知名国企小到私营企业&#xff0c;大量裁员。任何人都无法保证自己现在的…...

数据分析实战 - 2 订单销售数据分析(pandas 进阶)

题目来源&#xff1a;和鲸社区的题目推荐&#xff1a; 刷题源链接&#xff08;用于直接fork运行 https://www.heywhale.com/mw/project/6527b5560259478972ea87ed 刷题准备 请依次运行这部分的代码&#xff08;下方4个代码块&#xff09;&#xff0c;完成刷题前的数据准备 …...

测试服务器端口是否开通,计算退休时间

本案例知识点 netstat -tuln | grep 80 nestat 目前主机打开的网络服务端口&#xff0c;-tuln目前主机启动的服务&#xff0c;如图 报错说参数太多&#xff0c;仔细检查发现if后的中括号内&#xff0c;变量少双引号导致&#xff0c;改完之后运行显示22,25端口开放&#xff0…...

Prometheus接入AlterManager配置企业微信告警(基于K8S环境部署)

文章目录 一、创建企业微信机器人二、配置AlterManager告警发送至企业微信三、Prometheus接入AlterManager配置四、部署PrometheusAlterManager(放到一个Pod中)五、测试告警 注意&#xff1a;请基于 PrometheusGrafana监控K8S集群(基于K8S环境部署)文章之上做本次实验。 一、创…...

11.1 Linux 设备树

一、什么是设备树&#xff1f; 设备树(Device Tree)&#xff0c;描述设备树的文件叫做 DTS(DeviceTree Source)&#xff0c;这个 DTS 文件采用树形结构描述板级设备&#xff0c;也就是开发板上的设备信息&#xff1a; 树的主干就是系统总线&#xff0c; IIC 控制器、 GPIO 控制…...

万宾科技管网水位监测助力智慧城市的排水系统

以往如果要了解城市地下排水管网的水位变化&#xff0c;需要依靠人工巡检或者排查的方式&#xff0c;这不仅加大了人员的工作量&#xff0c;而且也为市政府带来了更多的工作难题。比如人员监管监测不到位或无法远程监控等情况&#xff0c;都会降低市政府对排水管网的管理能力&a…...

Glide transform CircleCrop()圆图,Kotlin

Glide transform CircleCrop()圆图&#xff0c;Kotlin import android.os.Bundle import android.widget.ImageView import androidx.appcompat.app.AppCompatActivity import com.bumptech.glide.load.resource.bitmap.CircleCropclass MainActivity : AppCompatActivity() {o…...

从NetSuite Payment Link杂谈财务自动化、数字化转型

最近在进行信息化的理论学习&#xff0c;让我有机会跳开软件功能&#xff0c;用更加宏大的视野&#xff0c;来审视我们在哪里&#xff0c;我们要到哪去。 在过去20多年&#xff0c;我们的财务软件经历了电算化、网络化、目前处于自动化、智能化阶段。从NetSuite这几年的功能发…...

1.UML面向对象类图和关系

文章目录 4种静态结构图类图类的表示类与类之间的关系依赖关系(Dependency)关联关系(Association)聚合(Aggregation)组合(Composition)实现(Realization)继承/泛化(Inheritance/Generalization)常用的UML工具reference欢迎访问个人网络日志🌹🌹知行空间🌹🌹 4种静态结构…...

JAVA小说小程序系统是怎样开发的

随着移动互联网的普及&#xff0c;小说阅读已经成为人们休闲娱乐的重要方式之一。为了满足广大读者的需求&#xff0c;我们开发了一款基于JAVA编程语言的小说小程序系统。本系统旨在提供一种便捷、高效、有趣的阅读体验&#xff0c;让用户能够随时随地阅读最新、最热门的小说。…...

【深度学习】pytorch——Tensor(张量)详解

笔记为自我总结整理的学习笔记&#xff0c;若有错误欢迎指出哟~ pytorch——Tensor 简介创建Tensortorch.Tensor( )和torch.tensor( )的区别torch.Tensor( )torch.tensor( ) tensor可以是一个数&#xff08;标量&#xff09;、一维数组&#xff08;向量&#xff09;、二维数组&…...

装修服务预约小程序的内容如何

大小装修不断&#xff0c;市场中大小品牌也比较多&#xff0c;对需求客户来说&#xff0c;可以线下咨询也可以线上寻找品牌&#xff0c;总是可以找到满意的服务公司&#xff0c;而对装修公司来说如今线下流量匮乏&#xff0c;很多东西也难以通过线下方式承载&#xff0c;更需要…...

easypoi 导出Excel 使用总结

easypoi 导出Excel 导出Excel需要设置标题&#xff0c;且标题是多行&#xff0c;标题下面是列表头 设置表格标题 ExportParams headExportParams new ExportParams();StringBuilder buffer new StringBuilder("");buffer.append("1、课程名称&#xff1a;....…...

MySQL性能优化的最佳20条经验

概述 关于数据库的性能&#xff0c;这并不只是DBA才需要担心的事。当我们去设计数据库表结构&#xff0c;对操作数据库时(尤其是查表时的SQL语句)&#xff0c;我们都需要注意数据操作的性能。下面讲下MySQL性能优化的一些点。 1. 为查询缓存优化你的查询 大多数的MySQL服务器…...