一、前言
最近才有空,想起来Compose-jb和kmm这2个框架,就来个快速入门指南吧。
什么是KMM (Kotlin Multiplatform Mobile)
KMM用于简化跨平台开发,可以在Android和IOS之间共享通用的代码。
仅在使用各自平台能力的时候,才会去编写各自平台特定的代码。
Compose Multiplatform, by JetBrains
缩写名称:compose-jb
Compose Multiplatform, by JetBrains
JetBrains开源的compose-jb官方的介绍内容:
桌面和Web UI框架,基于Google的JetpackCompose工具包
Compose Multiplatform 简化并加速了桌面和Web应用程序的UI开发,并且允许Android和桌面之间大部分的UI代码共享。
二、Window平台-开发工具
1、compose-jb环境安装
1、下载IntelliJ IDEA Community Edition
2、下载JAVA JDK
2、KMM环境安装
1、下载AndroidStudio
2、下载JAVA JDK
3、下载Kotlin多平台移动插件
在Android Studio中,在市场中搜索:
Kotlin Multiplatform Mobile
,然后安装
4、更新Kotlin插件
Kotlin插件与AndroidStudio版本是捆绑在一起的,我们需要更新Kotlin到最新版本,来避免兼容性问题。
三、MacOS平台-开发工具
1、compose-jb环境安装
1、下载IntelliJ IDEA Community Edition
2、下载JAVA JDK
2、KMM环境安装
1、下载XCode
2、在终端或命令行工具中,运行以下命令
brew install kdoctor
如果你还没有Homebrew,请安装它或查看KDoctor README以获取其他安装方法。
3、安装完成后,在控制台调用 KDoctor
kdoctor
四、KMM工程
1、创建工程
打开AndroidStudio
,点击 New Project
,然后找到 Kotlin Multiplatform App
,然后点击 Next
配置应用程序的名称、应用包名、项目的位置、最小SDK版本,配置完成之后,点击 Next
iOS framework distribution
我们选择Regular framework
, 因为此选项不需要第三方工具,并且安装问题较少。
cocoapods dependency manager
是什么呢?
CocoaPods是 Swift 和 Objective-C Cocoa项目的依赖管理器。
对于更复杂的项目,可能需要CocoaPods依赖项管理器来帮助处理库依赖项。
点击Finish
,首次执行此操作时,下载和设置所需的组件可能需要一些时间。
2、工介绍
KMM工程包含三个模块:
-
androidApp 是Android应用程序项目,依赖于shared模块,并将shared模块用作常规的Android库,UI就是使用Jetpack Compose那一套
-
shared 包含Android和iOS应用程序在平台之间共享的通用代码逻辑
-
iosApp 是iOS应用程序项目,它依赖于并使用shared模块作为iOS框架
androidApp和iosApp模块都是各自平台原来的开发方式,shared模块它是平台之间共享的通用代码逻辑,那么它如何实现共享的呢?我们看一下下面这张图片:
连接到各自的平台:
expect和actual文档
我们可以看到它在公共模块中使用expect关键字,expect修饰类、成员变量或方法时,表示类、成员变量、方法,可以跨平台实现。
注意:expect声明不包含任何实现代码。
expect和actual所修饰的类/变量/方法,名称都需要完全一样,并且位于同一包中(具有相同的完整包名)。
在各自的平台Android/IOS,中使用actual修饰,实现同名的类、方法、成员变量。
我们再来看Hello World
示例,commonMain
目录下面创建了一个Platform接口,并使用expect关键字
修饰了getPlatform()
方法
那么Android/IOS
平台,需要去使用actual修饰
,实现同名的类、方法、成员变量。
我们在commonMain
目录下面,可以定义逻辑类,共享通用的代码逻辑,同样以Hello World
为例:
那么在Android/IOS
的工程中就可以使用这个Greeting
类来获取通用的代码逻辑,如下:
3、如何添加依赖项
切换到Project视图下,找到shared模块,点击build.gradle.kts,在sourceSets里面,我们可以给
commonMain、androidMain、iosMain
分别添加依赖。
我们这里给commonMain
添加依赖项,这样Android、IOS
平台就可以在获取通用代码逻辑的时候,使用到这个依赖项的能力了。
添加kotlinx-datetime
的依赖项:
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
}
}
}
}
在commonMain
目录下面创建一个KotlinNewYear.kt
的File:
import kotlinx.datetime.*
fun daysUntilNewYear(): Int {
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
val closestNewYear = LocalDate(today.year + 1, 1, 1)
return today.daysUntil(closestNewYear)
}
我们在Greeting里面使用这个方法:
我们看看运行之后的结果:
4、网络请求
我们需要准备以下三个多平台库:
- kotlinx.coroutines,用于使用协程编写异步代码,允许同时操作
- kotlinx.serialization,用于将 JSON 响应反序列化为用于处理网络操作的实体类的对象。
- Ktor,一个作为HTTP客户端的框架,用于通过互联网检索数据。
1、添加Kotlinx.coroutines
依赖库:
在shared模块的build.gradle.kts中,添加kotlinx.coroutines
依赖
sourceSets {
val commonMain by getting {
dependencies {
// ...
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
}
}
}
我们打开项目根目录下面的build.gradle.kts
文件,查看kotlin版本是否小于1.7.20
使用 Kotlin 1.7.20 及更高版本
,则默认情况下已经启用了新的 Kotlin/Native 内存管理器。
Kotlin版本小于1.7.20
版本,请将以下内容添加到build.gradle.kts
文件末尾:
kotlin.targets.withType(org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget::class.java) {
binaries.all {
binaryOptions["memoryModel"] = "experimental"
}
}
2、添加Kotlinx.serialization
依赖库:
我们打开shared模块,打开build.gradle.kts
文件,在文件开头的plugins块中增加 序列化插件
内容:
plugins {
kotlin("plugin.serialization") version "1.8.0"
}
3、添加Ktor
依赖库
我们打开shared模块,打开build.gradle.kts
文件,增加下面内容:
val ktorVersion = "2.2.1"
sourceSets {
val commonMain by getting {
dependencies {
// ...
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-android:$ktorVersion")
}
}
val iosMain by creating {
// ...
dependencies {
implementation("io.ktor:ktor-client-darwin:$ktorVersion")
}
}
}
ktor-client-core
:核心依赖项。
ktor-client-content-negotiation
:负责序列化/反序列化特定格式的内容。
ktor-serialization-kotlinx-json
:使用JSON格式用作序列化库,在接收响应时将其反序列化为数据类。
ktor-client-android
:提供Android平台引擎
ktor-client-darwin
:提供IOS平台引擎
点击Sync Now
同步Gradle
之后,我们创建一个RocketLaunch.kt
:
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class RocketLaunch (
@SerialName("flight_number")
val flightNumber: Int,
@SerialName("name")
val missionName: String,
@SerialName("date_utc")
val launchDateUTC: String,
@SerialName("success")
val launchSuccess: Boolean?,
)
点击查看Ktor官方文档
4、创建一个Ktor
实例来执行网络请求并解析生成的JSON
:
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class Greeting {
private val platform: Platform = getPlatform()
private val httpClient = HttpClient {
// 安装ContentNegotiation插件
install(ContentNegotiation) {
// 注册 JSON 序列化程序
json(Json {
// true:表示打印并生成漂亮的JSON。 默认情况下:false
prettyPrint = true
// true:删除 JSON 规范限制,允许使用带引号的布尔文本和不带引号的字符串文字
isLenient = true
// true:表示可以忽略JSON中遇到的未知属性,防止引发序列化异常。 默认情况下:false
ignoreUnknownKeys = true
})
}
}
}
点击查看ContentNegotiation Ktor插件文档
修改greeting
方法,添加suspend
修饰,并使用httpClient
去获取网络请求的数据:
import io.ktor.client.call.*
import io.ktor.client.request.*
class Greeting {
// ...
@Throws(Exception::class)
suspend fun greeting(): String {
// 获取数据
val rockets: List<RocketLaunch> =
httpClient.get("https://api.spacexdata.com/v4/launches").body()
// 判断最近一次火箭是否发射成功
val lastSuccessLaunch = rockets.last { it.launchSuccess == true }
// 返回结果
return "Guess what it is! > ${platform.name.reversed()}!" +
"\nThere are only ${daysUntilNewYear()} left until New Year! 🎆" +
"\nThe last successful launch was ${lastSuccessLaunch.launchDateUTC} 🚀"
}
}
到这里还没结束,这里只是获取数据的代码,Android/IOS项目目录下面,仍然要配置内容,请往下看:
5、Android
相关配置
- 打开
androidApp/src/main/AndroidManifest.xml
配置网络权限:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.jetbrains.simplelogin.kotlinmultiplatformsandbox" >
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
- 打开
androidApp/build.gradle.kts
添加Android协程库,处理commMain模块
的挂起方法:
dependencies {
// ..
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
}
然后点击同步Gradle
,打开MainActivity.kt
文件,使用协程调用suspend fun greeting()
import androidx.compose.runtime.*
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val scope = rememberCoroutineScope()
var text by remember { mutableStateOf("Loading") }
LaunchedEffect(true) {
scope.launch {
text = try {
Greeting().greeting()
} catch (e: Exception) {
e.localizedMessage ?: "error"
}
}
}
GreetingView(text)
}
}
}
}
}
6、IOS
相关配置
IOS这里使用SwiftUI构建UI界面,使用Model-view-ViewModel,将 UI 连接到包含所有业务逻辑的shared模块
。
在ContentView.swift
中创建viewModel
,并获取shared模块
中的网络请求数据:
看到这里可能大家都会感觉很熟悉,鸿蒙的ArkUI和SwiftUI真的好像,感觉就是表兄弟。
Compose和SwitfUI还差点样子,样貌算是外甥的那种哈哈。
点击查看DispatchQueue.main.async解释
运行之后Android
和IOS
的界面显示如下:
5、小结
KMM
属于Android和IOS各自写各自平台的UI,通用的业务逻辑数据处理需要从shared模块去获取。
优点:重复的业务逻辑数据处理部分,统一处理,在业务需求发生变更,也只需要更新shared模块即可,Android/IOS各自平台只需要关心各自的UI和平台的细节处,分工合作。
缺点:不能统一平台UI,各自平台仍然要每个平台各自写一份,但是总提上来说还是减少了一定的工作量。
点击观看官方KMM技术演进的计划-视频源自youtube
点击观看官方KMM技术演进的计划-视频源自Bilibili
五、Compose-jb工程
我们从上面可以看到创建单平台
的项目,目前可以选择Desktop
和Web
。
1、单平台Desktop目录介绍
创建一个单平台
的Desktop
项目,项目目录如下:
在jvmMain
目录下面编写我们的窗口代码,build.gradle.kts
文件中,我们需要在sourceSets
的jvmMain
中添加我们的三方库依赖:
sourceSets {
val jvmMain by getting {
dependencies {
// 增加其他依赖库
implementation(compose.desktop.currentOs)
}
}
//.....
}
在build.gradle.kts
文件中最后,这段代码,里面的内容有啥含义呢?
// 包名
group = "com.example"
// 应用程序的版本名称
version = "0.1-SNAPSHOT"
compose.desktop {
application {
// 程序入口类
mainClass = "MainKt"
nativeDistributions {
// 目标格式
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
// 应用程序的名称
packageName = "demo"
// 安装包版本名称
packageVersion = "1.0.0"
// 下面是我用来介绍元数据增加的,仅供参考
// 应用程序描述(默认值:无)
description = "Compose Example App"
// 应用程序的版权(默认值:无)
copyright = "© 2023 My Name. All rights reserved."
// 应用程序的供应商(默认值:无)
vendor = "Example vendor"
// 应用程序的许可证(默认值:无)
licenseFile.set(project.file("LICENSE.txt"))
}
}
}
更多使用细节,可参考github这里的README.md文档
打开gradle.properties
文件,可修改kotlin、agp、compose
版本号:
打开settings.gradle.kts
文件,可修改配置maven仓库
镜像地址,以及插件版本号
这里注意一点,如果你不看参数注释直接去修改 Window
窗口透明的话:
// 错误的用法
fun main() = application {
Window(onCloseRequest = ::exitApplication, transparent = true) {
App()
}
}
运行的时候,会提示如下错误:
Exception in thread "main" java.lang.IllegalStateException: Transparent window should be undecorated!
............
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':run'.
> Process 'command '************\bin\java.exe'' finished with non-zero exit value 1
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
* Get more help at https://help.gradle.org
修改后的窗口透明代码如下:
fun main() = application {
// undecorated - 禁用或启用此窗口的装饰。
// transparent - 禁用或启用窗口透明度。只有在窗口【没有启用装饰】时才应设置透明度,否则将抛出异常。
Window(onCloseRequest = ::exitApplication, undecorated = true, transparent = true) {
App()
}
}
运行起来之后,整个窗口背景色都是透明的,但窗口实际占的位置还是原来那么大(占位的部分,不能点击穿透
),我用红色方框画了窗口的实际大小,如下:
2、单平台Web目录介绍
先看一下index.html
文件的内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sample</title>
</head>
<body>
<div id="root"></div>
<script src="替换成你的module名称.js"></script>
</body>
</html>
很普通,body
最后一行,需要引入以ModuleName
为名称的js文件
,还定义了一个div root
用来插入Compose管理的DOM树内容。
我们再打开Main.kt
文件,在main()方法里面,入口是这样使用的:
renderComposable(rootElementId = "root") {
// 内容在这里
}
Compose需要一个根节点,用来管理自己的DOM树:
fun renderComposable(
rootElementId: String,
content: @Composable DOMScope<Element>.() -> Unit
): Composition = renderComposable(
root = document.getElementById(rootElementId)!!,
content = content
)
这里的root,是通过document.getElementById(rootElementId)
获取的,这个方法的作用是:
返回一个Element对象,用于快速访问:id为root的div元素。
通过Compose DOM DSL给我们提供的常用的HTML可组合项,我们可以在renderComposable
里面使用它们,
如Text
、Span
、Div
、Input
、A
、TextArea
等等可组合项。
查看可组合项和HTML标签代码的异同:
Span(
attrs = { style { color(Color.red) } } // inline style
) {
Text("Red text")
}
对应的HTML代码:
<span style="color: red;">Red text</span>
可运行下面示例查看简单的界面:
import androidx.compose.runtime.*
import org.jetbrains.compose.web.attributes.*
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
fun main() {
var count: Int by mutableStateOf(0)
renderComposable(rootElementId = "root") {
val text = remember { mutableStateOf("") }
TextArea(
value = text.value,
attrs = {
onInput {
text.value = it.value
}
}
)
Div({ style { padding(25.px) } }) {
Button(attrs = {
onClick { count -= 1 }
}) {
Text("-")
}
Span({ style { padding(15.px) } }) {
Text("$count")
}
Button(attrs = {
onClick { count += 1 }
}) {
Text("+")
}
}
A(
attrs = {
href("https://www.baidu.com")
target(ATarget.Blank)
hreflang("en")
download("https://...")
}
) { Text("打开百度的超链接") }
}
}
那么如何运行Web项目到浏览器中呢?
我们可通过命令,或者通过工具栏菜单去运行:
./gradlew jsBrowserRun
如果不想每次变更内容都去重新编译和运行,可通过如下命令连续编译模式:
./gradlew jsBrowserRun --continuous
或者通过IDEA的工具栏去双击运行它:
运行之后,浏览器将打开:localhost:8080
界面如下:
点击查看Compose-Web详细使用的文档指南
3、多平台目录介绍
切换到多平台去创建一个工程,创建完工程,我们看一下目录图片(可保存到电脑上查看长图
):
我们发现这个多平台的目录,只有Android
和Desktop
平台,那么接着往下看吧:
android
和desktop
,把可组合项代码放到了commonMain
目录下面,意味着Android
和Desktop
能共用可组合项代码了,一模一样肯定是不能够的,我们要根据platform
区分,因为电脑桌面的UI和手机UI排列和样式这些还是会不同的。
1、运行桌面应用
./gradlew :desktop:run
2、构建桌面应用发布包(JDK>=15)
./gradlew :desktop:packageDistributionForCurrentOS
或者可以通过菜单栏去双击构建运行:
3、运行Android应用
点击IDE的Edit Configurations
,去配置一个Android app
然后在工具栏上面,菜单栏下面,有如下入口,可点击按钮运行:
4、打包Android APP
命令打包:
./gradlew build release
或者通过菜单项,手动点击去打包,Build -> Generate Signed Bundle/Apk
4、小结
Compose Multiplatform 加速了桌面和Web应用程序的UI开发,创建多平台的项目工程的时候,Android和桌面之间大部分的UI代码可直接共享。
目前看还有很长的路要走,期待:IOS工程能像Android和Desktop一样共享大部分UI代码。