实战项目二:天气App

好,咱们终于到了第二个实战项目。说实话,天气App是我个人特别喜欢用来教学的一个案例——它麻雀虽小,五脏俱全。网络请求、状态管理、动画、图表绘制、多语言支持,这些你在真实项目中天天要打交道的技术点,它全占了。

我记得几年前我刚接触Jetpack Compose时,第一个练手项目就是天气App。那时候Compose还在beta阶段,很多API都不稳定。但正是这个项目让我彻底爱上了声明式UI——你想想看,以前用XML写一个带动画的天气卡片,得写多少模板代码?现在用Compose,几十行搞定。

项目整体架构

先看看我们要做什么。这个天气App的核心功能很简单:输入城市名,显示当前天气和未来几天的预报。但我们要把它做得漂亮、流畅、国际化。

核心功能清单:

  • 城市搜索 + 自动补全
  • 当前天气展示(温度、湿度、风速、天气图标)
  • 未来7天天气预报(列表 + 折线图)
  • 下拉刷新 + 加载动画
  • 中英文切换
  • 深色模式适配

下面这张图是我画的整体架构。嗯,画得比较糙,但逻辑是清楚的。

天气App 架构图 UI层 (Compose) SearchScreen | WeatherScreen | ForecastScreen | SettingsScreen State: WeatherUiState, ForecastUiState, SettingsState ViewModel层 WeatherViewModel | ForecastViewModel | SettingsViewModel Repository层 WeatherRepository | ForecastRepository | SettingsRepository 数据源: Retrofit + OkHttp (网络) | DataStore (本地设置)

网络请求: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里用起来也很顺手。

步骤很简单:

  1. 在res/values/strings.xml里写默认中文
  2. 在res/values-en/strings.xml里写英文翻译
  3. 在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的完整流程。你做完之后,可以试着加一些自己的功能,比如空气质量指数、紫外线强度、日出日落时间等等。嗯,发挥空间很大。


公众号:蓝海资料掘金营,微信deep3321