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版本,虽然还在开发实验阶段,但我们已经开始尝试用起来了。
02Jetpack-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:
图片
03使用
既然是UI框架,那么我们就来实现一个简单的在移动端非常常规的业务需求:
从服务器请求数据,并以列表形式展现在UI上。
在此我们要说明的是,Compose-Multiplatform是要与KMM配合使用的,其中KMM负责把shared模块编译成Android的aar和iOS的framework,Compose-Multiplatform负责UI层面的交互与绘制的实现。
首先我们先回顾一下KMM工程的组织架构:
图片
其中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插件:
其中compose.version在gradle.properties进行了声明。需要注意的是目前Compose-Multiplatform的版本有要求,目前可以参考官方的具体配置。(3)
之后在shared模块的build.gradle文件中引用声明好的插件如下:
同时我们需要在build.gradle文件中配置compose静态资源文件的目录,方式如下:
- Android:
- iOS:
这意味着在寻找如图片等资源文件时,将从src/commonMain/resources/这个目录下寻找,如下图所示:
由于目前compose-iOS还处于实验阶段,我们需要在gradle.properties文件中添加如下代码开启UIKit:
最后我们需要在为commonMain添加compose依赖:
好了到此为止我们的配置就完成了,接下来开始写业务代码了。既然是从服务器获取数据,我们肯定得封装一个网络模块,下面我们将使用ktor封装一个简单的网络模块。
2、网络模块
先我们先在shared模块的build.gradle文件中添加依赖如下:
接下来我们封装一个最简单的HttpUtil,包含post和get请求;
代码非常直观,定义了HttpClient对象,进行了基础的设置来实现网络请求。我们来定义一下接口请求返回的数据结构。
3、返回的数据结构
接下来我们看看是怎么发送的请求。
4、发送请求
然后我们定义个SearchApi:
实现了search()方法。接着我们来看view层的实现与数据的绑定是如何实现的。
5、View层的实现
我们创建一个SearchCompose:
在searchCompose()里我们看到了在发送请求时开启了一个协程,scope()方法指定了作用域,除此之外,我们还定义了ioDispatcher在不同平台下的实现,具体的声明如下:
在Android上的实现:
在ios上的实现:
需要注意的是,Android平台,Dispatchers.IO在jvmMain/Dispatchers,ios平台,Dispatchers.IO在nativeMain/Dispatchers下。两者是不一样的。在获取了服务端数据后,我们使用LazyColumn对列表进行实现。其中有图片和文本的展示。为了方便进行说明,图片数据我们使用本地resources目录下的图片,文本展示的是服务端返回的数据。下面我来说明一下图片的加载。
6、图片加载
具体的实现如下:
先创建了一个ImageBitmap的remember对象,由于resource(picture).readBytes()是个挂起函数,我们需要用LaunchedEffect来执行。这段代码的作用是从resources目录下读取资源到内存中,然后我们在不同平台实现了toImageBitmap()将它转换成Bitmap。
- toImageBitmap()的声明:
- Android端的实现:
- iOS端的实现:
好了通过以上的方式我们就可以实现对本地图片的加载,到此为止,Compose的相应实现就完成了。那么它是怎么被Android和ios的view引用的呢?Android端我们已经非常熟悉了,和Jetpack-Compose的调用方式一样,在MainActivity中直接调用即可:
ios端会稍微麻烦一点。我们先来看一下iosApp模块下iOSApp.swift的实现:
关键代码是这两行:
创建了一个MainViewController对象,然后赋给window的rootViewController。这个MainViewController是在哪儿怎么定义的呢?我们回到shared模块,定义一个main.ios的文件,它会在framework编译成Main_iosKt文件。main.ios的实现如下:
我们看到在这儿会创建一个UIViewController对象MainViewController。这个便是ios端和Compose链接的桥梁。接下来我们来看看在Android和ios上的效果。
- Android端:
图片
- iOS端:
图片
好了到此为止,我们看到了一个简单的列表业务逻辑是怎样实现的了。由于Compose-Multiplatform还未成熟,在业务实现上势必有很多内容需要自己造轮子。
04Android端的compose绘制原理
由于网上已经有很多Compose的相关绘制原理,下一章我们只是进行简单的源码解析,来说明它是如何生成UI树并进行自绘的。
1、Android端的compose绘制原理
Android端是在从onCreate()里实现setContent()开始的:
setContent()的实现如下:
我们看到它主要是生成了ComposeView然后通过setContent(content)将compose的内容注册到ComposeView里,其中ComposeView继承ViewGroup,然后调用ComponentActivity的setContentView()方法将ComposeView添加到DecorView中相应的子View中。通过追踪ComposeView的setContent方法:
我们发现主要做了两件事情:
- 创建Composition对象,传入UiApplier
- 传入content函数
其中UiApplier的定义如下:
持有一个LayoutNode对象,它的说明如下:
An element in the layout hierarchy, built with compose UI
可以看到LayoutNode就是在Compose渲染的时候,每一个组件就是一个LayoutNode,最终组成一个LayoutNode树,来描述UI界面。LayoutNode是怎么创建的呢?
1)LayoutNode
我们假设创建一个Image,来看看Image的实现:
继续追踪Layout()的实现:
在这里创建了ComposeUiNode对象,而LayoutNode就是ComposeUiNode的实现类。我们再来看看Composition。
2)Composition
从命名来看,Composition的作用就是将LayoutNode组合起来。其中WrappedComposition继承Composition:
我们来追踪一下它的setContent()的实现:
在页面的生命周期是CREATED的状态下,执行original.setContent():
调用parent的composeInitial()方法,这段代码我们就不再继续追踪下去了,它最终的作用就是对布局进行组合,创建父子依赖关系。
3)Measure和Layout
在AndroidComposeView中的dispatchDraw()实现了measureAndLayout()方法:
调用remeasureAndRelayoutIfNeeded,遍历relayoutNodes,为每一个LayoutNode去进行measure和layout。具体的实现不分析了。
4)绘制
我们还是以Image举例:
主要的绘制工作是由BitmapPainter完成的,它继承自Painter。
在onDraw()方法里实现了drawImage():
而最终也是在Canvas上进行了绘制。通过以上的分析,我们了解到Compose并不是和原生控件一一映射的关系,而是像Flutter一样,有自己的UI组织方式,并最终调用自绘引擎直接在Canvas上进行绘制的。在Android和iOS端使用的自绘引擎是skiko。这个skiko是什么呢?它其实是Skia for Kotlin的缩写(Flutter在移动端也是用的Skia引擎进行的绘制)。事实上不止是在移动端,我们可以通过以下的截图看到,Compose的桌面端和Web端的绘制实际上也是用了skiko:
图片
关于skiko的更多信息,还请查阅文末的官方链接。(4)
好了到此为止,Compose的在Android端的绘制原理我们就讲完了。对其他端绘制感兴趣的同学可自行查看相应的源码,细节有不同,但理念都是一致的:创建自己的Compose树,并最终调用自绘引擎在Canvas上进行绘制。
05Compose-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