实战项目二:天气App
好,咱们终于到了第二个实战项目。说实话,天气App是我个人特别喜欢用来教学的一个案例——它麻雀虽小,五脏俱全。网络请求、状态管理、动画、图表绘制、多语言支持,这些你在真实项目中天天要打交道的技术点,它全占了。
我记得几年前我刚接触Jetpack Compose时,第一个练手项目就是天气App。那时候Compose还在beta阶段,很多API都不稳定。但正是这个项目让我彻底爱上了声明式UI——你想想看,以前用XML写一个带动画的天气卡片,得写多少模板代码?现在用Compose,几十行搞定。
项目整体架构
先看看我们要做什么。这个天气App的核心功能很简单:输入城市名,显示当前天气和未来几天的预报。但我们要把它做得漂亮、流畅、国际化。
核心功能清单:
- 城市搜索 + 自动补全
- 当前天气展示(温度、湿度、风速、天气图标)
- 未来7天天气预报(列表 + 折线图)
- 下拉刷新 + 加载动画
- 中英文切换
- 深色模式适配
下面这张图是我画的整体架构。嗯,画得比较糙,但逻辑是清楚的。
网络请求:Retrofit + OkHttp
网络请求这块,我选择Retrofit + OkHttp的组合。为什么?因为稳定。我在好几个生产项目里都用这套组合,没出过什么幺蛾子。
先定义API接口。这里我用的是OpenWeatherMap的免费API,你也可以换成其他的。
// WeatherApi.kt
interface WeatherApi {
@GET("data/2.5/weather")
suspend fun getCurrentWeather(
@Query("q") city: String,
@Query("appid") apiKey: String,
@Query("units") units: String = "metric",
@Query("lang") lang: String = "zh_cn"
): WeatherResponse
@GET("data/2.5/forecast")
suspend fun getForecast(
@Query("q") city: String,
@Query("appid") apiKey: String,
@Query("units") units: String = "metric",
@Query("lang") lang: String = "zh_cn"
): ForecastResponse
}
小提示:我个人习惯把API Key放在local.properties里,通过BuildConfig注入。千万别硬编码在代码里——我曾经见过有人把API Key直接提交到GitHub公开仓库,结果被刷了几十万次请求,账单直接爆炸。
然后创建Retrofit实例。这里我加了一个日志拦截器,方便调试。
// NetworkModule.kt
object NetworkModule {
private const val BASE_URL = "https://api.openweathermap.org/"
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val weatherApi: WeatherApi = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(WeatherApi::class.java)
}
状态管理:Sealed Class + StateFlow
状态管理是Compose的核心。我习惯用Sealed Class定义UI状态,用StateFlow驱动重组。
// WeatherUiState.kt
sealed class WeatherUiState {
object Loading : WeatherUiState()
data class Success(val weather: WeatherResponse) : WeatherUiState()
data class Error(val message: String) : WeatherUiState()
}
ViewModel里这样写:
// WeatherViewModel.kt
class WeatherViewModel : ViewModel() {
private val _uiState = MutableStateFlow<WeatherUiState>(WeatherUiState.Loading)
val uiState: StateFlow<WeatherUiState> = _uiState.asStateFlow()
fun fetchWeather(city: String) {
viewModelScope.launch {
_uiState.value = WeatherUiState.Loading
try {
val response = NetworkModule.weatherApi.getCurrentWeather(city, API_KEY)
_uiState.value = WeatherUiState.Success(response)
} catch (e: Exception) {
_uiState.value = WeatherUiState.Error("加载失败: ${e.localizedMessage}")
}
}
}
}
注意:千万不要在Composable函数里直接调用网络请求!我曾经见过有人这么写,结果每次重组都发一次请求,用户手机直接卡死。网络请求一定要放在ViewModel的viewModelScope里。
动画:让天气卡片活起来
动画是天气App的点睛之笔。我做了三个动画效果:
- 温度数字滚动:用animateIntAsState实现数字变化时的平滑过渡
- 天气图标旋转:用infiniteTransition让太阳图标缓缓旋转
- 卡片入场:用AnimatedVisibility实现卡片从底部滑入
// 温度数字滚动动画
@Composable
fun AnimatedTemperature(temp: Int) {
val animatedTemp by animateIntAsState(
targetValue = temp,
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing)
)
Text(
text = "$animatedTemp°C",
fontSize = 48.sp,
fontWeight = FontWeight.Bold
)
}
图表绘制:Canvas自定义折线图
天气预报的折线图,我用Compose的Canvas API手绘。说实话,刚开始用Canvas时我也觉得麻烦,但画过一次之后就会发现——其实比第三方库更灵活。
// ForecastChart.kt
@Composable
fun ForecastChart(temperatures: List<Int>) {
Canvas(modifier = Modifier.fillMaxWidth().height(200.dp)) {
val stepX = size.width / (temperatures.size - 1)
val maxTemp = temperatures.max()
val minTemp = temperatures.min()
val range = (maxTemp - minTemp).coerceAtLeast(1)
// 绘制折线
val path = Path()
temperatures.forEachIndexed { index, temp ->
val x = index * stepX
val y = size.height - ((temp - minTemp) / range.toFloat()) * size.height * 0.8f - 20f
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color = Color(0xFF4A90D9), style = Stroke(width = 4f))
// 绘制数据点
temperatures.forEachIndexed { index, temp ->
val x = index * stepX
val y = size.height - ((temp - minTemp) / range.toFloat()) * size.height * 0.8f - 20f
drawCircle(Color.White, radius = 8f, center = Offset(x, y))
drawCircle(Color(0xFF4A90D9), radius = 6f, center = Offset(x, y))
}
}
}
多语言支持:Localization
多语言支持其实比很多人想象的要简单。Android原生就支持资源文件国际化,Compose里用起来也很顺手。
步骤很简单:
- 在res/values/strings.xml里写默认中文
- 在res/values-en/strings.xml里写英文翻译
- 在Compose里用stringResource()引用
// strings.xml (中文)
<resources>
<string name="app_name">天气App</string>
<string name="search_hint">输入城市名...</string>
<string name="temperature">温度</string>
<string name="humidity">湿度</string>
<string name="wind_speed">风速</string>
</resources>
// strings.xml (英文 - 放在 values-en 目录下)
<resources>
<string name="app_name">Weather App</string>
<string name="search_hint">Enter city name...</string>
<string name="temperature">Temperature</string>
<string name="humidity">Humidity</string>
<string name="wind_speed">Wind Speed</string>
</resources>
在Compose里使用:
@Composable
fun WeatherLabel() {
Text(text = stringResource(R.string.temperature))
}
避坑指南:我曾经犯过一个错误——在代码里直接拼接字符串,比如"温度:" + temp + "°C"。这种写法在中文下没问题,但切换到英文时,语序就乱了。正确的做法是用字符串模板:stringResource(R.string.temperature_format, temp)。
深色模式适配
深色模式现在几乎是标配了。Compose里用MaterialTheme的colorScheme来控制,非常方便。
// Theme.kt
@Composable
fun WeatherTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) {
darkColorScheme(
primary = Color(0xFF90CAF9),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E)
)
} else {
lightColorScheme(
primary = Color(0xFF1976D2),
background = Color(0xFFF5F5F5),
surface = Color.White
)
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
完整页面示例
最后,把上面所有东西拼起来,就是一个完整的天气页面:
@Composable
fun WeatherScreen(viewModel: WeatherViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
val city = remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// 搜索框
OutlinedTextField(
value = city.value,
onValueChange = { city.value = it },
label = { Text(stringResource(R.string.search_hint)) },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.fetchWeather(city.value) }) {
Text("查询")
}
// 天气内容
when (val state = uiState) {
is WeatherUiState.Loading -> CircularProgressIndicator()
is WeatherUiState.Success -> WeatherCard(state.weather)
is WeatherUiState.Error -> Text(state.message, color = Color.Red)
}
}
}
好了,天气App的核心技术点就这些。说白了,这个项目就是把你之前学过的零散知识点串起来——网络请求、状态管理、动画、Canvas绘图、国际化、主题切换。每个点单独看都不难,但组合在一起,就是一个完整的、有模有样的App。
我个人觉得,这个项目最大的价值在于让你体会到「从零到一」搭建一个真实App的完整流程。你做完之后,可以试着加一些自己的功能,比如空气质量指数、紫外线强度、日出日落时间等等。嗯,发挥空间很大。