断断续续写了挺久,这个用 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 的行为:
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 内部时不一定生效,得放在外面。
3. Popup 和 Dialog 的返回键拦截
Popup 的 onDismissRequest 在某些机型上不能可靠拦截返回键。遇到这个问题后改成了 Dialog + BackHandler,或者 Dialog + decorFitsSystemWindows = false 的方案。
比如颜色输入对话框,最开始用 Popup 包裹,返回键会同时关闭弹窗和上一级页面。换成 Dialog 后就正常了。
4. 手势冲突
生活指数的气泡弹窗遇到了经典问题:Popup(focusable = true) 创建独立 window,底层 grid 的触摸事件传不到 popup。最后的方案是在 grid 和 popup 各放一套手势处理,用 awaitEachGesture 手动区分 tap 和长按拖拽,避免 detectTapGestures 和 detectDragGesturesAfterLongPress 互相抢事件。
5. 堆叠卡片(InfiniteCardStackWidget)
Flutter 版本有一个自定义的堆叠卡片组件,支持左右滑动切换、进退场动画。Compose 没有现成的,得自己用 Layout + pointerInput 实现。
关键点:
- 每张卡片的位置和大小用公式算(参照 Flutter 的 rect 计算),不用
graphicsLayer缩放(会有 transformOrigin 问题) - 拖拽松手后用
Animatable做过渡动画,不能直接snapTo切换索引(会闪) - 向右滑时需要额外渲染一张 "outside" 卡片从左侧滑入
6. 天气渐变背景
每张卡片里的渐变不能直接 fillMaxSize,那样渐变会被压缩到卡片高度。参照 Flutter 的 OverflowBox,用 requiredWidth/requiredHeight 把渐变容器撑到全屏尺寸再居中裁剪:
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 直接用)
graphicsLayer、drawBehind这些底层 API 很灵活- Kotlin 协程 + Flow 处理异步很自然
Flutter 的优势:
- 动画 API 更丰富、更易用(
AnimatedPositioned、AnimatedContainer用着很省心) SmartDialog这类三方库做弹窗管理非常方便- 跨平台
痛点:
- Compose 的手势系统嵌套场景处理比 Flutter 复杂不少,多个
pointerInput之间的事件消费顺序经常搞人 Popup和Dialog各有各的限制,选哪个得看具体场景
最后
代码放在 GitHub 上了,感兴趣的可以看看。东西不复杂,但细节挺多的,很多效果调了不少时间。如果对你有帮助就好。
后面开始用Claude Code帮我参照着flutter项目帮着写了 所以自己几乎没有写代码了 有AI之后真的不想写代码了哈哈
项目地址:ComposeYdWeather