基于Jetpack Compose与Ktor的Android天气应用POC开发实践
1. 项目概述:一个纯粹的Compose原生天气应用POC
最近在整理技术栈,想对比一下Kotlin在原生Android和跨平台Flutter上的开发体验差异。于是,我动手写了一个最简单的“天气查询”应用作为概念验证。这个项目,WeatherAppNative,就是一个纯粹的、用Jetpack Compose构建的Android原生应用。它的目标非常明确:验证一个最小可行产品(POC)的快速实现路径,核心就是输入城市名,点击按钮,然后从免费的wttr.inAPI拉取并显示当前的温度、湿度和天气状况。
选择这个方向,是因为在评估新技术或架构时,一个功能单一、边界清晰的小项目往往比一个庞杂的Demo更能暴露真实问题。比如,Compose的声明式UI与状态管理如何协作?网络请求在ViewModel中的生命周期如何处理?这些在复杂应用中容易被其他逻辑掩盖的细节,在这个小应用里会变得非常突出。同时,用Kotlin写原生和Flutter,也能直观地感受语言一致性带来的便利以及框架层面的差异。这个项目没有花哨的动画,没有复杂的本地缓存,就是一个“获取-显示”的直线流程,但正是这种简单,让它成为了一个绝佳的学习和对比样本。
2. 项目架构与核心设计思路
2.1 技术选型背后的考量
这个POC的技术栈非常精简:Kotlin + Jetpack Compose + Ktor + ViewModel。每一环的选择都有其明确的意图。
首先,Kotlin是毋庸置疑的基石。其空安全、扩展函数、协程等特性,能让我们用更简洁、健壮的代码实现业务逻辑。对于这个项目,协程尤为重要,它让我们能以近乎同步的方式编写异步的网络请求代码,避免回调地狱,极大地提升了代码的可读性。
Jetpack Compose作为现代的声明式UI框架,是本项目的核心。我选择它而非传统的View系统,是为了验证在快速原型开发中,Compose的生产力优势。Compose允许我们通过描述UI在不同状态下的样子来构建界面,当状态(例如,从API获取到的天气数据)改变时,相关的UI部分会自动重组更新。这种心智模型与数据驱动的开发模式非常契合,对于这个状态简单的应用来说,可以极大地简化UI层代码。
对于网络层,我没有选择更常见的Retrofit,而是用了Ktor Client。原因有二:一是Ktor由JetBrains出品,与Kotlin的亲和度极高,其DSL风格的API用起来非常流畅;二是它足够轻量,对于这样一个仅有一个GET请求的应用来说,引入Retrofit及其注解处理器显得有些“杀鸡用牛刀”。Ktor的简洁性正好匹配POC的轻量需求。
最后,ViewModel作为UI状态的管理者,是连接数据(网络请求)和UI(Compose)的桥梁。它遵循生命周期感知,确保屏幕旋转等配置变更时,我们的天气数据不会丢失。这里采用的是一个简单的、基于状态的MVVM模式,数据流清晰:UI事件触发ViewModel函数,函数内执行网络请求并更新状态,状态变化驱动UI刷新。
2.2 状态管理与数据流设计
应用的状态非常简单,我将其封装在一个UiState密封类中。这是Compose应用中管理状态的常见且推荐的做法。
sealed interface WeatherUiState { object Initial : WeatherUiState // 初始状态,显示输入框 object Loading : WeatherUiState // 加载中,显示进度条 data class Success(val weatherData: WeatherData) : WeatherUiState // 成功,显示天气数据 data class Error(val message: String) : WeatherUiState // 错误,显示错误信息 }WeatherData是一个简单的数据类,包含我们从API响应中解析出的字段:城市名、温度、湿度和天气状况描述。
在ViewModel中,我暴露了一个UiState状态和一个fetchWeather函数。当用户在UI中点击按钮时,会调用fetchWeather(cityName)。这个函数内部会启动一个协程,先将状态置为Loading,然后尝试调用Ktor客户端请求API。成功则解析数据并置为Success,失败则捕获异常并置为Error。
整个数据流是单向的:UI -> ViewModel (事件) -> Repository/DataSource (网络请求) -> ViewModel (更新状态) -> UI (重组)。这种模式使得逻辑非常清晰,易于测试和调试。在Compose的@Composable函数中,我们通过viewModel().uiState.collectAsState()来观察这个状态,并根据不同的状态分支渲染不同的UI内容。
注意:在真实的、稍复杂的应用中,我们可能会引入
StateFlow或SharedFlow来更精细地管理状态流,并使用cachedIn等操作符优化状态共享。但对于这个POC,简单的MutableState配合密封类状态已经完全够用,避免了不必要的复杂性。
3. 核心实现细节与实操要点
3.1 使用Ktor Client进行网络请求
Ktor客户端的配置和调用是本项目的关键。首先,需要在build.gradle.kts中添加依赖。
// app/build.gradle.kts dependencies { implementation("io.ktor:ktor-client-android:2.3.7") implementation("io.ktor:ktor-client-content-negotiation:2.3.7") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7") }接下来,创建一个单例的HttpClient实例。我通常将其放在一个独立的Kotlin对象中,以便全局复用。
import io.ktor.client.* import io.ktor.client.engine.android.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json object ApiClient { val client = HttpClient(Android) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true // 忽略JSON中我们不关心的字段 isLenient = true }) } // 可以在这里配置超时、日志等 engine { connectTimeout = 10_000 socketTimeout = 10_000 } } }这里有几个要点:
- 引擎选择:在Android上,我们使用
Android引擎。它基于OkHttp,性能良好。 - 内容协商:安装了
ContentNegotiation插件并配置了Kotlinx Serialization的JSON支持。这意味着当服务器返回Content-Type: application/json的响应时,Ktor能自动将其反序列化成我们指定的Kotlin数据类。 - JSON配置:
ignoreUnknownKeys = true至关重要。wttr.in返回的JSON结构非常庞大,包含大量我们不需要的字段(如天文数据、每小时预报等)。我们只定义我们关心的那几个字段(如current_condition下的temp_C、humidity、weatherDesc),解析器会自动忽略其他键,避免解析失败。 - 超时设置:为
connectTimeout和socketTimeout设置合理值(这里都是10秒)是一个好习惯,可以防止网络不佳时应用长时间无响应。
定义与API响应对应的数据类需要一些技巧。因为wttr.in的JSON嵌套很深,我们不需要定义完整的结构。
import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class WttrInResponse( @SerialName("current_condition") val currentCondition: List<CurrentCondition>? ) @Serializable data class CurrentCondition( @SerialName("temp_C") val tempC: String?, @SerialName("humidity") val humidity: String?, @SerialName("weatherDesc") val weatherDesc: List<WeatherDesc>? ) @Serializable data class WeatherDesc( val value: String? ) // 最终我们应用层使用的简洁数据模型 data class WeatherData( val city: String, val temperature: String, val humidity: String, val condition: String )在Repository或ViewModel中发起请求的代码如下:
suspend fun fetchWeatherData(cityName: String): Result<WeatherData> { return try { val response: WttrInResponse = ApiClient.client.get( "https://wttr.in/${URLEncoder.encode(cityName, "UTF-8")}?format=j1" ) // 解析并转换为我们应用的WeatherData val current = response.currentCondition?.firstOrNull() val weatherData = WeatherData( city = cityName, temperature = current?.tempC ?: "N/A", humidity = current?.humidity ?: "N/A", condition = current?.weatherDesc?.firstOrNull()?.value ?: "N/A" ) Result.success(weatherData) } catch (e: Exception) { Result.failure(e) } }实操心得:对城市名进行
URLEncoder.encode编码是必须的,否则城市名包含空格(如“New York”)或特殊字符时,URL会构造失败。这是初学者很容易忽略的一个坑。
3.2 Compose UI的构建与状态驱动
UI层完全由Compose构建,遵循Material Design 3指南。核心的界面是一个垂直排列的Column,包含一个TextField、一个Button和一个根据状态显示不同内容区域。
@Composable fun WeatherScreen( viewModel: WeatherViewModel = viewModel() ) { val uiState by viewModel.uiState.collectAsState() var cityInput by remember { mutableStateOf("") } Column( modifier = Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { // 城市输入框 OutlinedTextField( value = cityInput, onValueChange = { cityInput = it }, label = { Text("Enter City Name") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) // 查询按钮 Button( onClick = { if (cityInput.isNotBlank()) { viewModel.fetchWeather(cityInput) } }, enabled = cityInput.isNotBlank() && uiState !is WeatherUiState.Loading ) { Text("Get Weather") } // 根据状态显示内容 when (val state = uiState) { is WeatherUiState.Initial -> { Text( "Enter a city name above.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } is WeatherUiState.Loading -> { CircularProgressIndicator() } is WeatherUiState.Success -> { WeatherInfoCard(weatherData = state.weatherData) } is WeatherUiState.Error -> { Text( "Error: ${state.message}", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge ) } } } }WeatherInfoCard是一个展示天气信息的卡片Composable,使用Card组件并内部布局温度、湿度等信息。
这里的关键点是:
- 状态提升:文本输入框的状态
cityInput是UI的本地状态,使用remember { mutableStateOf() }管理。而天气数据uiState是业务逻辑状态,由ViewModel管理并提升到可组合函数之外。这符合Compose的状态提升原则,使逻辑更清晰。 - 状态收集:使用
collectAsState()将ViewModel中的State转换为Compose可观察的状态。当ViewModel中的状态变化时,这个WeatherScreen函数中读取到uiState的部分会自动重组。 - 条件渲染:
when表达式是处理多种UI状态的完美工具。它让UI逻辑变得非常直观:什么状态,显示什么组件。
3.3 ViewModel的协程管理与生命周期
ViewModel是连接UI和数据的枢纽,必须妥善管理协程的生命周期,防止内存泄漏。
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class WeatherViewModel : ViewModel() { private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Initial) val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow() fun fetchWeather(cityName: String) { // 如果正在加载,则忽略新的请求(简单的防抖) if (_uiState.value is WeatherUiState.Loading) return viewModelScope.launch { _uiState.value = WeatherUiState.Loading val result = weatherRepository.fetchWeatherData(cityName) _uiState.value = when { result.isSuccess -> { WeatherUiState.Success(result.getOrNull() ?: return@launch) } else -> { WeatherUiState.Error(result.exceptionOrNull()?.message ?: "Unknown error") } } } } }关键设计解析:
- 使用
viewModelScope:所有在ViewModel中启动的协程都应使用viewModelScope.launch。这个作用域与ViewModel的生命周期绑定,当ViewModel被清除(例如,Activity/Fragment被销毁)时,它会自动取消所有在此作用域内启动的协程,这是避免内存泄漏的核心机制。 - 使用
StateFlow作为状态容器:StateFlow是一个热流,它持有当前状态的最新值,并且只向收集器发射最新的值。它非常适合用来表示UI状态。通过将内部的MutableStateFlow暴露为只读的StateFlow,我们保护了状态,使其只能通过ViewModel内部的方法(如fetchWeather)来修改。 - 简单的状态防抖:在
fetchWeather函数开始处,检查当前状态是否为Loading,如果是则直接返回。这是一个非常基础的防抖(debounce)或防重复提交(prevent duplicate submission)策略,防止用户快速连续点击按钮导致发送多个重复请求。在更复杂的场景下,可能需要更完善的防抖或节流机制。
4. 项目配置、构建与运行全流程
4.1 开发环境搭建与项目初始化
要复现这个项目,你需要一个标准的Android开发环境。
- 安装Android Studio:建议使用Hedgehog (2023.1.1) 或更高版本。这些版本对Compose工具链的支持更成熟。从官网下载并安装。
- 配置SDK:首次启动Android Studio时,它会引导你安装Android SDK。确保安装的SDK平台版本至少为API 24 (Android 7.0),这是我们
build.gradle中设置的minSdk。同时安装对应版本的“Sources for Android SDK”,便于调试。 - 安装JDK:Android Studio通常捆绑了JDK。但请确认使用的是JDK 11或更高版本。你可以在Android Studio的
File -> Project Structure -> SDK Location中查看和修改JDK路径。
环境就绪后,创建新项目:
- 选择“Empty Activity”模板。
- 将“Language”设置为“Kotlin”。
- 将“Minimum SDK”设置为“API 24”。
- 确保“Use legacy android.support libraries”未被勾选。
项目创建后,需要修改app/build.gradle.kts文件来配置Compose和依赖。关键的配置部分如下:
android { buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.7" // 版本需与Kotlin版本匹配 } } dependencies { val composeBom = platform("androidx.compose:compose-bom:2023.10.01") implementation(composeBom) androidTestImplementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") debugImplementation("androidx.compose.ui:ui-tooling") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.activity:activity-compose:1.8.0") // Ktor implementation("io.ktor:ktor-client-android:2.3.7") implementation("io.ktor:ktor-client-content-negotiation:2.3.7") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7") // Kotlinx Serialization implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") }注意:
kotlinCompilerExtensionVersion的版本必须与你的Kotlin版本兼容。你可以查阅Android开发者官网的Compose-Kotlin兼容性对照表。使用compose-bom(物料清单)可以自动管理所有Compose库的版本,确保它们彼此兼容,这是Google推荐的做法。
4.2 网络权限与ProGuard配置
由于应用需要访问网络,必须在AndroidManifest.xml文件中声明互联网权限。
<!-- app/src/main/AndroidManifest.xml --> <manifest ...> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application ...> ... </application> </manifest>添加ACCESS_NETWORK_STATE权限是一个好习惯,这样你可以在代码中检查网络是否可用,并在无网络时给用户友好的提示,而不是直接抛出网络异常。
如果你的应用需要发布,并且启用了代码混淆(ProGuard或R8),你需要在proguard-rules.pro文件中为Ktor和序列化库添加保留规则,防止其必要的类和方法被混淆导致运行时崩溃。
# app/proguard-rules.pro -keep class io.ktor.** { *; } -keep class kotlinx.serialization.** { *; } -dontwarn io.ktor.**4.3 构建、运行与调试
- 同步项目:在修改完
build.gradle文件后,Android Studio右上角会出现一个“Sync Now”的提示,点击它。或者点击工具栏的大象图标。Gradle会下载所有依赖库。 - 连接设备:你可以使用Android Studio自带的AVD Manager创建一个模拟器(建议选择Pixel系列,API级别在24以上),或者通过USB连接一台开启了开发者选项和USB调试的实体手机。
- 运行应用:点击工具栏的绿色运行按钮,选择你的目标设备,应用就会被编译、安装并启动。
- 调试与日志:你可以在代码中设置断点,使用
Log.d(“TAG”, “message”)打印日志,并在Android Studio的“Logcat”窗口查看。这对于调试网络请求和状态流转非常有用。记得为网络请求和状态变更添加清晰的日志标签。
5. 常见问题、调试技巧与优化方向
5.1 开发过程中遇到的典型问题与解决方案
在实际编写和运行这个POC时,我遇到了几个有代表性的问题,以下是排查和解决的方法。
问题一:应用崩溃,Logcat报错android.os.NetworkOnMainThreadException
- 现象:点击按钮后应用立即闪退。
- 原因:这是Android开发中最经典的错误之一。意味着你在主线程(UI线程)上执行了网络操作。在Android中,所有可能耗时的操作(如网络I/O、文件读写、复杂计算)都必须在后台线程执行。
- 解决方案:确保所有的网络请求都包装在协程中,并且是在
Dispatchers.IO或Dispatchers.Default等后台调度器上启动。在我们的代码中,使用viewModelScope.launch默认会在主线程启动协程,但Ktor客户端的suspend函数内部已经正确处理了线程切换。关键检查点:确认fetchWeatherData函数和Ktor的get请求都被标记为suspend,并且是在协程作用域内调用。
问题二:输入中文城市名(如“北京”)查询失败
- 现象:输入英文城市名正常,输入“北京”后显示网络错误或返回奇怪的数据。
- 原因:URL构造不正确。中文字符“北京”需要被编码成
%E5%8C%97%E4%BA%AC才能放入URL中。如果没有编码,形成的URLhttps://wttr.in/北京?format=j1是无效的。 - 解决方案:在拼接URL前,使用
URLEncoder.encode(cityName, "UTF-8")对城市名进行编码。注意:URLEncoder.encode方法会将空格编码为+,而URL中的空格通常应编码为%20。对于wttr.inAPI,两种方式似乎都能工作,但使用%20是更通用的做法。你可以使用URLEncoder.encode(cityName, "UTF-8").replace("+", "%20")来确保。
问题三:JSON解析失败,抛出SerializationException
- 现象:请求成功(HTTP 200),但在解析响应体时崩溃。
- 原因:最常见的原因是数据类结构与实际JSON不匹配。可能是字段名不对,类型不对,或者JSON中有我们数据类未定义的字段,且没有设置
ignoreUnknownKeys = true。 - 排查步骤:
- 在
ApiClient配置中添加日志拦截器,打印出原始的JSON响应。import io.ktor.client.plugins.logging.* // 在HttpClient配置中添加 install(Logging) { level = LogLevel.ALL } - 将打印出的JSON复制到在线JSON格式化工具(如 jsonformatter.org)中,仔细查看其结构。
- 核对数据类中的
@SerialName注解是否与JSON中的键名完全一致(注意大小写和下划线)。 - 确保在创建
Json实例时设置了ignoreUnknownKeys = true。
- 在
问题四:UI不更新,一直显示Loading或初始状态
- 现象:点击按钮后,按钮变灰(Loading状态),但成功获取数据后UI没有刷新为成功状态。
- 原因:这通常是状态流(
StateFlow)没有正确触发UI重组。可能的原因有:- 状态对象没有正确变化:Kotlin数据类的
equals方法比较的是所有属性。如果你在Success状态中返回了一个看似相同的新WeatherData实例,但它的属性值实际没变,StateFlow的distinctUntilChanged机制可能会认为状态未改变,从而不发射新值。确保在成功时创建了一个全新的WeatherData实例。 - 在错误的协程上下文中更新StateFlow:
StateFlow的.value赋值必须在协程作用域内,或者使用update函数。确保你在viewModelScope.launch的协程体内更新_uiState.value。
- 状态对象没有正确变化:Kotlin数据类的
- 调试技巧:在ViewModel中更新
_uiState的地方添加日志,观察状态变化的顺序是否符合预期(Initial -> Loading -> Success/Error)。同时,在Composable函数中添加Log.d,观察其重组次数。
5.2 性能与体验优化建议
虽然这是一个POC,但引入一些简单的优化能极大提升体验,也更贴近真实项目。
- 添加简单的本地缓存:频繁查询同一城市天气会浪费流量和API资源。可以在ViewModel或Repository层引入一个内存缓存(如一个
Map<String, WeatherData>),在发起请求前先检查缓存。可以为缓存设置一个简单的过期时间(例如5分钟)。 - 实现输入防抖:将
TextField的onValueChange与一个防抖函数结合,在用户停止输入一段时间(如500毫秒)后再自动触发搜索,而不是仅在按钮点击时。这能提供更流畅的搜索体验。 - 处理网络异常与重试:网络请求可能因各种原因失败。可以在Repository的请求逻辑中加入重试机制(使用
retry操作符),并对不同的异常(如ConnectException,SocketTimeoutException,HttpRequestTimeoutException)提供更友好的错误提示信息,而不是简单的“Unknown error”。 - 改善UI状态:在
Loading状态时,除了显示进度条,还可以禁用输入框和按钮,防止用户进行额外操作。在Error状态,可以提供一个“重试”按钮。 - 添加单元测试:为ViewModel和Repository编写单元测试。使用
TestDispatcher来测试协程逻辑,使用MockK或Mockito来模拟Ktor客户端,验证在不同场景(成功、失败、网络异常)下状态是否正确更新。
5.3 从POC到可发布应用的扩展思考
这个项目是一个起点。如果要将其发展为一个可发布的天气应用,需要考虑以下方面:
- 更换更稳定的天气API:
wttr.in是一个优秀的免费服务,但可能不适合生产环境(有速率限制,无服务保障)。可以考虑使用OpenWeatherMap、WeatherAPI等提供免费层级的商业API,它们通常有更规范的文档、更丰富的数据和更高的可靠性。 - 引入依赖注入:使用Hilt等依赖注入框架来管理
ApiClient、Repository、ViewModel的创建,提高代码的可测试性和可维护性。 - 实现数据持久化:使用Room数据库将查询过的天气数据缓存到本地,这样在无网络时也能展示最近的数据,并实现离线访问。
- 增加更多功能:如基于地理位置的自动定位、多城市管理、未来几天天气预报、天气图标、主题切换(深色/浅色模式)等。
- 完善UI/UX:根据天气状况动态改变应用主题色,添加更生动的动画和过渡效果,让界面更具吸引力。
通过这个小小的WeatherAppNative项目,我不仅验证了Compose开发原生Android应用的流畅性,也实践了从网络请求到UI展示的完整数据流管理。它像一块干净的画布,你可以在此基础上尝试任何你感兴趣的技术点,是学习现代Android开发技术栈一个非常理想的起点。
