☰
Android天气预报APP毕业设计:定位请求解析缓存全链路实战
2026/10/9 10:40:32 网站建设 项目流程

简介:这是一套面向计算机相关专业学生的Android毕业设计完整项目,基于AndroidStudio开发的天气预报APP,附带详细文档说明,适合正在准备毕设、课程设计或期末大作业的学习者参考使用。项目经导师指导并获评审98分认可,源码均经本地编译与严格调试,可稳定运行,难度适中,兼顾学习与实战需求。资源包共666个文件,约23.93MB,涵盖108个java源文件、188个xml布局与配置、167个class编译文件、98个png图片资源,以及jar、gradle、aidl、aar等依赖与构建文件,并包含apk安装包与数据库文件,结构完整,便于按模块研读。目前已有185人学习关注。通过该资源,读者可获得一套可直接运行的天气APP完整方案,理解界面布局、数据请求与本地存储等核心实现,同时借助文档说明梳理项目结构与开发思路,为毕设答辩与项目实战提供有力参考。

1. 天气预报 APP 的毕业设计,真正拉开差距的是数据链路

每年到了毕设选题季,基于 Android Studio 的天气预报 APP 都是高频选项。原因很直接:需求清晰、界面直观、答辩时老师一眼能看懂,不像某些算法类题目讲十分钟还没进入正题。但问题也恰恰出在这里——十个人做天气预报,八个人的界面长得差不多,剩下两个连数据都是写死的。真正能拿到高分的,从来不是谁的 UI 更花哨,而是谁把「定位 → 请求 → 解析 → 缓存 → 展示」这条数据链路做扎实了。

这篇笔记面向正在做这个题目的同学,也面向想快速搭一个可演示天气模块的开发者。我会按真实开发顺序,把项目结构、网络请求、数据解析、本地缓存、界面绑定和答辩前最容易翻车的地方讲清楚。你照着做,能跑出一个联网可用、断网可看、定位可切换的完整 APP,而不是一个只能截图交差的壳子。

2. 先把工程骨架搭对:Android Studio 项目结构与依赖选型

很多人一上来就写 Activity,写到一半发现权限没配、依赖冲突、模拟器没网,然后开始到处搜报错。正确的顺序是先定架构,再写代码。这一章解决的是「项目怎么搭、库怎么选、权限怎么配」的问题。

2.1 用 MVVM 还是 MVC:毕设场景下的取舍

教科书上讲 MVC,实际开发里 MVVM 更省心。原因在于天气 APP 的数据流是单向的:定位拿到经纬度 → 请求接口 → 返回 JSON → 解析成实体 → 更新 UI。如果全塞进 Activity,一个文件轻松超过八百行,答辩时老师翻代码会直接问「你这 Activity 怎么这么臃肿」。

我一般会这样分层:

  • ui包:Activity、Fragment、Adapter,只负责展示和用户交互
  • data包:网络请求、JSON 解析、本地缓存
  • model包:实体类,比如WeatherResponse、DailyForecast
  • util包:定位工具、时间格式化、网络状态判断

这样分的好处是,答辩时你可以指着目录说「这是数据层,这是展示层」,逻辑清晰,老师印象分直接上去。不需要引入完整的 Jetpack 全家桶,毕设体量用不上,反而增加学习成本。

2.2 依赖库怎么选:Retrofit + Gson + OkHttp 的最小组合

网络请求这块,常见做法是 Retrofit 配 Gson,底层用 OkHttp。这三个库配合成熟、文档多、出问题好搜。在app/build.gradle里加:

