从零开始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 内部时不一定生效,得放在外面。

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 把渐变容器撑到全屏尺寸再居中裁剪:

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 直接用)
  • graphicsLayer、drawBehind 这些底层 API 很灵活
  • Kotlin 协程 + Flow 处理异步很自然

Flutter 的优势:

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

痛点:

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

最后

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

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

项目地址:ComposeYdWeather

相关推荐
天空之城--1 天前
Android一周动态:Android 18首次官宣、Compose Material3 1.4转正(5趋势+5资讯)
android·人工智能·flutter·架构·android jetpack
传奇开心果编程2 天前
【现代声明式UI学与练】第1课 从命令式UI到声明式UI
学习·flutter·ui·swiftui·react·android jetpack
传奇开心果编程5 天前
【用案例学Material 3 Expressive】第1课:从一封邮件开始,感受安卓设计新语言的表现力
android·学习·ui·kotlin·android jetpack
传奇开心果编程6 天前
【Jetpack Compose进阶学与练】第14课:系列收尾复习总结;Compose项目常见坑点汇总;学习路线与后续学习方向
android·学习·ui·kotlin·android jetpack
hai_android6 天前
子 View 被压小时,如何向父容器"告状"?——MEASURED_STATE_TOO_SMALL 向上传递机制
android·kotlin·android jetpack
alexhilton7 天前
生命周期感知的多模块启动模式
android·kotlin·android jetpack
hai_android7 天前
RecyclerView 缓存机制详解
android·kotlin·android jetpack
hai_android8 天前
深入理解 Java 的四种引用:强引用、软引用、弱引用、虚引用
android·kotlin·android jetpack
zbmwa8 天前
jetpack compose 副作用 SideEffect
android jetpack
alexhilton14 天前
藏在设备上的秘密,终究藏不住
android·kotlin·android jetpack