做Android天气预报App,听起来是个入门级练手项目,但真要把它做到"能稳定运行、界面不糊、耗电可控、数据不崩",牵扯到的知识点远比想象中多。网络请求、JSON解析、定位权限、数据库缓存、后台任务调度、多彩布局适配,几乎把Android日常开发的核心技术栈全串起来了。这篇文章我想把做这个APP的完整思路和实操细节写出来,从选型到最后上架前要检查的东西,一次说透。适合刚学完基础、准备做第一个完整项目的开发者,也适合想用一个小项目验证自己工程化能力的同学参考。
1. 整体架构设计与技术选型
很多初学者拿到"天气预报"这个需求,第一反应就是"找个天气API,返回什么就显示什么",但这样写出来的代码通常只能跑两天,第三天换个需求就崩了。我建议先花半天时间把架构想清楚,后面所有功能都是往这个骨架里填肉。
1.1 功能需求先拆解
天气预报APP的核心功能,按优先级排列:
- 实时天气展示:温度、天气现象、体感温度、风力风向、湿度、气压、能见度
- 多日天气预报:未来7天的最高温、最低温、白昼天气、夜间天气
- 城市管理与切换:支持搜索城市、定位当前城市、保存常用城市
- 下拉刷新与定时刷新:手动刷新、后台自动更新数据
- 离线兜底:没网的时候展示上次缓存的数据
每一项拆开都不难,但合在一起就考验架构能力。我见过最失败的写法是把所有逻辑塞进MainActivity里,Activity堆了两千行,后来加一个城市管理功能,改一个字段要牵连三个地方。所以第一步要是把数据层、界面层、业务层分开。
1.2 技术选型的核心思路
技术栈的选择,我不建议全都用最新的,但也不建议全用最原始的原生API。选型的逻辑很简单:团队维护成本低、社区案例多、出问题能快速搜到答案。
| 模块 | 技术方案 | 选型理由 |
|---|---|---|
| 网络请求 | Retrofit + OkHttp | 生态成熟,拦截器、超时配置、协程支持都齐 |
| JSON解析 | Gson / kotlinx.serialization | 网络和模型对接简便,debug直观 |
| 异步处理 | Kotlin协程 | 避免回调地狱,代码可读性高 |
| 本地存储 | Room数据库 | 类型安全,SQLite的官方封装,支持Flow观察数据变化 |
| 界面 | XML + Material Components | 兼容性强,自定义控件方便,对低版本机型友好 |
| 图片图标 | 本地矢量图 + 自绘天气图标 | 不依赖服务端图片URL,省流量、加载快 |
有人会问,现在Google都推Jetpack Compose了,为什么不用Compose?我个人的看法是:如果你做的是全新项目且团队有足够时间踩坑,Compose完全可用;但天气预报这种需要大量列表、自适应布局、深浅色切换的场景,XML方案依然是当前多数存量项目的主流。而且用XML写,后续接外包、接手老项目也更容易,学习成本对新手更友好。
1.3 整体数据流设计
整个APP的数据流我习惯画成一条单向链路:
界面层(Activity/Fragment) → ViewModel → Repository → 数据源(网络API / 本地数据库)
界面层只做两件事:展示状态、转发用户操作。ViewModel负责持有UI状态、调用Repository。Repository对上层暴露一个统一的入口,内部自己判断"是走网络还是走缓存",上层完全不关心数据从哪来。
这样设计的最大好处是,哪天你想把天气API服务商换掉,只需要改Repository里对应的方法实现,界面层一行都不用动。我后来接手过一个历史项目,就是没做这层隔离,API字段一变,全部页面集体改签名,那个酸爽不想体验第二次。
2. 数据接入与解析:最容易被坑的一环
天气APP的核心是数据,但数据恰恰是最容易出问题的环节。第三方天气API返回的JSON结构五花八门,字段单位不统一,有的给华氏度,有的给摄氏度,有的风速给km/h,有的给m/s。这里必须把所有细节吃透。
2.1 天气数据源的接入方式
我用的是市面上可以直接访问的第三方天气数据服务商,申请一个API Key就能调用。接入时主要用三个接口:实时天气、未来7天预报、城市搜索/地理位置反查。
以实时天气接口为例,请求参数通常是:
@GET("v3/weather/now.json") suspend fun fetchWeatherNow( @Query("location") location: String, // 经纬度或城市ID @Query("key") apiKey: String ): WeatherNowResponselocation参数支持经纬度也支持城市ID。定位成功后把经纬度传进去,返回的就是对应位置的实时天气。这里有个细节:直接传经纬度的精度会比传城市ID高,尤其在城市边缘地带,同一个城市中心到郊区温度可能差好几度。
2.2 数据模型的层级设计
天气API返回的JSON是嵌套结构,数据模型必须严格对应。拿实时天气举例,核心结构大概是:
data class WeatherNowResponse( val code: String, // 状态码 val updateTime: String, // 数据更新时间 val now: NowWeather // 实时天气核心数据 ) data class NowWeather( val temp: String, // 温度,单位:℃ val feelsLike: String, // 体感温度 val icon: String, // 天气图标编码 val text: String, // 天气现象描述:晴、多云、小雨等 val windDir: String, // 风向 val windScale: String, // 风力等级 val humidity: String, // 相对湿度,百分比数值 val pressure: String, // 气压,单位:hPa val vis: String, // 能见度,单位:km val cloud: String // 云量,百分比数值 )每个字段我都建议用String而不是Int或Float来接收,因为某些天气服务商在字段缺失时返回的是空字符串,用数值类型接收直接抛JSON解析异常,用String接收至少不会崩溃,后续再做安全转换。
2.3 解析细节与单位换算
解析JSON看似简单,但坑藏在单位换算和编码映射里。这里我踩过几次,值得单独写一段。
温度和体感温度:默认是摄氏度,但部分接口会提供华氏度参数。如果你面向的是全球用户,务必在设置里做单位切换。换算公式很简单:℃ = (℉ - 32) × 5 / 9。我建议在数据层统一转成摄氏度存储,界面展示时再按设置项转换。
风力等级换算:有些服务商给的是风速数值,比如"12.0",单位是km/h或m/s,需要自行换算成风力等级。实际用到的换算逻辑:
fun windSpeedToLevel(speedKmh: Double): Int { return when { speedKmh < 1 -> 0 speedKmh < 6 -> 1 speedKmh < 12 -> 2 speedKmh < 20 -> 3 speedKmh < 29 -> 4 speedKmh < 39 -> 5 speedKmh < 50 -> 6 speedKmh < 62 -> 7 speedKmh < 75 -> 8 speedKmh < 89 -> 9 speedKmh < 103 -> 10 else -> 11 } }天气图标编码:天气服务商通常会给一串数字或字符串编码,比如"100"代表晴,"101"代表多云,"300"代表小雨。这些编码不同服务商各不相同。我强烈建议拿到编码后在本地映射到自绘矢量图,而不是直接用服务商返回的图片URL。原因有两个:一是图片URL有时效性,二是有可能被刷流量,而且本地矢量图的加载速度远快于网络图片。
2.4 异常重试与缓存兜底
网络请求一定要做超时和重试。我用OkHttp拦截器统一设置:
val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .build()但要注意,retryOnConnectionFailure只处理连接失败,不处理HTTP 500之类的异常。所以Repository层还要再包一层重试逻辑:
suspend fun <T> retryIO(block: suspend () -> T, maxRetry: Int = 3): T { var retryCount = 0 while (true) { try { return block() } catch (e: Exception) { retryCount++ if (retryCount > maxRetry) throw e delay(1000L * retryCount) // 指数退避 } } }提示:重试不是越多越好。天气数据实时性高,超过3次重试意义不大。另外,用户手动下拉刷新时也不要无限重试,要友好提示"刷新失败,当前展示的是X分钟前的数据"。
3. 界面开发:从"能用"到"好看"
天气预报APP的界面设计有一个天然优势:数据维度多,布局可以做得层次分明。但层次分明不等于信息堆砌,如何把实时温度、体感、风、湿度、未来几天预报合理地组织在屏幕上,需要认真设计。
3.1 主界面的信息层级划分
我推荐的主界面布局分成三个信息层级:
第一层级:当前温度和天气现象,作为视觉焦点,字号最大,通常用64sp以上的超大字重。这一层要让人一眼看到"今天多少度"。
第二层级:体感温度、天气描述、最高最低温。字号中等,作为第一层的补充说明。
第三层级:风力、湿度、气压、能见度,用两列或四列的宫格展示,小字号+图标。
第四层级:未来7天预报,用垂直列表展示,每天一行,包含日期、白天/夜间天气图标、最低最高温区间。这个列表我建议用RecyclerView嵌套在主界面的NestedScrollView里,再配合头部大卡片一起滚动。
3.2 卡片化设计与圆角层次
Material Design的卡片设计特别适合天气类应用。我用的参数组合:
- 外层背景:浅灰色或主题背景色
- 卡片背景:纯白色或半透明白,圆角16dp,海拔2dp
- 卡片间距:12~16dp
深浅色模式切换时,卡片颜色要跟着变。系统深色模式下,卡片用深灰色而不是纯黑,能保留层次感。
用MaterialCardView实现卡片:
<com.google.android.material.card.MaterialCardView android:layout_width="match_parent" android:layout_height="wrap_content" app:cardCornerRadius="16dp" app:cardElevation="2dp"> <!-- 卡片内容 --> </com.google.android.material.card.MaterialCardView>3.3 多类型列表适配器
未来7天预报这一块,如果是固定模式,可以直接用LinearLayout一行行加;但如果要混排"24小时预报"和"7天预报",建议用RecyclerView的多type实现。
class WeatherListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { companion object { const val TYPE_HEADER = 0 const val TYPE_HOURLY = 1 const val TYPE_DAILY = 2 const val TYPE_INDEX = 3 // 生活指数,比如穿衣、紫外线 } override fun getItemViewType(position: Int): Int { return dataList[position].type } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { TYPE_HEADER -> HeaderViewHolder(...) TYPE_HOURLY -> HourlyViewHolder(...) TYPE_DAILY -> DailyViewHolder(...) else -> IndexViewHolder(...) } } }多type的关键点在getItemViewType要稳定返回,不能依赖数据内容动态变化,否则RecyclerView复用时会错乱。
3.4 下拉刷新与状态管理
下拉刷新我用SwipeRefreshLayout包着NestedScrollView。刷新时要做三件事:
- 请求最新数据
- 数据成功返回后更新UI
- 关闭刷新动画
在ViewModel里维护一个isRefreshing状态:
sealed class WeatherUiState { object Loading : WeatherUiState() data class Success(val data: WeatherData) : WeatherUiState() data class Error(val message: String) : WeatherUiState() }界面层观察这个状态,根据状态决定显示加载动画、数据内容还是错误提示。这比直接用try-catch包裹网络请求再塞给Adapter要清晰得多,也方便做单元测试。
3.5 天气图标的矢量实现
天气图标不要用PNG图片集合,占包体积不说,还容易糊。我推荐用Android矢量图(VectorDrawable),每个图标一个xml文件。比如晴天是一个圆+几条放射线,用pathData绘制。网上有很多免费的天气矢量图标集,找一套风格统一的,转换成xml格式就行。
矢量图的好处:任意缩放不模糊,颜色可以跟随主题动态变化,包体积几乎是零成本。
4. 定位、数据库缓存与后台数据刷新
天气预报APP的"灵魂"是自动定位。用户打开APP就想看到当前位置的天气,如果每次都要手动搜索城市,体验就废了。但定位链路牵扯到权限、LocationManager、地理编码、后台任务调度,也是崩溃高发区。
4.1 定位服务的正确姿势
定位我分两条链路处理:GPS定位和网络定位。优先用GPS,GPS没有信号时降级到网络定位。
val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager val location = if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } else { locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) }但getLastKnownLocation有个问题:返回的可能是很久之前的缓存位置。如果用户从A城市飞到B城市,拿到的还是A城市的坐标。所以更可靠的方式是主动请求定位更新:
locationManager.requestLocationUpdates( LocationManager.GPS_PROVIDER, 1000L, // 最小时间间隔,单位毫秒 0f, // 最小距离间隔,单位米 locationListener )拿到经纬度后,要用Geocoder反向地理编码获取城市名:
val geocoder = Geocoder(context, Locale.CHINA) val addresses = geocoder.getFromLocation(latitude, longitude, 1) val cityName = addresses.firstOrNull()?.locality注意:反向地理编码是IO操作,必须放到子线程或协程的Dispatchers.IO里执行,放到主线程会出现"Application Not Responding"闪退。
权限处理:Android 10(API 29)需要ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION两个权限。Android 12开始还要求声明ACCESS_BACKGROUND_LOCATION才能后台定位,但天气预报这类应用完全没必要申请后台定位权限,只要前台定位就可以。权限申请要动态请求,用户拒绝后要给出引导弹窗,再拒绝就回退到默认城市。
4.2 数据库缓存设计
离线缓存我用Room实现。两张表:
- 城市表:存用户添加的常用城市、当前选中的城市
- 天气缓存表:存每个城市的天气JSON数据,加上缓存时间戳
@Entity(tableName = "city") data class CityEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, val latitude: Double, val longitude: Double, val isSelected: Boolean = false ) @Entity(tableName = "weather_cache") data class WeatherCacheEntity( @PrimaryKey val cityKey: String, val weatherDataJson: String, val cachedAt: Long )天气缓存表为什么不拆成多个字段而是直接存JSON?因为天气数据结构复杂、字段多,拆字段存进数据库反而难维护。存整个JSON字符串,反序列化还是一条逻辑,简单可靠。缓存时间戳用于判断数据是否过期,一般天气数据的合理缓存时间是30分钟,超过就触发网络刷新。
4.3 定时刷新:别用AlarmManager,用WorkManager
很多同学第一反应是AlarmManager+Service做定时刷新,但这是个坑。现在的Android系统对后台限制越来越严格,AlarmManager的精确闹钟会被Doze模式延迟,而且频繁唤醒CPU非常耗电。
我推荐用WorkManager,它是Google官方推荐的异步任务调度框架,系统会智能地将任务合并批量执行,省电且稳定。
class WeatherWorker(context: Context, params: WorkerParameters) : Worker(context, params) { override fun doWork(): Result { return try { val repository = WeatherRepository.getInstance(context) val refreshResult = runBlocking { repository.refreshSelectedCityWeather() } if (refreshResult) { Result.success() } else { Result.retry() } } catch (e: Exception) { Result.retry() } } }然后创建定时任务:
val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val request = PeriodicWorkRequestBuilder<WeatherWorker>(30, TimeUnit.MINUTES) .setConstraints(constraints) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( "weather_refresh", ExistingPeriodicWorkPolicy.KEEP, request )WorkManager最快执行周期是15分钟,如果APP想每30分钟刷新一次,这刚好满足。数据刷新成功后,如果要通知用户,前台发一条通知即可。注意Android 13开始要动态申请POST_NOTIFICATIONS权限,不申请的通知会直接不显示。
4.4 城市搜索功能
城市搜索我建议本地维护一份城市列表,用SQLite或直接Assets里放JSON,搜索时做模糊匹配。这样比每次搜索都请求服务端接口更快,还没网也能用。
搜索逻辑:
fun searchCities(keyword: String): List<CityEntity> { return cityDao.search("%$keyword%") }Room里的查询:
@Query("SELECT * FROM city WHERE name LIKE :keyword") suspend fun search(keyword: String): List<CityEntity>这个方案简单粗暴,但用户体验很直接,输入一个字就有候选列表。
5. 真机调试与发布前检查
说实话,前面写了那么多设计,真要让APP在几十种Android机型上都能正常跑,调试和发布前的排查才是真正耗时间的环节。这里把我实际踩过的坑和总结的检查清单分享出来。
5.1 常见的崩溃与显示异常问题
专业易懂但不算难点的问题
JSON字段缺失导致崩溃:天气服务商偶尔会返回异常的JSON结构,比如某个字段直接没有了。用Gson解析时如果目标字段是非空String,解析会抛异常。我的对策是:数据模型中可空字段用nullable类型,然后在界面展示处做空值兜底。
val tempText = weather.temp ?: "--"华氏度/摄氏度混乱:某次测试发现某个城市体感温度异常,排查后发现是服务商返回的字段单位变了。后来我在Repository层统一做一次单位转换,所有数据进入界面层之前都规整成统一单位,这个问题就再没出现过。
图标看不清:某个天气现象编码我在矢量图里没做映射,导致界面显示空白。解决方式是建立一张完整的编码映射表,并且给一个默认兜底图标,映射不到的统统显示为"多云"图标。
夜间模式适配崩坏:很多新手在XML里写死了背景色和文字颜色,深浅色模式切换时文字和背景融为一体。我的建议是全部用主题属性,比如?attr/colorSurface、?attr/colorOnSurface,不要写死#FFFFFF。
5.2 API Key的安全问题
这是发布前必须处理的问题。如果直接把API Key写在代码里,对方一抓包就能把Key抽走,一天就能把你的免费调用额度耗光。规避方案我推荐两种:
一是把Key放进local.properties或环境变量,通过BuildConfig注入。但这样只是不给"直接看代码的人"偷走,严格来说Key还是打包在APK里了。
二是搭一个轻量后端代理,APP请求自己的服务器,服务器再转发天气API请求,Key只保存在服务端。这样最安全,但需要额外维护服务器。对于个人练手项目,用第一种方案就够,但记得在API服务商后台设置配额限制,万一Key泄露损失也可控。
5.3 混淆与包体积优化
发布release包前要配置R8混淆。混淆时要注意数据模型类不能被混淆,否则Gson反射找不到字段。
在proguard-rules.pro里添加:
-keep class com.example.weather.data.model.** { *; }这个规则的意思是:data.model包下的类及其所有成员都要保留。这是我调试Gson解析问题后总结出来的经验,忘了加这一行,release包里的列表数据会全部变成null。
包体积优化方面,我的做法:
- 替换掉所有大图资源,能矢量就矢量
- 去掉未引用的依赖,用
./gradlew dependencies分析依赖树 - 开启资源收缩
android.enableResourceShrinking=true - 使用App Bundle替代APK上传应用市场,同一套代码能根据机型下发不同尺寸包
5.4 上线前检查清单
分享一份我每次发布前都会过的检查单:
| 检查项 | 具体内容 |
|---|---|
| 权限 | 是否只申请了必要权限,位置权限有没有合理拒绝引导 |
| 网络 | 弱网、无网环境下是否走缓存,是否给出友好提示 |
| 数据 | 温度单位、风力等级是否正确,异常数据是否兜底 |
| 界面 | 深浅色模式是否正常,小屏和大屏设备布局是否错乱 |
| 性能 | 启动时间是否过长,列表滚动是否卡顿 |
| 安全 | API Key是否移除硬编码,日志是否打印完整JSON |
| 后台 | 定时刷新是否省电,通知是否受权限限制 |
| 兼容 | Android 8到Android 14全版本实测,刘海屏安全区域是否避开 |
我见过太多APP没做兼容性测试就发布,结果在几款旧机型上直接闪退,用户差评一波带走。建议最少找5台不同品牌、不同Android版本的手机做一次真机测试,比在模拟器上跑一百遍都管用。
写在最后的一点体会
做天气APP这个项目,最大的收获其实不是"我会调API了",而是建立了一套完整的"发现问题—定位问题—解决问题"的方法。我做过一次最尴尬的事故:上线第二天,某个城市用户反馈温度显示异常,排查到半夜发现是该地区出现了一次极端天气,服务商返回的windScale字段为空,我的代码没做兜底。这种事故经历一次就长记性了,后面所有字段我都默认它可能不存在。
还有一个建议想分享给正在做这个项目的同学:开发阶段一定要做一个模拟数据开关。我在Repository里加了一个配置项,打开后不请求网络,而是从本地Asset读取固定JSON返回。这个开关在界面调试时特别好用——你可以快速切换不同天气数据来测试UI,比如模拟雨天UI、极端高温UI、数据字段缺失UI,不用频繁请求API、不用担心被限流、还能测试各种边界样式。
天气预报APP至少可以再扩展的方向很多:桌面小插件、语音播报天气、多城市天气对比、天气预警推送、穿衣出行指数。但我始终认为,把核心流程做扎实比追求功能堆砌重要得多。数据链路稳定了、界面交互顺了、异常情况兜住了,再往外扩功能,怎么加都不乱。