KMM & Compose Multiplatform 跨平台开
原创 转载请联系作者
1. 介绍
引言
在移动应用开发领域,Kotlin Multiplatform Mobile (KMM) 和 Compose Multiplatform 的结合正在成为一种强大的解决方案。它们不仅解决了传统跨平台开发中的诸多痛点,还提供了许多独特的优势,使得开发者能够更加高效地构建和维护跨平台应用。
以下是它的主要优势:
-
代码复用
KMM 和 Compose Multiplatform 允许开发者在 Android 和 iOS 之间共享大部分代码,包括业务逻辑和 UI 组件。 -
性能优化
与某些其他跨平台框架不同,KMM 生成的代码是直接运行在目标平台的原生环境中的。这意味着开发者可以享受到与原生开发相同的性能和系统级优化。 -
平台特性和灵活性
KMM 通过 expect 和 actual 关键字,允许开发者为不同的平台编写特定的实现。这种灵活性使得开发者可以充分利用各个平台的特性,而不会妥协于跨平台的限制。Compose Multiplatform 同样支持这种灵活性,确保 UI 设计可以根据平台的需求进行优化。 -
一致的开发体验
Kotlin 作为一种现代语言,拥有简洁的语法和强大的功能特性,深受开发者喜爱。利用 Kotlin 和 Compose Multiplatform,开发者能够在相同的开发环境中进行多平台开发,提升了开发者的体验和生产力。使用 Android Studio 和 Xcode 无缝集成,开发者可以在熟悉的 IDE 中进行跨平台开发和调试。 -
现代化的 UI 构建
Compose Multiplatform 基于声明式编程范式,简化了复杂的 UI 构建。开发者可以通过简明的代码定义动态界面,减少了样板代码和 UI 更新的复杂度。这种现代化的 UI 构建方式,不仅使代码更加清晰和易于维护,还提升了开发效率。
使用 KMM 和 Compose Multiplatform,开发团队可以在保证高性能和平台特性的同时,实现代码的最大复用和开发效率的提升。这种结合不仅为项目带来了显著的优势,还为开发者提供了创新和灵活的开发体验。接下来,让我们进入 KMM + Compose Multiplatform 的学习与使用。
基础概念
什么是 Kotlin Multiplatform (KMP)?
Kotlin Multiplatform (KMP) 是 JetBrains 提供的一项功能,允许使用 Kotlin 编写可以在多个平台上运行的代码。KMP 的目标是通过 共享业务逻辑代码 来减少不同平台开发的重复工作。
KMP 支持的平台包括但不限于:
JVM:用于服务器端开发以及 Android 开发。
JavaScript:用于前端 Web 开发。
原生平台 (Native):如 iOS、Windows、macOS、Linux 等。
什么是 Kotlin Multiplatform Mobile (KMM)?
Kotlin Multiplatform Mobile (KMM) 是专注于移动平台的一种 KMP 实现。KMM 让开发者能够使用 Kotlin 编写可以在 Android 和 iOS 上运行的共享代码,主要关注点是在移动设备上的应用开发。
简单来说,KMM 是 KMP 的一个子集或特化实现,专注于移动开发。
什么是 Compose multiform?
Compose Multiplatform 是 JetBrains 开发的一种跨平台用户界面 (UI) 框架,它基于 Kotlin 和 Jetpack Compose 的设计理念,旨在通过声明式编程范式简化 跨平台 UI 构建。
什么是 Kotlin/Native、Kotlin/JVM、Kotlin/JS?与 KMP 的关系?
Kotlin 是一种现代化的编程语言,由 JetBrains 开发,支持多平台开发,包括 JVM、JavaScript 和原生平台。为了更高效地开发跨平台应用,Kotlin 提供了三种主要的编译器后端:Kotlin/JVM、Kotlin/JS 和 Kotlin/Native。
Kotlin/JVM 是最初的 Kotlin 编译器后端,也是最常用的一种。它将 Kotlin 代码编译成可以在 Java 虚拟机 (JVM) 上运行的字节码。由于 Kotlin 与 Java 高度互操作,Kotlin/JVM 可以直接利用现有的 Java 库和框架,使得开发者能够轻松地将 Kotlin 与 Java 项目集成。使用场景有 Android 开发与服务器开发。
Kotlin/JS 将 Kotlin 编译为 JavaScript 代码,从而可以在浏览器或 Node.js 环境中运行。Kotlin/JS 使得前端开发者可以使用 Kotlin 编写网页应用,同时充分利用现有的 JavaScript 库和框架。使用场景是 Web 前端开发。
Kotlin/Native 将 Kotlin 编译为原生二进制代码,可以直接运行在不依赖 JVM 或 JavaScript 引擎的环境中。Kotlin/Native 支持多种平台,包括 iOS、Windows、MacOS、Linux、嵌入式设备等,甚至于 Android、鸿蒙也可以使用 KN 跨平台开发(需要编写 JNI or NAPI 桥接代码)。
KMP 结合了 Kotlin/JVM、Kotlin/JS 和 Kotlin/Native 的优势,使开发者能够在统一的代码库中,针对不同平台进行开发,实现跨平台方案。
2. KMM 环境搭建
KDoctor
kdoctor 是一个用于验证 Kotlin Multiplatform Mobile (KMM) 开发环境是否正确配置的命令行工具。它会检查系统上的各种依赖和配置,确保已经安装并正确配置了所有必要的软件,例如 Android Studio、Xcode、CocoaPods 等。如果是第一次设置 KMM 环境,强烈建议使用 kdoctor 来确认一切都已正确配置。
安装 kdoctor
对于 macOS 系统:
- 确保 Homebrew 已安装
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- 安装 kdoctor
brew install kdoctor
使用 kdoctor 进行环境检查
安装完 kdoctor 后,通过以下命令检查 KMM 开发环境:
kdoctor
kdoctor 将检查以下内容:
JDK
:是否安装了 Java Development Kit。
Android Studio
:是否安装了 Android Studio 和必要的组件。
Xcode
:是否安装了 Xcode 和命令行工具。
CocoaPods
:是否安装了 CocoaPods(用于管理 iOS 依赖)。
运行 kdoctor 后的示例输出:
$ kdoctor
[ ✓ ] Checking the Java version (11.0.8).
[ ✓ ] Checking the Android Studio installation.
[ ✓ ] Checking the Xcode installation.
[ ✓ ] Checking the CocoaPods installation.
Conclusion:
✓ Your operation system is ready for Kotlin Multiplatform Mobile Development!
如果 kdoctor 报告某些组件未正确安装或配置,按照指导信息进行相应的操作来解决问题。
在这个示例输出中,kdoctor 表明所有必要的软件都已正确安装,已准备好进行 KMM 开发。
Kotlin Multiplatform Plugin
在 Android Studio 中安装 Kotlin Multiplatform 插件:
- 打开 Android Studio。
- 进入 Settings -> Plugins。
- 搜索并安装 Kotlin Multiplatform 插件。
- 重启 Android Studio 以激活插件。
3. 创建 KMM 项目
新建项目
- 打开 Android Studio,选择 New Project。
-
在项目模板中,选择 Kotlin Multiplatform App。
项目模板选择 -
配置项目名称、保存路径和包名。
配置项目
这里 iOS 依赖管理我选择使用 CocoaPods,可以简化依赖管理和配置,尤其是在需要集成大量第三方 iOS 库的项目中。
- 点击 Finish。
项目结构
生成的 KMM 项目包含 Android 和 iOS 两个平台的代码,以及一个共享代码模块:
MyKMMApp/
├── androidApp/ # Android 壳工程
├── iosApp/ # iOS 壳工程
├── shared/ # 共享代码模块,目前仅包含业务逻辑,未来还会在此添加 UI 组件、资源等
│ ├── src/
│ │ ├── commonMain/ # 共享逻辑
│ │ ├── androidMain/ # Android 平台代码
│ │ ├── iosMain/ # iOS 平台代码
└── build.gradle # 项目级 build 文件
配置 build.gradle
在 shared 模块的 build.gradle.kts 文件中,配置 Kotlin Multiplatform 插件和依赖项:
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinCocoapods)
alias(libs.plugins.androidLibrary)
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
iosX64()
iosArm64()
iosSimulatorArm64()
cocoapods {
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
version = "1.0"
ios.deploymentTarget = "16.0"
podfile = project.file("../iosApp/Podfile")
framework {
baseName = "shared"
isStatic = true
}
}
sourceSets {
val commonMain by getting {
dependencies {
//put your multiplatform dependencies here
}
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
val androidMain by getting {
dependencies {
// Android 平台依赖
}
}
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
dependencies {
// iOS 平台依赖
}
}
}
}
android {
namespace = "com.dixon.app.kmmsample"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
4. 添加 Compose Multiplatform 依赖
上述项目仅支持业务逻辑共享,支持 UI 共享需要添加 Compose Multiplatform 依赖。
依赖管理和 Gradle 配置
-
配置所需依赖
在 gradle - libs.versions.toml 文件中添加所需依赖。
[versions]
...
[versions]
agp = "8.2.2"
kotlin = "1.9.20"
compose = "1.5.4"
compose-compiler = "1.5.4"
compose-material3 = "1.1.2"
androidx-activityCompose = "1.8.0"
# compose multiplatform support
compose-plugin = "1.6.1"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
# compose multiplatform support
composeLibrary = { id = 'org.jetbrains.compose', version.ref = "compose-plugin" }
-
配置共享模块
在你的共享模块(通常是 shared)的 build.gradle.kts 文件中添加 Compose Multiplatform 的依赖。
plugins {
...
// compose multiplatform support
alias(libs.plugins.composeLibrary)
}
kotlin {
...
sourceSets {
val commonMain by getting {
dependencies {
// put your multiplatform dependencies here
// compose multiplatform support
api(compose.material3) // md 设计的组件
api(compose.foundation) // 基础布局组件
api(compose.ui) // 测量、布局、绘制、事件、Modifier
api(compose.runtime) // 树管理能力
api(compose.animation) // 动画
}
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
...
}
}
- 创建跨平台的 Compose UI
// shared/src/commonMain/kotlin/com/example/shared/Greeting.kt
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
import androidx.compose.material.Text
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
style = TextStyle(fontSize = 24.sp)
)
}
Android 项目中使用 Compose Multiplatform
在 androidApp 模块中,引入并使用共享模块的 Compose 组件:
// androidApp/src/main/java/com/example/mykmmapp/MainActivity.kt
package com.example.mykmmapp
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import com.example.shared.Greeting
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Greeting("Android")
}
}
}
Configuration 选择 AndroidApp 运行。
iOS 项目中使用 Compose Multiplatform
iOS 无法直接使用 Compose UI 组件,但是通过包装成 UIViewController,可以确保这些组件能够在 iOS 的 UIKit 和 SwiftUI 中使用,并充分利用 iOS 平台的视图管理机制。
- 在 iosMain 目录下包装 Compose UI 组件
// shared/src/iosMain/kotlin/com/example/mykmmapp/ui/App.kt
package com.example.mykmmapp
import androidx.compose.ui.window.ComposeUIViewController
import com.example.mykmmapp.ui.App
fun AppViewController() = ComposeUIViewController { App() }
- 在 iosApp 模块中,引入并使用 UIViewController 组件
// iosApp/iosApp/ContentView.swift
import SwiftUI
import shared
// 创建一个 Swift 结构体来包装 UIViewController
struct ComposeUIViewControllerRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
ComposeViewControllerKt.AppViewController() // 调用 Kotlin 中创建的 Compose UIViewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
let greet = Greeting().greet()
var body: some View {
ComposeUIViewControllerRepresentable()
.edgesIgnoringSafeArea(.all) // 如果只想忽略水平方向的 Safe Area
// .edgesIgnoringSafeArea(.horizontal) // 如果只想忽略水平方向的 Safe Area
// .edgesIgnoringSafeArea([]) // 默认不忽略 Safe Area
// .all 状态栏顶部高度、横向 safe area 都会忽略
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Configuration 选择 iosApp 运行,也可以 Xcode 开启 iosApp 目录运行。
5. 实现实例应用
基础环境已经配置完毕,并可以运行在不同平台的设备上。接下来,我将开发一个简单的实例应用,包含网络请求、图片展示、本地资源管理、日志、路由、序列化等应用开发非常基础的能力,为大家演示 KMM + Compose Multiplatform 项目的实战能力。
开始前,推荐简单了解 kmp-awesome 仓库,它整理和推荐了一些 Kotlin Multiplatform 相关的优秀库、工具和资源,是一个非常有价值的资源集合,适合那些想要深入了解和使用 Kotlin Multiplatform 的开发者。
I. 导航
介绍
Navigator 用于在多平台项目中进行高效导航,如果不使用导航,你可能需要在不同的平台下创建页面(如 Android 平台页面由 Activity 承载),并桥接跳转逻辑。而 Navigator 使你可以编写一次导航逻辑,并在 iOS 和 Android 上重用,且提供了一种简洁的方法来管理应用的所有路由。
这里我选用 PreCompose 库提供的跨平台导航能力。
依赖管理和 Gradle 配置
- 配置所需依赖
[versions]
...
precompose = "1.6.1"
[libraries]
...
precompose = { module = "moe.tlaster:precompose", version.ref = "precompose" }
precompose-viewmodel = { module = "moe.tlaster:precompose-viewmodel", version.ref = "precompose" }
- 配置共享模块
val commonMain by getting {
// 跨平台导航库
api(libs.precompose)
// ViewModel
implementation(libs.precompose.viewmodel)
}
使用导航
在 Compose Root UI 组件使用导航:
package com.dixon.app.kmmsample.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import moe.tlaster.precompose.PreComposeApp
import moe.tlaster.precompose.navigation.NavHost
import moe.tlaster.precompose.navigation.Navigator
import moe.tlaster.precompose.navigation.rememberNavigator
import moe.tlaster.precompose.navigation.transition.NavTransition
/*
navigator 使用:
Navigator.navigate(route: String, options: NavOptions? = null)
Navigator.goBack()
Navigator.canGoBack: Boolean
*/
val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("Navigator not provided")
}
@Composable
fun App() {
PreComposeApp {
// your app's content goes here
val navigator = rememberNavigator()
CompositionLocalProvider(LocalNavigator provides navigator) {
NavHost(
// Assign the navigator to the NavHost
navigator = navigator,
// Navigation transition for the scenes in this NavHost, this is optional
navTransition = NavTransition(),
// The start destination
initialRoute = PAGE_HOME_MAIN_ROUTE,
) {
pages.forEach {
scene(
// Scene's route path
route = it.route,
// Navigation transition for this scene, this is optional
navTransition = NavTransition(),
content = it.content
)
}
}
}
}
}
后续任意 Composable 使用 LocalNavigator.current.navigate(route, options)
进行页面跳转。
关于路由的详细用法和封装可以跳转此 示例仓库 查看。
在导航依赖里,我同时导入了 ViewModel 依赖。这是因为在导航返回时,Composable 函数会重新执行,部分数据需要通过 ViewModel 缓存起来。可以说,导航和 ViewModel 是密不可分的。在下边的网络库中你会看到相关应用。
II. 网络库
介绍
Ktor Client 是 Ktor 框架的一部分,由 JetBrains 开发,用于构建高效、非阻塞的 HTTP 客户端。Ktor Client 支持多个平台,如 JVM、Android、JavaScript、Native 等,使其成为 Kotlin Multiplatform 项目中理想的选择。
依赖管理和 Gradle 配置
- 配置所需依赖
[versions]
...
kotlinxSerializationJson = "1.6.1"
kotlinxSerializationPlugin = "2.0.0"
# ktorClientCore = "3.0.0-beta-2" # 新版本有问题弃用
ktorClientCore = "2.3.12" # 选用稳定版本
[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClientCore" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClientCore" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktorClientCore" }
ktor-client-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktorClientCore" }
ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktorClientCore" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktorClientCore" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktorClientCore" }
ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktorClientCore" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorClientCore" }
[plugins]
kotlinSerializationPlugin = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerializationPlugin" }
- 配置共享模块
plugins {
...
// 添加 Kotlin Serialization 插件,注意低版本无效
// 配套 libs.kotlinx.serialization.json 库使用
alias(libs.plugins.kotlinSerializationPlugin)
}
kotlin {
...
sourceSets {
val commonMain by getting {
dependencies {
// put your multiplatform dependencies here
...
// 跨平台网络库
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
val androidMain by getting {
dependencies {
// 平台网络库引擎
implementation(libs.ktor.client.okhttp)
}
}
...
val iosMain by creating {
...
dependencies {
// 平台网络库引擎
implementation(libs.ktor.client.ios)
}
}
}
}
使用网络库
这里我使用 狗狗免费 API 来测试我的网络功能。
- 初始化 KtorClient 实例
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage
import io.ktor.client.plugins.cookies.HttpCookies
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
val ktorClient = HttpClient {
install(ContentNegotiation) {
json()
}
install(HttpCookies) {
storage = AcceptAllCookiesStorage()
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
expectSuccess = true
}
// ---- 简单封装 ----
private const val KTOR_TAG = "KtorSafeCall"
// append try-catch
suspend fun <T> safeCall(
client: HttpClient = ktorClient,
call: suspend HttpClient.() -> T
): Result<T> {
return try {
val result = call(client)
com.dixon.app.kmmsample.core.base.Logger.i(KTOR_TAG) { "Request succeed: $result" }
Result(data = result)
} catch (e: ConnectTimeoutException) {
com.dixon.app.kmmsample.core.base.Logger.i(KTOR_TAG) { "Connection timed out: ${e.message}" }
Result(exception = e)
} catch (e: HttpRequestTimeoutException) {
com.dixon.app.kmmsample.core.base.Logger.i(KTOR_TAG) { "Request timed out: ${e.message}" }
Result(exception = e)
} catch (e: ClientRequestException) {
// 4xx responses
com.dixon.app.kmmsample.core.base.Logger.i(KTOR_TAG) { "Client request error: ${e.response.status.description}" }
Result(exception = e)
} catch (e: ServerResponseException) {
// 5xx responses
com.dixon.app.kmmsample.core.base.Logger.i(KTOR_TAG) { "Server response error: ${e.response.status.description}" }
Result(exception = e)
} catch (e: Exception) {
com.dixon.app.kmmsample.core.base.Logger.i(KTOR_TAG) { "An unexpected error occurred: ${e.message}" }
Result(exception = e)
}
}
suspend inline fun <reified T> apiGet(
source: String
): Result<T> =
safeCall {
get(source).body<T>()
}
data class Result<T>(
val data: T? = null,
val exception: Throwable? = null
)
fun <T> Result<T>.isSucceed() = data != null
fun <T> Result<T>.isFailed() = exception != null
inline fun <T> Result<T>.dispose(
onSucceed: (T) -> Unit,
onFailed: (Throwable) -> Unit
) {
if (data != null) {
onSucceed.invoke(data)
} else {
onFailed.invoke(exception ?: RuntimeException("Unknown Exception"))
}
}
inline fun <T> Result<T>.disposeSucceed(
onSucceed: (T) -> Unit,
) = this.apply {
if (data != null) {
onSucceed.invoke(data)
}
}
inline fun <T> Result<T>.disposeFailed(
onFailed: (Throwable) -> Unit,
) = this.apply {
if (exception != null) {
onFailed.invoke(exception)
}
}
- 创建 ViewModel 发起并保存请求
package com.dixon.app.kmmsample.logic.home
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import com.dixon.app.kmmsample.bean.DogData
import com.dixon.app.kmmsample.core.base.Logger
import com.dixon.app.kmmsample.core.base.apiGet
import com.dixon.app.kmmsample.core.base.disposeFailed
import com.dixon.app.kmmsample.core.base.disposeSucceed
import kotlinx.coroutines.launch
import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope
class HomeViewModel : ViewModel() {
private var _data: MutableState<DogData?> = mutableStateOf(null)
val data: State<DogData?> = _data
// 请求数据,并将返回的 Json 转为 DogData 保存起来
fun fetchData() {
viewModelScope.launch {
apiGet<List<DogData>>("https://api.thecatapi.com/v1/images/search?api_key=请自行申请 api_key")
.disposeSucceed {
_data.value = it.first()
}.disposeFailed {
Logger.e("HomeViewModel#fetchData") { it.message.toString() }
}
}
}
}
Bean 类:
package com.dixon.app.kmmsample.bean
import kotlinx.serialization.Serializable
/**
* 测试接口返回的 json 数据
*/
@Serializable
data class DogData(
val id: String,
val url: String,
val width: Int,
val height: Int
)
- 页面使用 ViewModel
package com.dixon.app.kmmsample.ui.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.dixon.app.kmmsample.MR
import com.dixon.app.kmmsample.core.base.Logger
import com.dixon.app.kmmsample.logic.home.HomeViewModel
import com.dixon.app.kmmsample.ui.Builder
import com.dixon.app.kmmsample.ui.LocalNavigator
import com.dixon.app.kmmsample.ui.PAGE_IMAGE_DETAIL_ROUTE
import com.dixon.app.kmmsample.ui.build
import com.dixon.app.kmmsample.ui.put
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import moe.tlaster.precompose.viewmodel.viewModel
/**
* Navigator与传统Activity不同,跨页面要使用ViewModel缓存数据,否则返回页面会重新请求
*/
@Composable
fun HomeMain() {
Logger.i("HomeMain") { "Composable Root : HomeMain" }
val navigator = LocalNavigator.current
val homeVM = viewModel(
modelClass = HomeViewModel::class,
creator = {
HomeViewModel()
})
val data by homeVM.data
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
homeVM.fetchData()
}) {
Text("点击获取随机图片数据")
}
Spacer(Modifier.height(20.dp))
Text(
text = data?.toString() ?: "请先获取数据!",
fontSize = 14.sp,
color = Color.Gray
)
Spacer(Modifier.height(20.dp))
Button(onClick = {
// 导航到图片展示页,详见下文图片库
navigator.Builder(PAGE_IMAGE_DETAIL_ROUTE)
.put("resource", data?.url)
.build()
}) {
Text("展示网图")
}
}
}
关于网络库的详细用法和封装可以跳转此 示例仓库 查看。
III. 日志
介绍
演示项目相对简单,日志功能我选择了自行实现,并借此演示如何使用平台能力实现跨平台代码。
代码实现
- CommonMain 添加跨平台代码
// shared/src/commonMain/kotlin/com/dixon/app/kmmsample/core/base/Logger.kt
package com.dixon.app.kmmsample.core.base
object Logger {
enum class LogLevel {
DEBUG, INFO, WARNING, ERROR, NONE
}
var currentLogLevel: LogLevel = LogLevel.NONE
private set
var isLoggingEnabled: Boolean = false
private set
// 首次调用 Ln 时执行
init {
enableLogging(true)
setLogLevel(LogLevel.DEBUG)
}
fun setLogLevel(level: LogLevel) {
currentLogLevel = level
}
fun enableLogging(enable: Boolean) {
isLoggingEnabled = enable
}
fun d(tag: String, message: () -> String) {
if (isLoggingEnabled && currentLogLevel <= LogLevel.DEBUG) {
print(LogLevel.DEBUG, tag, message.invoke())
}
}
fun i(tag: String, message: () -> String) {
if (isLoggingEnabled && currentLogLevel <= LogLevel.INFO) {
print(LogLevel.INFO, tag, message.invoke())
}
}
fun w(tag: String, message: () -> String) {
if (isLoggingEnabled && currentLogLevel <= LogLevel.WARNING) {
print(LogLevel.WARNING, tag, message.invoke())
}
}
fun e(tag: String, message: () -> String) {
if (isLoggingEnabled && currentLogLevel <= LogLevel.ERROR) {
print(LogLevel.ERROR, tag, message.invoke())
}
}
}
internal expect fun print(level: Logger.LogLevel, tag: String, message: String)
在不同平台上,输出日志的方式是不一致的(如 Android 使用 Log,iOS 使用 NSLog)。为了支持跨平台的开发,Kotlin 提供了 expect 和 actual 关键字。expect 声明用于在共享模块中声明一个平台无关的接口或方法,而 actual 声明用于在平台特定的模块中实现这些接口或方法。实际上项目依赖的基础能力库都是通过这样的方式实现了跨平台能力。
- 实现 Android 平台代码
// shared/src/androidMain/kotlin/com/dixon/app/kmmsample/core/base/Logger.android.kt
package com.dixon.app.kmmsample.core.base
import android.util.Log
internal actual fun print(level: Logger.LogLevel, tag: String, message: String) {
when (level) {
Logger.LogLevel.INFO -> Log.i(tag, message)
Logger.LogLevel.DEBUG -> Log.d(tag, message)
Logger.LogLevel.WARNING -> Log.w(tag, message)
Logger.LogLevel.ERROR -> Log.e(tag, message)
Logger.LogLevel.NONE -> Log.i(tag, message)
}
}
- 实现 iOS 平台代码
// shared/src/iosMain/kotlin/com/dixon/app/kmmsample/core/base/Logger.ios.kt
package com.dixon.app.kmmsample.core.base
import platform.Foundation.NSLog
/*
关于在 iosMain 中使用 ios 原生/第三方 api:
1. 在 Kotlin Multiplatform 项目中,NSLog 以及其他 NS 前缀的函数和类来自于 Kotlin/Native 提供的对 Apple 平台(iOS 和 macOS)的基础库的绑定。
这些绑定是 Kotlin/Native 标准库的一部分,允许你在 Kotlin 代码中直接使用 Apple 平台的 API。
2. 通过使用 Kotlin/Native 提供的 CocoaPods 集成,你可以在 Kotlin Multiplatform 项目中使用 iOS 的第三方 SDK。
*/
internal actual fun print(level: Logger.LogLevel, tag: String, message: String) {
val logMessage = when (level) {
Logger.LogLevel.DEBUG -> "DEBUG[$tag]: $message"
Logger.LogLevel.INFO -> "INFO[$tag]: $message"
Logger.LogLevel.WARNING -> "WARN[$tag]: $message"
Logger.LogLevel.ERROR -> "ERROR[$tag]: $message"
Logger.LogLevel.NONE -> "NONE[$tag]: $message"
}
NSLog(logMessage)
}
使用日志
Logger.i(TAG) { "Request succeed: $xx" }
IV. 图片库
介绍
一般应用都有展示本地图片、网络图片的能力,本地图片涉及本地资源,我们先跳过,这里我们演示如何展示网络图片。
这里我选用 Coil3 库提供的跨平台图片库能力。
Coil3 目前只有 Alpha 版本,有精力也可以通过桥接各端图片库自行实现。
依赖管理和 Gradle 配置
- 配置所需依赖
[versions]
coil3 = "3.0.0-alpha08" # 09 没有对应版本的 ktor
[libraries]
coil3-core = { module = "io.coil-kt.coil3:coil", version.ref = "coil3" }
coil3-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" }
coil3-ktor = { module = "io.coil-kt.coil3:coil-network-ktor", version.ref = "coil3" }
- 配置共享模块
val commonMain by getting {
dependencies {
// put your multiplatform dependencies here
...
// 图片库 目前还是Alpha版本
implementation(libs.coil3.core)
implementation(libs.coil3.ktor)
implementation(libs.coil3.compose)
}
}
使用图片库
AsyncImage(
modifier = Modifier.fillMaxWidth().align(Alignment.Center)
.clip(RoundedCornerShape(10.dp)),
model = resource,
contentDescription = null,
contentScale = ContentScale.FillWidth
)
在 Demo 项目里,我创建了一个新的页面用于展示网络库请求到的狗狗图片,使用导航可以携带图片 url 跳转到该页面,并且通过 navigator.goBack()
可以重新返回至上一级页面。
package com.dixon.app.kmmsample.ui.image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.dixon.app.kmmsample.core.base.Logger
import com.dixon.app.kmmsample.ui.LocalNavigator
@Composable
fun ImageDetail(resource: String?) {
Logger.i("ImageDetail") { "Composable Root : ImageDetail $resource" }
val navigator = LocalNavigator.current
if (resource.isNullOrEmpty()) {
Logger.i("ImageDetail") { "Composable Root : ImageDetail ErrorTipPage" }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Error",
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold
)
Spacer(Modifier.height(20.dp))
Text(text = "Please request image first!")
Spacer(Modifier.height(20.dp))
Button(onClick = {
navigator.goBack()
}) {
Text("返回")
}
}
} else {
Logger.i("ImageDetail") { "Composable Root : ImageDetail ContentPage" }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
/*
or ImageRequest、Painter
*/
AsyncImage(
modifier = Modifier.fillMaxWidth().align(Alignment.Center)
.clip(RoundedCornerShape(10.dp)),
model = resource,
contentDescription = null,
contentScale = ContentScale.FillWidth
)
Button(onClick = {
navigator.goBack()
}) {
Text("Back")
}
}
}
}
建议结合 示例仓库 查看相关代码。
V. 本地资源
介绍
Compose UI 支持跨平台,相应的,string、image、fonts、colors 等本地资源也需要跨平台支持。
moko-resources 是一个 Kotlin Multiplatform 库和 Gradle 插件,旨在提供在 macOS、iOS、Android、JVM 和 JS/Browser 上访问本地资源的功能。
依赖管理和 Gradle 配置
- 配置根目录 build.gradle
buildscript {
dependencies {
// 本地资源配置1
classpath("dev.icerock.moko:resources-generator:0.24.1")
}
}
- 配置共享模块
plugins {
...
// 本地资源配置2
id("dev.icerock.mobile.multiplatform-resources")
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
//put your multiplatform dependencies here
...
// 本地资源配置3
api("dev.icerock.moko:resources:0.24.1")
api("dev.icerock.moko:resources-compose:0.24.1")
}
}
...
}
}
// 本地资源配置 4
multiplatformResources {
resourcesPackage.set("com.dixon.app.kmmsample") // required
// resourcesClassName.set("SharedRes") // optional, default MR
// resourcesVisibility.set(MRVisibility.Internal) // optional, default Public
iosBaseLocalizationRegion.set("en") // optional, default "en"
iosMinimalDeploymentTarget.set("16.0") // optional, default "9.0"
}
-
iOS 配置
由于 iOS 是静态库,不支持直接打包资源,因此需要通过脚本将资源拷贝到 iOS 应用中。
配置步骤
3.1. 在 gradle.properties
中添加以下配置:
moko.resources.disableStaticFrameworkWarning=true
3.2. 在 Xcode 中配置脚本:
* 打开 `iosApp`
* 点击 `iosApp` 目录
* 选择 `Build Phases`
* 点击 "+"
* 选择 `New Run Script Phase`
* 将以下脚本粘贴进去:
# Type a script or drag a script file from your workspace to insert its path.
"$SRCROOT/../gradlew" -p "$SRCROOT/../" :shared:copyFrameworkResourcesToApp \
-Pmoko.resources.BUILT_PRODUCTS_DIR="$BUILT_PRODUCTS_DIR" \
-Pmoko.resources.CONTENTS_FOLDER_PATH="$CONTENTS_FOLDER_PATH" \
-Pkotlin.native.cocoapods.platform="$PLATFORM_NAME" \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
更多资源配置与脚本配置细节详见 GitHub 项目文档
使用本地资源
-
添加本地资源
新增资源目录:
MyKMMApp/
├── shared/ # 共享模块
│ ├── src/
│ │ ├── commonMain/ # 共享的主要代码(跨平台)
│ │ │ ├── kotlin/ # 共享的 Kotlin 源代码文件夹
│ │ │ ├── moko-resources/ # 共享的本地资源文件夹
│ │ │ │ ├── images/ # 共享的本地图片资源文件夹
│ │ │ │ │ ├── demo@2x.png # 共享的本地图片资源
│ │ │ │ ├── base/ # 共享的本地字符串资源文件夹
│ │ │ │ │ ├── strings.xml # 共享的本地字符串资源
│ │ │ │ ├── zh-CN/ # 共享的本地字符串资源文件夹 中文
│ │ │ │ │ ├── strings.xml # 共享的本地字符串资源
│ │ │ ├── ...
│ ├── ...
├── ...
- 使用资源
// 图片
painterResource(MR.images.demo)
// 字符串
stringResource(MR.strings.welcome_message)
详见 示例仓库 。
VI. 效果演示
示例仓库 演示:
图片演示页.png
6. KMM & Compose Multiplatform 进阶 & 畅想
使用 KMM(Kotlin Multiplatform Mobile)和 Compose Multiplatform 结合开发具有如下优点:
优点
- 共享逻辑:利用 KMM 共享业务逻辑和数据处理代码。
- 共享 UI:通过 Compose Multiplatform 共享 UI 组件和布局。
这种结合使得开发人员可以在业务逻辑和界面上均实现代码共享,从而提高开发效率和一致性,同时保留对各个平台进行特定优化和实现的灵活性。
跨平台特性
由于 KMM 和 Compose Multiplatform 出色的跨平台特性,即便是新的平台也可以低成本支持。据笔者所知,某厂支持了包括鸿蒙平台在内的跨移动三端的 KMM & Compose Multiplatform,并基于此开发跨三端的业务代码。相信这些代码不久的将来就会开源出来。
支持鸿蒙平台
Compose Multiplatform 仍然可以基于 Skiko 渲染,KMM 可以使用 Kotlin/JS 桥接 ArkTs,但是 Kotlin 的很多功能特性会丧失。因此,建议使用 Kotlin/Native + N-API 桥接层,这样可以用 Kotlin 语言编写高性能的本地代码,并将其作为原生扩展在 Node.js 应用程序中使用。基本原理:
- 编写 Kotlin/Native 代码:使用 Kotlin/Native 编写需要实现的功能代码,并将其编译为共享库(例如 .so 文件)。
- 使用 N-API:在 Node.js 端使用 N-API 管理和调用这一共享库。
7. 进阶学习和资源
Kotlin Multiplatform Mobile 官方文档
KMM 官方博客,最新消息
Compose-multiplatform 官方 Github
KMM 开源三方库整合
8. 总结
Kotlin Multiplatform Mobile (KMM) 和 Compose Multiplatform 未来充满前景。以下是一些核心展望:
- 更多生产级应用:更多企业和开发者将采用这些技术构建跨平台应用。
- 改进的工具链:IntelliJ IDEA 和 Android Studio 将提供更强大的支持,提升开发效率。
- 更多平台支持:支持范围将扩展到桌面和 Web 平台,实现更广泛的跨平台覆盖。
- 性能优化:进一步提升生成代码的性能,接近原生体验。
- 壮大的库生态系统:更多第三方库和插件将出现,简化开发过程。
- 社区贡献:活跃的社区将提供丰富的开源项目和知识分享,推动技术发展。
- 改进的文档和教育资源:官方和社区将提供更全面的教程和学习材料,助力开发者快速上手。
- 广泛的企业级应用采纳:随着技术的成熟和优化,更多企业将采用这些技术开发关键应用。
总之,KMM 和 Compose Multiplatform 将大幅简化跨平台开发,提高效率和应用质量,成为跨平台开发的重要选择。
[TOC]