dependencies { // 网络请求核心 implementation 'com.squareup.retrofit2:retrofit:2.9.0' // Gson 转换器,把 JSON 自动映射成实体类 implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // OkHttp 日志拦截器,调试时打印请求和响应 implementation 'com.squareup.okhttp3:logging-interceptor:4.9.3' // 定位,用系统自带的即可,不必引入第三方 implementation 'com.google.android.gms:play-services-location:21.0.1' }

这里有个坑要提前说:converter-gson的版本必须和retrofit主版本对应,否则运行时会抛NoClassDefFoundError。我见过有人 retrofit 用 2.9.0,converter 用 2.6.0,编译能过,一请求就崩,查了半天。

2.3 权限配置:定位和网络一个都不能少

在AndroidManifest.xml里声明权限:

<!-- 网络权限,请求天气接口必须 --> <uses-permission android:name="android.permission.INTERNET" /> <!-- 粗略定位,用于获取城市 --> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- 精确定位,精度更高但耗电 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- 读取网络状态,用于判断是否离线 --> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

注意 Android 6.0 以后定位是危险权限,必须在运行时动态申请,只在 Manifest 里写是没用的。动态申请的逻辑放在首页 Activity 的onCreate里,用户拒绝后要给一个「去设置」的引导,否则定位永远拿不到值。

2.4 接口选型:为什么建议用聚合类天气 API

天气数据源大致分两类:一类是官方气象接口,数据权威但接入门槛高、需要申请 key;另一类是聚合类 API,注册即用、返回 JSON 结构清晰,适合毕设。选型时重点看三点:是否免费、是否有逐小时预报、返回字段是否包含湿度风力等常用指标。

我一般会选一个提供「实时天气 + 未来七天 + 逐小时」三类接口的服务,这样界面能做出层次感。接口地址和 key 不要硬编码在 Java 文件里,放到gradle.properties或单独的Constants类,答辩时老师问「key 泄露怎么办」你能答上来。

3. 网络请求与 JSON 解析:从经纬度到天气实体的完整链路

骨架搭好后,核心工作就是打通数据链路。这一章是整篇笔记最厚的部分,因为大部分 bug 都出在这里。

3.1 定义实体类:字段名对不上是解析失败的头号原因

假设接口返回的 JSON 长这样:

{ "location": {"name": "某市", "lat": 30.2, "lon": 120.1}, "now": {"temp": 26, "text": "多云", "humidity": 65, "windDir": "东南风"}, "daily": [ {"date": "2024-06-01", "tempMax": 30, "tempMin": 22, "textDay": "晴"} ] }

对应的实体类要这样写:

public class WeatherResponse { // 字段名必须和 JSON 的 key 完全一致,大小写敏感 private Location location; private Now now; private List<Daily> daily; // getter/setter 省略,但必须有,Gson 靠反射赋值 public static class Location { private String name; private double lat; private double lon; } public static class Now { private int temp; private String text; private int humidity; private String windDir; } public static class Daily { private String date; private int tempMax; private int tempMin; private String textDay; } }

逻辑说明:Gson 解析时按字段名匹配,JSON 里叫tempMax,Java 字段就必须叫tempMax,写成temp_max或maxTemp都会得到 null。如果接口字段名和 Java 命名规范冲突(比如接口用下划线),用@SerializedName("temp_max")注解映射,不要改字段名去迁就接口。

参数说明:int和double的选择看数据范围,温度用 int 够用,经纬度必须用 double,用 float 会丢精度导致定位偏移。

3.2 封装 Retrofit 客户端:单例 + 拦截器 + 超时

public class ApiClient { private static final String BASE_URL = "https://api.example.com/"; private static Retrofit retrofit; public static Retrofit getInstance() { if (retrofit == null) { // 日志拦截器,只在 Debug 版本开启,Release 要关掉 HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); // 超时设置,天气接口响应快,10 秒足够 OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .addInterceptor(logging) .build(); retrofit = new Retrofit.Builder() .baseUrl(BASE_URL) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build(); } return retrofit; } }

逻辑说明:用双重检查锁保证单例,避免每次请求都新建 OkHttpClient 导致连接池浪费。日志拦截器在调试时能直接看到请求 URL 和返回 JSON,是排查解析问题的第一工具。

参数说明:connectTimeout是建立连接的超时,readTimeout是等待响应的超时。两个都要设,只设一个在弱网下仍会卡死。超时时间不要设太长,否则用户等半天没反应会以为 APP 崩了。

3.3 定义接口与发起请求

public interface WeatherService { // GET 请求,路径参数用 @Path 占位 @GET("weather") Call<WeatherResponse> getWeather( @Query("lat") double lat, @Query("lon") double lon, @Query("key") String key ); }

调用时:

WeatherService service = ApiClient.getInstance().create(WeatherService.class); service.getWeather(lat, lon, Constants.API_KEY) .enqueue(new Callback<WeatherResponse>() { @Override public void onResponse(Call<WeatherResponse> call, Response<WeatherResponse> response) { if (response.isSuccessful() && response.body() != null) { // 主线程更新 UI updateUI(response.body()); } else { // HTTP 码非 200,比如 401 key 失效、429 限流 showError("请求失败:" + response.code()); } } @Override public void onFailure(Call<WeatherResponse> call, Throwable t) { // 网络异常,比如无网、DNS 失败 showError("网络异常:" + t.getMessage()); } });

逻辑说明:enqueue是异步请求,回调默认在子线程,更新 UI 必须切回主线程,用runOnUiThread或 Handler。onResponse里先判断isSuccessful(),再判断body()非空,两层都要判,否则接口返回空数据时会空指针。

参数说明:@Query会把参数拼到 URL 后面,@Path是替换路径中的占位符。key 建议放在请求头而不是 URL,避免日志里泄露,但毕设用 Query 也能接受。

3.4 定位获取经纬度:LocationManager 的正确用法

LocationManager lm = (LocationManager) getSystemService(Context.LOCATION_SERVICE); // 先检查权限,没授权直接返回 if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { requestPermission(); return; } // 用 GPS 和网络双通道,室内 GPS 弱时靠网络兜底 lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, new LocationListener() { @Override public void onLocationChanged(Location location) { double lat = location.getLatitude(); double lon = location.getLongitude(); // 拿到坐标后立刻请求天气 loadWeather(lat, lon); } // 其余回调省略 });

逻辑说明:定位是异步的,不能拿到 LocationManager 就以为有坐标了,必须等onLocationChanged回调。很多新手在这里翻车——直接在onCreate里调getLastKnownLocation,模拟器上返回 null,界面一直空白。

参数说明:requestLocationUpdates的第二个参数是最小更新时间(毫秒),第三个是最小更新距离(米)。都设 0 表示实时更新,但耗电高。毕设场景设 60000 和 100 就够,避免频繁回调。

4. 本地缓存与离线展示:断网时 APP 不能是白板

答辩现场网络往往不稳定,如果断网就白屏,老师会直接质疑可用性。缓存不只是加分项,是保命项。

4.1 用 SharedPreferences 存最近一次天气 JSON

最轻量的方案是把整个响应 JSON 字符串存下来,下次启动先读缓存渲染,再发请求刷新。

// 存 SharedPreferences sp = getSharedPreferences("weather_cache", MODE_PRIVATE); sp.edit().putString("last_json", new Gson().toJson(response)).apply(); // 读 String cached = sp.getString("last_json", null); if (cached != null) { WeatherResponse data = new Gson().fromJson(cached, WeatherResponse.class); updateUI(data); // 先用旧数据渲染 }

逻辑说明:存的是序列化后的 JSON 字符串,读的时候反序列化回实体。这样缓存和网络返回的数据结构完全一致,UI 更新逻辑可以复用,不用写两套。

参数说明:apply()是异步写入,commit()是同步写入。缓存场景用apply()即可,不阻塞主线程。SharedPreferences 单个 value 不宜超过 100KB,天气 JSON 通常几 KB,完全够用。

4.2 加时间戳判断缓存是否过期

只存数据不存时间,用户可能看到三天前的天气。加一个时间戳:

long cacheTime = sp.getLong("cache_time", 0); long now = System.currentTimeMillis(); // 超过 30 分钟就算过期,但仍先展示,再后台刷新 boolean expired = (now - cacheTime) > 30 * 60 * 1000; if (expired) { // 触发网络请求,成功后覆盖缓存 loadWeatherFromNet(); }

逻辑说明:过期不代表不能用,而是「先展示旧的,再静默刷新」。这样用户打开 APP 立刻有内容,不会盯着 loading 转圈。

参数说明:30 分钟是天气类 APP 的常见刷新间隔,太短浪费流量,太长数据不准。可以做成常量方便调整。

4.3 网络状态判断:避免无网时傻等超时

ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo info = cm.getActiveNetworkInfo(); boolean online = (info != null && info.isConnected()); if (!online) { // 直接读缓存,不发请求 loadFromCache(); Toast.makeText(this, "当前无网络,展示缓存数据", Toast.LENGTH_SHORT).show(); }

逻辑说明:先判断网络再决定走网络还是走缓存,能避免无网时请求卡 10 秒超时,用户体验差别很大。

参数说明:getActiveNetworkInfo在 Android 10 以后被标记废弃,但毕设兼容低版本仍可用。新项目建议用NetworkCapabilities,这里不展开。

5. 避坑与排查:那些让答辩当场卡壳的问题

这一章是我带过几届学生后总结的高频翻车点,每条都按「现象 → 原因 → 解决」写,照着排查能省大量时间。

5.1 模拟器能联网,真机请求失败

现象:在 Android Studio 自带模拟器上天气正常显示,装到真机上一直提示网络异常。

原因:模拟器走的是宿主机网络,真机走的是移动数据或 WiFi,如果接口是 HTTP 而非 HTTPS,Android 9 以后默认禁止明文流量。

解决:在AndroidManifest.xml的<application>标签加android:usesCleartextTraffic="true",或者把接口换成 HTTPS。生产环境必须用 HTTPS,毕设如果接口只支持 HTTP,加这个属性即可。

5.2 定位一直返回 null

现象:权限也申请了,代码也写了,onLocationChanged就是不回调。

原因:三种可能——室内 GPS 信号弱、模拟器没设置虚拟位置、权限被用户拒绝后没重新申请。

解决:模拟器在 Extended Controls 里手动设置经纬度;真机到窗边测试;权限拒绝后引导用户去系统设置页手动开启。另外检查是否在onCreate里就调用了getLastKnownLocation,这个方法在冷启动时经常返回 null,必须配合requestLocationUpdates使用。

5.3 JSON 解析后字段全是 null

现象:请求成功,response.body()不为 null,但里面的温度、城市名都是 null。

原因:字段名不匹配。接口返回temp,实体类写temperature;或者接口返回嵌套对象,实体类写成了平铺。

解决:打开日志拦截器,把返回的 JSON 完整打印出来,逐字段对照实体类。嵌套结构必须用内部类对应,不能拍平。命名不一致的用@SerializedName注解。

5.4 界面更新时崩溃,报 CalledFromWrongThreadException

现象:请求成功后调textView.setText()直接崩。

原因:Retrofit 的onResponse回调在子线程,Android 不允许子线程操作 UI。

解决:用runOnUiThread(() -> updateUI(data))包一层,或者用 Handler 切主线程。如果用了 LiveData 或 RxJava,注意 observe 的线程切换。

5.5 打包 release 版本后接口报 401

现象:debug 版本正常,签名打包后请求全部失败。

原因:代码里用了BuildConfig.DEBUG判断,release 下走了不同的 key 或 URL;或者混淆规则把实体类字段名改了,导致 Gson 解析失败。

解决:检查build.gradle的buildTypes配置,确认 release 的 key 正确。在proguard-rules.pro里加-keep class com.xxx.model.** { *; },保留实体类不被混淆。

6. 让界面和数据对得上:Adapter 绑定与刷新时机的一个技巧

最后一章讲一个具体技巧,也是我踩过坑之后固定下来的习惯:列表类天气数据(比如未来七天)用 RecyclerView 绑定时,刷新时机比绑定逻辑更容易出问题。

常见写法是在请求成功后直接adapter.notifyDataSetChanged()。这在数据量小的时候没问题,但如果用户快速切换城市,两次请求的回调顺序可能颠倒——先发的请求后返回,后发的请求先返回,结果界面显示的是旧城市的数据。这个 bug 在答辩演示时如果被触发,非常尴尬。

我的做法是给每次请求打一个自增的 requestId,回调时比对当前最新的 requestId,不一致就丢弃:

private int latestRequestId = 0; private void loadWeather(double lat, double lon) { final int currentId = ++latestRequestId; service.getWeather(lat, lon, Constants.API_KEY) .enqueue(new Callback<WeatherResponse>() { @Override public void onResponse(Call<WeatherResponse> call, Response<WeatherResponse> response) { // 关键:只处理最新一次请求的结果 if (currentId != latestRequestId) return; if (response.isSuccessful() && response.body() != null) { runOnUiThread(() -> { adapter.updateData(response.body().getDaily()); adapter.notifyDataSetChanged(); }); } } @Override public void onFailure(Call<WeatherResponse> call, Throwable t) { if (currentId != latestRequestId) return; runOnUiThread(() -> showError(t.getMessage())); } }); }

逻辑说明:latestRequestId每次请求前自增,回调里比对,只有最后一次请求的结果会被渲染。这样即使用户连续切换城市,界面最终显示的也是最后一次选择的数据,不会出现「切到 B 城市却显示 A 城市天气」的错乱。

参数说明:currentId必须是 final 或 effectively final,因为要在匿名内部类里引用。adapter.updateData里先清空旧列表再添加新数据,避免数据叠加。

另外一个小习惯:RecyclerView 的 item 布局里,温度、天气状况这些文本控件宽度设成wrap_content而不是固定 dp,因为不同城市的天气描述长度不一样(「多云」和「雷阵雨转中到大雨」差很多),固定宽度会截断。这个细节答辩时老师不一定问,但界面看起来舒服,印象分是实打实的。

做这个题目最大的体会是:天气预报 APP 的技术难点不在界面,而在数据链路的稳定性。把定位、请求、解析、缓存、刷新这五步都考虑到异常情况,代码量不大,但经得起断网、切城市、快速点击这些真实操作的考验。我现在的习惯是每写完一个数据模块,先手动断网跑一遍,再连续切换三次城市,能扛住这两下,基本就稳了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询