Kotlinv2.4.0

创建你自己的应用

本教程使用 IntelliJ IDEA,但你也可以在 Android Studio 中进行——这两个 IDE 共享相同的核心功能和 Kotlin Multiplatform 支持。



这是使用共享逻辑和 UI 创建 Compose 跨平台应用教程的最后一部分。在继续之前,请确保你已经完成了之前的步骤。

第一步

创建你的 Compose 跨平台应用
本教程使用 IntelliJ IDEA,但你也可以在 Android Studio 中进行——这两个 IDE 共享相同的核心功能和 Kotlin Multiplatform 支持。这是使用共享逻辑和 UI 创建 Compose 跨平台应用教程的第一部分。创建你的 Compose 跨平台应用 探索可组合代码 修改项目 创建你自己的应用

第二步
探索可组合项代码
本教程使用 IntelliJ IDEA,但你也可以在 Android Studio 中进行——这两个 IDE 共享相同的核心功能和 Kotlin Multiplatform 支持。这是使用共享逻辑和 UI 创建 Compose 跨平台应用教程的第二部分。在继续之前,请确保你已经完成了之前的步骤。创建你的 Compose 跨平台应用 探索可组合代码 修改项目 创建你自己的应用

第三步
修改项目
本教程使用 IntelliJ IDEA,但你也可以在 Android Studio 中进行——这两个 IDE 共享相同的核心功能和 Kotlin Multiplatform 支持。这是使用共享逻辑和 UI 创建 Compose 跨平台应用教程的第三部分。在继续之前,请确保你已经完成了之前的步骤。创建你的 Compose 跨平台应用 探索可组合代码 修改项目 创建你自己的应用

第四步 创建你自己的应用

既然你已经探索并增强了由向导创建的示例项目,你就可以利用已经掌握的概念并引入一些新概念,从头开始创建自己的应用。

你将创建一个“本地时间应用”,用户可以在其中输入国家和城市,应用将显示该国家首都的时间。Compose 跨平台应用的所有功能都将使用跨平台库在公共代码中实现。它将在下拉菜单中加载并显示图片,并将使用事件、样式、主题、修饰符和布局。

在每个阶段,你都可以在所有三个平台(iOS、Android 和桌面端)上运行应用,或者你可以专注于最适合你需求的特定平台。

你可以在我们的 GitHub 仓库中找到项目的最终状态。

奠定基础

首先,实现一个新的 App() 可组合项:

  1. 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 事件处理程序更改状态。
  2. 在 Android 和 iOS 上运行应用:

    Android 和 iOS 上的新 Compose 跨平台应用

    当你运行应用并点击按钮时,会显示硬编码的时间 13:30。

  3. 通过启动 desktopApp [hot] 🔥 运行配置,使用 Compose 实时重新加载 (Hot Reload) 在桌面端运行应用。 应用可以运行,但窗口对于 UI 来说显然不匹配:

    桌面端的新 Compose 跨平台应用

  4. 为了修复这个问题,如下更新 desktopApp/src/kotlin 目录中的 main.kt 文件:

    kotlin
    fun 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 类型在屏幕上给出了窗口的初始大小和位置。

  5. 按照 IDE 的指示导入缺失的依赖项。

  6. 要查看应用自动更新,请保存任何修改过的文件( / )。它的外观应该会有所改善:

    桌面端 Compose 跨平台应用窗口缩小

    Compose 实时重新加载 (Hot Reload)

支持用户输入

现在让用户输入城市名称以查看该位置的时间。实现此目的最简单的方法是添加一个 TextField 可组合项:

  1. commonMain/kotlin/compose.project.demo/App.ktApp() 的当前实现替换为下面的代码:

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

    新代码同时添加了 TextFieldlocation 属性。当用户在文本字段中输入时,该属性的值会通过 onValueChange 事件处理程序逐步更新。

  2. 按照 IDE 的建议导入缺失的依赖项。

  3. 在你针对的每个平台上运行应用。显示的时间仍然是硬编码的,但现在你可以在文本字段中输入时区:

Android 和 iOS 上 Compose 跨平台应用中的用户输入
桌面端 Compose 跨平台应用中的用户输入
Web 上 Compose 跨平台应用中的用户输入

计算时间

下一步是使用给定的输入来计算时间。为此,创建一个 currentTimeAt() 函数:

  1. 返回 shared/src/commonMain/kotlin/compose.project.demo/App.kt 文件并添加以下函数:

    kotlin
    fun 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 库添加到项目中,请按照添加新依赖项部分中的说明进行操作。

  2. 按照 IDE 的指示导入缺失的依赖项。 确保从 kotlin.time 导入 Clock 类,而不是 kotlinx.datetime

  3. 调整你的 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")
               }
           }
       }
    }
  4. 再次运行应用并输入有效的时区。

  5. 点击按钮。你应该会看到正确的时间:

Android 和 iOS 上 Compose 跨平台应用中的时间显示
桌面端 Compose 跨平台应用中的时间显示
Web 上 Compose 跨平台应用中的时间显示

改进样式

应用运行正常,但其外观存在问题。可组合项之间的间距可以更好,时间消息也可以渲染得更突出。

  1. 为了解决这些问题,使用以下版本的 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 周围以及 ButtonTextField 的顶部添加了内边距。
    • Text 可组合项填充可用的水平空间并居中其内容。
    • style 参数自定义了 Text 的外观。
  2. 按照 IDE 的指示导入缺失的依赖项。

  3. 运行应用以查看外观是如何改进的:

Android 和 iOS 上 Compose 跨平台应用外观改进
桌面端 Compose 跨平台应用外观改进
Web 上 Compose 跨平台应用外观改进

重构 UI

应用运行正常,但容易受到拼写错误的影响。例如,如果用户输入 "Franse" 而不是 "France",应用将无法处理该输入。最好是让用户从预定义的列表中选择国家。

  1. 为此,请更新 App() 可组合项和 currentTimeAt() 函数,并添加一个辅助数据类:

    kotlin
    data 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 取代了 TextFieldshowCountries 属性的值决定了 DropdownMenu 的可见性。每个国家都有一个 DropdownMenuItem
  2. 按照 IDE 的指示导入缺失的依赖项。 导入 Row() 时,请选择 @Composable 版本。

  3. 运行应用以查看重新设计后的版本:

Android 和 iOS 上 Compose 跨平台应用中的国家列表
桌面端 Compose 跨平台应用中的国家列表
Web 上 Compose 跨平台应用中的国家列表

你可以使用依赖注入框架(如 Koin)来构建和注入位置表,从而进一步改进设计。如果数据存储在外部,你可以使用 Ktor 库通过网络获取数据,或者使用 SQLDelight 库从数据库获取数据。

引入图片

国家名称列表可以运行,但用户体验不佳。 你可以通过在国家名称旁边添加国旗图片来改进列表。

Compose 跨平台提供了一个库,用于通过所有平台的公共代码访问资源。Kotlin Multiplatform 向导已经添加并配置了此库,因此你可以直接开始加载资源,而无需修改构建文件。

要在项目中支持图片,你需要下载图片文件,将它们存储在正确的目录中,并添加代码来加载和显示它们:

  1. Flag CDN 下载国旗图片,以匹配你已经创建的国家列表。在本例中,分别是 日本法国墨西哥印度尼西亚埃及

  2. 将图片移动到 composeApp/src/commonMain/composeResources/drawable 目录,以便相同的国旗在所有平台上都可用:

    Compose 跨平台资源项目结构

  3. 构建或运行应用以生成带有新增资源访问器的 Res 类。

  4. 更新 commonMain/kotlin/.../App.kt 文件中的代码以支持图片:

    kotlin
    import 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 对象来获取数据。
  5. 按照 IDE 的指示导入缺失的依赖项。

  6. 运行应用以查看新行为:

Android 和 iOS 上 Compose 跨平台应用中的国旗
桌面端 Compose 跨平台应用中的国旗
Web 上 Compose 跨平台应用中的国旗

你可以在我们的 GitHub 仓库中找到项目的最终状态。

下一步

我们鼓励你进一步探索跨平台开发并尝试更多项目:

加入社区: