创建你自己的应用
本教程使用 IntelliJ IDEA,但你也可以在 Android Studio 中进行——这两个 IDE 共享相同的核心功能和 Kotlin Multiplatform 支持。
这是使用共享逻辑和 UI 创建 Compose 跨平台应用教程的最后一部分。在继续之前,请确保你已经完成了之前的步骤。
既然你已经探索并增强了由向导创建的示例项目,你就可以利用已经掌握的概念并引入一些新概念,从头开始创建自己的应用。
你将创建一个“本地时间应用”,用户可以在其中输入国家和城市,应用将显示该国家首都的时间。Compose 跨平台应用的所有功能都将使用跨平台库在公共代码中实现。它将在下拉菜单中加载并显示图片,并将使用事件、样式、主题、修饰符和布局。
在每个阶段,你都可以在所有三个平台(iOS、Android 和桌面端)上运行应用,或者你可以专注于最适合你需求的特定平台。
你可以在我们的 GitHub 仓库中找到项目的最终状态。
奠定基础
首先,实现一个新的 App() 可组合项:
在
shared/src/commonMain/kotlin中,打开App.kt文件,并用以下App()可组合项替换其中的代码:kotlin@Composable @Preview fun App() { MaterialTheme { var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), ) { Text(timeAtLocation) Button(onClick = { timeAtLocation = "13:30" }) { Text("Show Time At Location") } } } }- 布局是一个包含两个可组合项的列。第一个是
Text可组合项,第二个是Button。 - 这两个可组合项通过一个共享状态(即
timeAtLocation属性)相关联。Text可组合项是该状态的观察者。 Button可组合项使用onClick事件处理程序更改状态。
- 布局是一个包含两个可组合项的列。第一个是
在 Android 和 iOS 上运行应用:

当你运行应用并点击按钮时,会显示硬编码的时间 13:30。
通过启动 desktopApp [hot] 🔥 运行配置,使用 Compose 实时重新加载 (Hot Reload) 在桌面端运行应用。 应用可以运行,但窗口对于 UI 来说显然不匹配:

为了修复这个问题,如下更新
desktopApp/src/kotlin目录中的main.kt文件:kotlinfun main() = application { val state = rememberWindowState( size = DpSize(400.dp, 350.dp), position = WindowPosition(300.dp, 300.dp) ) Window( title = "Local Time App", onCloseRequest = ::exitApplication, state = state, alwaysOnTop = true ) { App() } }在这里,你设置了窗口标题,并使用
WindowState类型在屏幕上给出了窗口的初始大小和位置。按照 IDE 的指示导入缺失的依赖项。
要查看应用自动更新,请保存任何修改过的文件( / )。它的外观应该会有所改善:


支持用户输入
现在让用户输入城市名称以查看该位置的时间。实现此目的最简单的方法是添加一个 TextField 可组合项:
将
commonMain/kotlin/compose.project.demo/App.kt中App()的当前实现替换为下面的代码:kotlin@Composable @Preview fun App() { MaterialTheme { var location by remember { mutableStateOf("Europe/Paris") } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), ) { Text(timeAtLocation) TextField(value = location, onValueChange = { location = it }) Button(onClick = { timeAtLocation = "13:30" }) { Text("Show Time At Location") } } } }新代码同时添加了
TextField和location属性。当用户在文本字段中输入时,该属性的值会通过onValueChange事件处理程序逐步更新。按照 IDE 的建议导入缺失的依赖项。
在你针对的每个平台上运行应用。显示的时间仍然是硬编码的,但现在你可以在文本字段中输入时区:



