26、实战项目二:天气 App(网络请求 + 状态管理 + Canvas 绘图 + 动画)

说实话,学到这里,你手里应该已经攒了不少 Compose 的零碎技能了。但怎么把它们串起来,做成一个真正能用的 App?

我个人觉得,最好的方式就是动手做一个完整项目。今天这个天气 App,我会把网络请求、状态管理、Canvas 绘图和动画全部揉进去。做完它,你基本就能独立开发一个中等复杂度的 Compose 应用了。

项目概览:我们要做什么?

一个天气 App,核心功能很简单:输入城市名,显示当前天气和未来几天的预报。但我会在细节上做文章——用 Canvas 画一个动态的天气背景,比如晴天有太阳光晕,雨天有雨滴落下。这些动画效果,会让你的 App 看起来比那些「Hello World」demo 高级得多。

核心知识点清单:
  • Retrofit + Kotlin 协程做网络请求
  • ViewModel + StateFlow 管理 UI 状态
  • Canvas 绘制自定义天气图形
  • Animatable 实现平滑动画过渡
天气 App 知识体系 天气 App 网络请求 Retrofit + 协程 状态管理 ViewModel + StateFlow Canvas 绘图 自定义天气图形 动画 Animatable 过渡 四个模块协同工作,构成完整天气 App

第一步:搭建网络请求层

我习惯先把数据层搞定。毕竟没有数据,UI 画得再漂亮也是空壳。

这里用 Retrofit 请求和风天气的 API。你想想看,如果每次都手动解析 JSON,那得多痛苦。Retrofit 帮我们省了这一步。

// 定义 API 接口
interface WeatherApi {
    @GET("v7/weather/now")
    suspend fun getCurrentWeather(
        @Query("location") location: String,
        @Query("key") apiKey: String
    ): WeatherResponse

    @GET("v7/weather/3d")
    suspend fun getForecast(
        @Query("location") location: String,
        @Query("key") apiKey: String
    ): ForecastResponse
}

// 创建 Retrofit 实例
object RetrofitClient {
    private const val BASE_URL = "https://devapi.qweather.com/"
    
    val instance: WeatherApi by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(WeatherApi::class.java)
    }
}
小提示:我在项目中遇到过 API Key 写死在代码里的情况,后来被同事骂了一顿。建议用 BuildConfig 字段或者本地配置文件来管理密钥,别直接硬编码。

第二步:ViewModel 状态管理

网络请求拿到数据后,怎么通知 UI 更新?用 StateFlow。它比 LiveData 更灵活,而且完全支持协程。

我一般会在 ViewModel 里定义三种状态:加载中、成功、失败。这样 UI 层只需要观察一个 StateFlow,就能处理所有情况。

class WeatherViewModel : ViewModel() {
    private val weatherApi = RetrofitClient.instance
    
    // 密封类定义状态
    sealed class WeatherState {
        object Loading : WeatherState()
        data class Success(val data: WeatherResponse) : WeatherState()
        data class Error(val message: String) : WeatherState()
    }
    
    private val _weatherState = MutableStateFlow<WeatherState>(WeatherState.Loading)
    val weatherState: StateFlow<WeatherState> = _weatherState.asStateFlow()
    
    fun fetchWeather(city: String) {
        viewModelScope.launch {
            _weatherState.value = WeatherState.Loading
            try {
                val response = weatherApi.getCurrentWeather(city, API_KEY)
                _weatherState.value = WeatherState.Success(response)
            } catch (e: Exception) {
                _weatherState.value = WeatherState.Error(e.message ?: "未知错误")
            }
        }
    }
}
注意:千万不要在 ViewModel 里持有 Compose 的 Context 引用,否则会导致内存泄漏。用 viewModelScope 管理协程生命周期就够了。

第三步:Canvas 绘制天气背景

这部分是我最喜欢的。用 Canvas 画天气图形,比用图片灵活得多。你可以根据天气数据动态调整颜色、大小、运动轨迹。

举个例子,画一个太阳光晕效果:

@Composable
fun SunAnimation(modifier: Modifier = Modifier) {
    val alpha = remember { Animatable(0.3f) }
    
    LaunchedEffect(Unit) {
        // 无限循环的光晕呼吸效果
        while (true) {
            alpha.animateTo(0.8f, animationSpec = tween(1500))
            alpha.animateTo(0.3f, animationSpec = tween(1500))
        }
    }
    
    Canvas(modifier = modifier.fillMaxSize()) {
        val centerX = size.width / 2
        val centerY = size.height / 2
        val radius = size.minDimension / 4
        
        // 外圈光晕
        drawCircle(
            color = Color.Yellow.copy(alpha = alpha.value),
            radius = radius * 1.5f
        )
        // 内圈太阳
        drawCircle(
            color = Color.Yellow,
            radius = radius
        )
    }
}

你看,用 Animatable 控制透明度,就能做出光晕呼吸的效果。如果是雨天,我会画很多小线段,让它们从顶部下落,配合随机偏移,看起来就像真的雨滴。

避坑指南:我曾经在 Canvas 里直接用了 dp 单位,结果在不同屏幕密度下画出来的东西大小完全不对。记住,Canvas 里所有尺寸都要用像素(px),或者用 size 属性做相对计算。

第四步:动画让界面活起来

天气数据切换时,如果直接跳变,用户体验会很生硬。用 animateXxxAsState 系列函数,可以让数值平滑过渡。

比如温度数字的变化:

@Composable
fun AnimatedTemperature(temp: Int) {
    val animatedTemp by animateIntAsState(
        targetValue = temp,
        animationSpec = tween(durationMillis = 800)
    )
    
    Text(
        text = "$animatedTemp°C",
        fontSize = 48.sp,
        fontWeight = FontWeight.Bold
    )
}

当温度从 25 变成 30 时,数字会从 25 慢慢跳到 30,而不是瞬间变化。这种细节,用户可能说不出来,但能感觉到「这个 App 用着很舒服」。

第五步:组装起来

最后,把上面所有东西拼到 Composable 里:

@Composable
fun WeatherScreen(viewModel: WeatherViewModel = viewModel()) {
    val state by viewModel.weatherState.collectAsState()
    
    Box(modifier = Modifier.fillMaxSize()) {
        // 背景动画层
        SunAnimation(modifier = Modifier.fillMaxSize())
        
        // 内容层
        when (state) {
            is WeatherState.Loading -> CircularProgressIndicator()
            is WeatherState.Success -> {
                val data = (state as WeatherState.Success).data
                Column {
                    AnimatedTemperature(data.temp)
                    Text("天气:${data.weather}")
                    Text("湿度:${data.humidity}%")
                }
            }
            is WeatherState.Error -> {
                Text("出错了:${(state as WeatherState.Error).message}")
            }
        }
    }
}
我的习惯:把背景动画和内容层分开,用 Box 叠加。这样动画不会影响内容的布局,而且可以独立控制动画的启停。比如页面不可见时,可以暂停动画来省电。

总结一下

这个天气 App 虽然不大,但涵盖了实际项目中的核心流程:数据从网络来,经过 ViewModel 管理,最终展示在 Compose UI 上,还加了 Canvas 绘图和动画调味。

你做完之后,可以试着扩展一下:加个城市搜索历史、用 Room 缓存数据、或者画个更复杂的暴风雨动画。这些都是在现有框架上「加料」而已。

嗯,动手试试吧。代码写出来跑通的那一刻,你会觉得前面学的所有东西都值了。


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