从零开始Compose天气预报(完结)

断断续续写了挺久,这个用 Jetpack Compose 写的天气 App 终于算是做完了。之前一直在用 Flutter 写,这次想试试纯 Compose 能做到什么程度,顺便也是对照着 Flutter 版本一个个功能搬过来。

做完回头看,Compose 写 UI 确实舒服,但有些地方踩坑不少。这篇简单聊聊整个项目做了什么、过程中遇到的问题和一些心得。

做了什么

先放几个效果图:

主要功能点:

  • 实时天气、逐小时预报、15日预报曲线图/列表
  • 空气质量详情(点击面板展开到中心,带手势返回)
  • 生活指数气泡弹窗(长按拖拽切换 item)
  • 15日天气堆叠卡片弹窗(参照 Flutter 的 InfiniteCardStackWidget)
  • 城市搜索、多城市管理(最多20个城市,快照卡片预览)
  • 天气背景自定义编辑(HSV 调色 + HEX 键盘输入)
  • iOS 风格弹性滚动回弹
  • 实时模糊(Backdrop blur)
  • 预测性返回手势
  • 下拉刷新
  • 气象预警

技术栈

分类 选型
UI Jetpack Compose + Material3
架构 MVVM,单模块
DI Hilt + KSP
网络 Retrofit + OkHttp + Kotlinx Serialization
数据库 Room
KV存储 MMKV
导航 Navigation Compose(类型安全路由)
图片 Coil 3

API 用的和风天气,免费版够用了。

踩坑记录

1. iOS 弹性滚动

这个是花时间最多的地方之一。Android 原生是 OverScroll 辉光效果,想做成 iOS 那种橡皮筋回弹需要自己写 NestedScrollConnection

核心思路:

  • onPostScroll 里,列表滚到边界后剩余的滚动量用阻尼公式处理,越远越难拉
  • 松手后用 spring 动画弹回
  • fling 到边界的处理最头疼------速度大的时候容易冲过头

最后的阻尼公式参照了 iOS UIScrollView 的行为:

kotlin 复制代码
private fun rubberbandDelta(delta: Float): Float {
    val absOffset = abs(_offset.value)
    val progress = (absOffset / maxDrag).coerceIn(0f, 0.95f)
    return delta * resistance * (1f - progress)
}

resistance 设 0.55(iOS 默认值),回弹用临界阻尼弹簧(无振荡),手感基本对了。

2. 预测性返回手势

Android 14 开始支持 Predictive Back,Compose 里用 PredictiveBackHandler 就能拿到手势进度。比较适合做弹窗退场动画------手势跟手,松手后要么完成关闭要么弹回。

有个坑:PredictiveBackHandler 放在 Popup 内部时不一定生效,得放在外面。

PopuponDismissRequest 在某些机型上不能可靠拦截返回键。遇到这个问题后改成了 Dialog + BackHandler,或者 Dialog + decorFitsSystemWindows = false 的方案。

比如颜色输入对话框,最开始用 Popup 包裹,返回键会同时关闭弹窗和上一级页面。换成 Dialog 后就正常了。

4. 手势冲突

生活指数的气泡弹窗遇到了经典问题:Popup(focusable = true) 创建独立 window,底层 grid 的触摸事件传不到 popup。最后的方案是在 grid 和 popup 各放一套手势处理,用 awaitEachGesture 手动区分 tap 和长按拖拽,避免 detectTapGesturesdetectDragGesturesAfterLongPress 互相抢事件。

5. 堆叠卡片(InfiniteCardStackWidget)

Flutter 版本有一个自定义的堆叠卡片组件,支持左右滑动切换、进退场动画。Compose 没有现成的,得自己用 Layout + pointerInput 实现。

关键点:

  • 每张卡片的位置和大小用公式算(参照 Flutter 的 rect 计算),不用 graphicsLayer 缩放(会有 transformOrigin 问题)
  • 拖拽松手后用 Animatable 做过渡动画,不能直接 snapTo 切换索引(会闪)
  • 向右滑时需要额外渲染一张 "outside" 卡片从左侧滑入

6. 天气渐变背景

每张卡片里的渐变不能直接 fillMaxSize,那样渐变会被压缩到卡片高度。参照 Flutter 的 OverflowBox,用 requiredWidth/requiredHeight 把渐变容器撑到全屏尺寸再居中裁剪:

kotlin 复制代码
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
    Box(
        modifier = Modifier
            .requiredWidth(screenWidthDp)
            .requiredHeight(screenHeightDp)
            .background(Brush.verticalGradient(colors))
    )
}

和 Flutter 版本的对比

同一个 App 两套代码写下来,体会比较深的几点:

Compose 的优势:

  • 和 Android 生态无缝集成(Hilt、Room、ViewModel 直接用)
  • graphicsLayerdrawBehind 这些底层 API 很灵活
  • Kotlin 协程 + Flow 处理异步很自然

Flutter 的优势:

  • 动画 API 更丰富、更易用(AnimatedPositionedAnimatedContainer 用着很省心)
  • SmartDialog 这类三方库做弹窗管理非常方便
  • 跨平台

痛点:

  • Compose 的手势系统嵌套场景处理比 Flutter 复杂不少,多个 pointerInput 之间的事件消费顺序经常搞人
  • PopupDialog 各有各的限制,选哪个得看具体场景

最后

代码放在 GitHub 上了,感兴趣的可以看看。东西不复杂,但细节挺多的,很多效果调了不少时间。如果对你有帮助就好。

后面开始用Claude Code帮我参照着flutter项目帮着写了 所以自己几乎没有写代码了 有AI之后真的不想写代码了哈哈

项目地址:ComposeYdWeather

相关推荐
阿巴斯甜9 小时前
produceState的使用:
android jetpack
阿巴斯甜9 小时前
snapshotFlow的使用
android jetpack
菜鸟国国10 小时前
从0开始学Jetpack Compose|第二篇:基础组件+核心布局,从零搭建实用UI
android jetpack
simplepeng10 小时前
mutableStateOf(list) vs mutableStateListOf():该如何选择?
android jetpack
zh_xuan1 天前
Android Jetpack DataStore存储数据
android·android jetpack·datastore
simplepeng1 天前
MVI with Jetpack Compose:让你的应用更简洁和整洁
android jetpack
simplepeng1 天前
别再让团队困惑:少有人提及的 MVI 命名规范
android jetpack
zh_xuan1 天前
Android Jetpack 使用Room数据库
android·android jetpack·room
alexhilton4 天前
Jetpack Compose中的富文本输入
android·kotlin·android jetpack