计算时间
下一步是使用给定的输入来计算时间。为此,创建一个 currentTimeAt() 函数:
返回
shared/src/commonMain/kotlin/compose.project.demo/App.kt文件并添加以下函数:kotlinfun currentTimeAt(location: String): String? { fun LocalTime.formatted() = "$hour:$minute:$second" return try { val time = Clock.System.now() val zone = TimeZone.of(location) val localTime = time.toLocalDateTime(zone).time "The time in $location is ${localTime.formatted()}" } catch (ex: IllegalTimeZoneException) { null } }此函数类似于你之前创建且不再需要的
todaysDate()。如果尚未将 kotlinx-datetime 库添加到项目中,请按照添加新依赖项部分中的说明进行操作。
按照 IDE 的指示导入缺失的依赖项。 确保从
kotlin.time导入Clock类,而不是kotlinx.datetime。调整你的
App可组合项以调用currentTimeAt():kotlin@Composable @Preview fun App() { MaterialTheme { var location by remember { mutableStateOf("Europe/Paris") } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .safeContentPadding() .fillMaxSize() ) { Text(timeAtLocation) TextField(value = location, onValueChange = { location = it }) Button(onClick = { timeAtLocation = currentTimeAt(location) ?: "Invalid Location" }) { Text("Show Time At Location") } } } }再次运行应用并输入有效的时区。
点击按钮。你应该会看到正确的时间:



改进样式
应用运行正常,但其外观存在问题。可组合项之间的间距可以更好,时间消息也可以渲染得更突出。
为了解决这些问题,使用以下版本的
App可组合项:kotlin@Composable @Preview fun App() { MaterialTheme { var location by remember { mutableStateOf("Europe/Paris") } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .padding(20.dp) .safeContentPadding() .fillMaxSize(), ) { Text( timeAtLocation, style = TextStyle(fontSize = 20.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) ) TextField( value = location, onValueChange = { location = it }, modifier = Modifier.padding(top = 10.dp) ) Button( onClick = { timeAtLocation = currentTimeAt(location) ?: "Invalid Location" }, modifier = Modifier.padding(top = 10.dp) ) { Text("Show Time") } } } }modifier参数在Column周围以及Button和TextField的顶部添加了内边距。Text可组合项填充可用的水平空间并居中其内容。style参数自定义了Text的外观。
按照 IDE 的指示导入缺失的依赖项。
运行应用以查看外观是如何改进的:



重构 UI
应用运行正常,但容易受到拼写错误的影响。例如,如果用户输入 "Franse" 而不是 "France",应用将无法处理该输入。最好是让用户从预定义的列表中选择国家。
为此,请更新
App()可组合项和currentTimeAt()函数,并添加一个辅助数据类:kotlindata class Country(val name: String, val zone: TimeZone) fun currentTimeAt(location: String, zone: TimeZone): String { fun LocalTime.formatted() = "$hour:$minute:$second" val time = Clock.System.now() val localTime = time.toLocalDateTime(zone).time return "The time in $location is ${localTime.formatted()}" } fun countries() = listOf( Country("Japan", TimeZone.of("Asia/Tokyo")), Country("France", TimeZone.of("Europe/Paris")), Country("Mexico", TimeZone.of("America/Mexico_City")), Country("Indonesia", TimeZone.of("Asia/Jakarta")), Country("Egypt", TimeZone.of("Africa/Cairo")), ) @Composable @Preview fun App(countries: List<Country> = countries()) { MaterialTheme { var showCountries by remember { mutableStateOf(false) } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .padding(20.dp) .safeContentPadding() .fillMaxSize(), ) { Text( timeAtLocation, style = TextStyle(fontSize = 20.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) ) Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { DropdownMenu( expanded = showCountries, onDismissRequest = { showCountries = false } ) { countries().forEach { (name, zone) -> DropdownMenuItem( text = { Text(name)}, onClick = { timeAtLocation = currentTimeAt(name, zone) showCountries = false } ) } } } Button(modifier = Modifier.padding(start = 20.dp, top = 10.dp), onClick = { showCountries = !showCountries }) { Text("Select Location") } } } }- 定义了一个
Country类型,由名称和时区组成。 currentTimeAt()函数将TimeZone作为其第二个参数。App现在需要一个国家列表作为参数。countries()函数提供该列表。DropdownMenu取代了TextField。showCountries属性的值决定了DropdownMenu的可见性。每个国家都有一个DropdownMenuItem。
- 定义了一个
按照 IDE 的指示导入缺失的依赖项。 导入
Row()时,请选择@Composable版本。运行应用以查看重新设计后的版本:



你可以使用依赖注入框架(如 Koin)来构建和注入位置表,从而进一步改进设计。如果数据存储在外部,你可以使用 Ktor 库通过网络获取数据,或者使用 SQLDelight 库从数据库获取数据。
引入图片
国家名称列表可以运行,但用户体验不佳。 你可以通过在国家名称旁边添加国旗图片来改进列表。
Compose 跨平台提供了一个库,用于通过所有平台的公共代码访问资源。Kotlin Multiplatform 向导已经添加并配置了此库,因此你可以直接开始加载资源,而无需修改构建文件。
要在项目中支持图片,你需要下载图片文件,将它们存储在正确的目录中,并添加代码来加载和显示它们:
从 Flag CDN 下载国旗图片,以匹配你已经创建的国家列表。在本例中,分别是 日本、法国、墨西哥、印度尼西亚和埃及。
将图片移动到
composeApp/src/commonMain/composeResources/drawable目录,以便相同的国旗在所有平台上都可用:
构建或运行应用以生成带有新增资源访问器的
Res类。更新
commonMain/kotlin/.../App.kt文件中的代码以支持图片:kotlinimport demo.composeapp.generated.resources.jp import demo.composeapp.generated.resources.mx import demo.composeapp.generated.resources.eg import demo.composeapp.generated.resources.fr import demo.composeapp.generated.resources.id data class Country(val name: String, val zone: TimeZone, val image: DrawableResource) fun currentTimeAt(location: String, zone: TimeZone): String { fun LocalTime.formatted() = "$hour:$minute:$second" val time = Clock.System.now() val localTime = time.toLocalDateTime(zone).time return "The time in $location is ${localTime.formatted()}" } val defaultCountries = listOf( Country("Japan", TimeZone.of("Asia/Tokyo"), Res.drawable.jp), Country("France", TimeZone.of("Europe/Paris"), Res.drawable.fr), Country("Mexico", TimeZone.of("America/Mexico_City"), Res.drawable.mx), Country("Indonesia", TimeZone.of("Asia/Jakarta"), Res.drawable.id), Country("Egypt", TimeZone.of("Africa/Cairo"), Res.drawable.eg) ) @Composable @Preview fun App(countries: List<Country> = defaultCountries) { MaterialTheme { var showCountries by remember { mutableStateOf(false) } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .padding(20.dp) .safeContentPadding() .fillMaxSize(), ) { Text( timeAtLocation, style = TextStyle(fontSize = 20.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) ) Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { DropdownMenu( expanded = showCountries, onDismissRequest = { showCountries = false } ) { countries.forEach { (name, zone, image) -> DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { Image( painterResource(image), modifier = Modifier.size(50.dp).padding(end = 10.dp), contentDescription = "$name flag" ) Text(name) } }, onClick = { timeAtLocation = currentTimeAt(name, zone) showCountries = false } ) } } } Button(modifier = Modifier.padding(start = 20.dp, top = 10.dp), onClick = { showCountries = !showCountries }) { Text("Select Location") } } } }Country类型存储相关图片的路径。- 传递给
App的国家列表包含这些路径。 App在每个DropdownMenuItem中显示一个Image,随后是一个带有国家名称的Text可组合项。- 每个
Image都需要一个Painter对象来获取数据。
按照 IDE 的指示导入缺失的依赖项。
运行应用以查看新行为:



你可以在我们的 GitHub 仓库中找到项目的最终状态。
下一步
我们鼓励你进一步探索跨平台开发并尝试更多项目:
- 让你的 Android 应用实现跨平台
- 使用 Ktor 和 SQLDelight 创建跨平台应用
- 在 iOS 和 Android 之间共享业务逻辑,同时保持 UI 原生
- 使用 Kotlin/Wasm 创建 Compose 跨平台应用
- 查看精选示例项目列表
加入社区:
Compose Multiplatform GitHub:为该仓库点赞并贡献代码
Kotlin Slack:获取邀请并加入 #multiplatform 频道
Stack Overflow:订阅 "kotlin-multiplatform" 标签
Kotlin YouTube 频道:订阅并观看有关 Kotlin Multiplatform 的视频