简介:Android Studio实现的天气预报App项目源码,面向Android初、中级学习者及需要完成课程设计或毕业设计的学生。项目采用中国天气网API,通过HTTP获取网络数据、JSON解析并展示到界面,数据库负责存放城市与对应URL,支持城市收藏及切换天气,是一套功能完整的可运行课设方案。源码包共107个文件,涵盖15个Java逻辑文件、24个XML布局与配置、53个PNG界面资源,另含gradle构建文件、pinyin4j.jar等依赖,压缩包仅6.75MB,结构清晰便于直接导入Android Studio使用。当前已有532人学习下载。通过该项目可以系统掌握网络请求、JSON解析、SQLite存储和动态界面刷新等核心开发流程,也可作为毕业设计、课程作业的参考模板或二次开发基础。
1. Android Studio实现天气预报App,这个课设题目的关键不在"写代码"
Android Studio实现天气预报App,几乎是Android方向课设里点名率最高的题目之一。但大部分人的进度卡在同一个位置:代码能从 GitHub 或网盘下下来,一 Sync 就报错;报错修好了,一运行又闪退;闪退解决了,定位权限弹窗出来后,天气数据还是空的。原因不是代码写得差,而是这条链路由四段组成——定位、联网、解析、上屏,任何一段出问题,界面上就是一片空白。
这个标题对应的不只是一份"能运行的工程",而是一份按课设评分点整理过的完整项目:入口界面、网络请求模块、JSON解析Bean、多日天气列表、权限申请都在里面。你要做的不是从零写一遍,而是把它的结构吃透,然后按自己老师的要求改包名、改UI、改数据源,再能在答辩现场把每个模块讲清楚。
我接下来会按拿到这类工程后的处理顺序来讲:先定三件容易被忽略的前置事,再走一遍核心链路的实现,最后把高频坑和答辩前的自测方法补上。适合两类人:准备交Android大作业的学生,以及想拿现成工程做二次开发、但不想被环境问题卡住的人。
2. 跑通天气App前的三件事:环境、数据源、工程骨架
2.1 环境匹配:AGP 与 Gradle 版本对不上,Sync 永远是红色
拿到工程的第一件事不是点 Run,而是先确认这个项目原本是在什么环境上跑的。天气课设项目大多是从 Android Studio 里直接导出的 zip,里面会带着gradle-wrapper.properties和根目录build.gradle。这两个文件决定了项目要用哪个 Gradle 版本和 Android Gradle Plugin(AGP)版本。大部分 Sync 报错、依赖下载失败、构建卡在 90% 的问题,根源都是版本错配。
先看gradle-wrapper.properties里锁定的 Gradle 版本:
distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists再对照根目录build.gradle里的 AGP 版本:
buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.4.0' } }版本匹配关系决定了 Sync 能不能过。常见的做法是:AGP 8.x 配 Gradle 8.x,AGP 7.x 配 Gradle 7.x。如果你本地的 Android Studio 版本比较新,但项目用的还是 AGP 7.x,Sync 时它会提示升级;反过来,本地 Gradle 版本太旧,AGP 8.x 会直接报AGP requires Gradle X并停止构建。
| 工程里的 AGP 版本 | 最低 Gradle 版本 | 对应 JDK |
|---|---|---|
| AGP 7.x | Gradle 7.x | JDK 11 |
| AGP 8.x | Gradle 8.x | JDK 17 |
我一般拿到工程后先做两件事:第一,确认gradle-wrapper.properties里的 distributionUrl 是不是国内能正常下载的地址,不是的话改成或手动下载对应 zip 放到 Gradle 用户目录;第二,确认 JDK 用的是 Android Studio 自带的 JBR(JetBrains Runtime),不要在系统里额外配一个 JDK 8 或 JDK 11,否则会冒出各种 "Unsupported class file major version" 的诡异报错。
提示:不要因为 Sync 报错就盲目把 Gradle 版本往上升。AGP 和 Gradle 是一一对应的,改一个不换另一个,只会从红色报错变成另一个红色报错。
2.2 天气数据源选型:免费 Key、URL 请求格式、返回 JSON 结构
Weather App 的核心不是界面,是数据源。选数据源的原则有三条:免费额度够课设演示、接口不需要 OAuth 那种复杂的授权流程、返回的 JSON 结构稳定且字段完整。我习惯用某天气数据平台的免费版,它按城市 ID 查询实时天气和未来几天预报,key 直接拼在 URL 上,没有签名算法,最适合课设和二次开发。
这类接口的请求格式通常长这样:
GET http://api.weather.example.com/v1/weather/now?cityId=CITY_ID&key=YOUR_KEY返回的 JSON 大概是这样,注意嵌套层级:
{ "code": "200", "cityId": "101010100", "now": { "temp": "26", "text": "多云", "windDir": "东南风", "windScale": "3" }, "forecast": [ { "date": "2025-01-06", "tempMax": "30", "tempMin": "22", "textDay": "晴" } ] }搞清楚每个字段的含义,比急着写代码更重要。这决定了后面 JSON 解析的 Bean 长什么样:
| 请求参数 | 说明 | 示例 |
|---|---|---|
| cityId | 城市编号,不是城市名 | 101010100 |
| key | 数据平台分配的免费密钥 | 一串十六进制字符串 |
| v1/weather/now | 实时天气接口路径 | 固定不变 |
免费版通常有每日调用次数限制,课设演示时不会触发,但要注意一个细节:不要在代码里写死循环去轮询,也不要每次进页面都重新申请 key。数据源选好后,把 key 提到一个常量类里,别散落在 Activity 里,后面改起来方便。
2.3 工程目录结构:先认骨架,再动手改
解压这个 zip 后,正常情况下你会看到一个标准的 Android 工程目录。我拿到这类项目后,会先把目录按模块过一遍,不急着打开 MainActivity 看逻辑。因为天气 App 的结构太固定了,认清了每个模块的职责,后面改代码时才知道去哪找。
app/src/main/java/com/example/weather/ ├── MainActivity.java # 入口,负责初始化、权限、刷新逻辑 ├── adapter/ │ └── WeatherAdapter.java # 多日天气列表的适配器 ├── network/ │ └── HttpUtil.java # OkHttp 请求封装 ├── bean/ │ └── WeatherBean.java # Gson 解析用的数据类 └── res/ ├── layout/ │ ├── activity_main.xml # 主界面布局 │ └── item_weather.xml # 单日天气卡片 └── values/ ├── strings.xml └── colors.xml入口 Activity 负责把权限、定位、网络请求、UI 更新串起来;HttpUtil 只管发请求和拿回 JSON 字符串;WeatherBean 只负责把 JSON 映射成 Java 对象;WeatherAdapter 只负责把数据画到列表上。各模块之间用最简单的方式调用,不要搞复杂的架构,课设项目最怕"过度设计"。
拿到工程后我建议先做一个容易被忽略的操作:改包名。右键项目名 → Refactor → Rename → 把com.example.weather改成你自己的域名形式。这一步不涉及代码逻辑,但意义重大。同一届里一旦有两个人交了包名一样的项目,老师那边查重时解释成本会非常高。顺便把applicationId也同步改掉,这一步做完,项目才真的算"你的"。
3. 把核心链路串起来:定位、请求、解析、上屏
3.1 Android 6.0+ 动态权限申请:定位和联网缺一不可
天气 App 需要三个权限:网络权限、粗略定位权限、精确定位权限。网络权限是普通权限,在 Manifest 里声明即可;定位权限属于危险权限,Android 6.0 及以上必须在运行时动态申请,否则就算你在 Manifest 里写了,应用也拿不到定位结果。
先在AndroidManifest.xml里声明:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />然后在 MainActivity 里写运行时请求:
private void requestLocationPermission() { if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION }, 1001); } else { getLocation(); } } @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == 1001 && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { getLocation(); } }这段代码的逻辑很直接:先检查自己有没有定位权限,没有就弹系统对话框;有就直接去拿定位。requestCode传 1001,回调时用同一个值判断是哪次请求的结果。要注意的是,别把网络权限也放进requestPermissions里一起申请,普通权限不需要运行时弹窗,也不应该出现在这个列表里。
提示:Android 12 及以上还多了一个"精确位置"开关。模拟器上默认可能只给了模糊定位,实测发现拿不到坐标时,先去系统设置里确认位置权限是"精确"而不是"大致"。
3.2 用 OkHttp 封装天气请求:URL 构造与回调线程切换
网络请求我习惯用 OkHttp。相比 HttpURLConnection,它的 API 更简洁,超时设置、异步回调、连接池都是开箱即用。课设项目里不需要引入 Retrofit,那会平白多一层注解和适配器,答辩时反而不好解释。
封装一个 HttpUtil,核心就一个方法:传入 URL,回调里返回 JSON 字符串。
public class HttpUtil { private static final OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build(); public static void get(String url, Callback callback) { Request request = new Request.Builder() .url(url) .get() .build(); client.newCall(request).enqueue(callback); } }调用时注意回调线程。enqueue的回调跑在 OkHttp 的子线程里,不能直接更新 UI:
HttpUtil.get(url, new Callback() { @Override public void onFailure(Call call, IOException e) { runOnUiThread(() -> showError("网络请求失败")); } @Override public void onResponse(Call call, Response response) throws IOException { String json = response.body().string(); runOnUiThread(() -> parseAndShow(json)); } });connectTimeout是建立连接的超时时间,readTimeout是拿到响应体的超时时间,课设场景下各设 10 秒足够。runOnUiThread是把子线程切回主线程的标准姿势,不用它就会崩在Only the original thread that created a view hierarchy can touch its views这个异常上。
有个细节值得注意:response.body().string()只能调用一次,多次调用会拿到空字符串。很多人在这里踩坑,以为接口返回了空数据,其实是自己把 Response Body 读了两遍。
3.3 Gson 解析嵌套 JSON:字段对照和兜底默认值
拿到 JSON 字符串之后,下一步是解析成 Java 对象。这里我用 Gson,配合@SerializedName注解把 JSON 字段名映射成 Java 字段名。不用注解也可以,但要求 Java 字段名和 JSON 字段名完全一致,课设代码里还是注解更直观。
WeatherBean 按返回结构拆成嵌套类:
public class WeatherBean { @SerializedName("code") public String code; @SerializedName("cityId") public String cityId; @SerializedName("now") public Now now; @SerializedName("forecast") public List<Forecast> forecast; public static class Now { @SerializedName("temp") public String temp; @SerializedName("text") public String text; @SerializedName("windDir") public String windDir; } public static class Forecast { @SerializedName("date") public String date; @SerializedName("tempMax") public String tempMax; @SerializedName("tempMin") public String tempMin; @SerializedName("textDay") public String textDay; } }解析那一行的代码很简单:
WeatherBean bean = new Gson().fromJson(json, WeatherBean.class);但简单的前提是字段对得上。我把常见的对应关系列出来:
| JSON 字段 | Bean 字段 | 类型 | 说明 |
|---|---|---|---|
| now.temp | now.temp | String | 当前温度 |
| now.text | now.text | String | 当前天气现象 |
| forecast[0].tempMax | forecast.get(0).tempMax | String | 当天最高温 |
| forecast[0].textDay | forecast.get(0).textDay | String | 白天天气 |
解析时另一个要点是判空。后端偶尔会把now或forecast返回成 null,直接用bean.now.temp必然空指针。我习惯在 Bean 里给所有字段设默认值,或者在解析完后加一层if (bean.now != null)保护。
3.4 RecyclerView 展示多日天气:把 Bean 变成卡片
实时天气那一块用 TextView 显示没问题,多日天气预报就必须上 RecyclerView 了。用 ScrollView 套横向 LinearLayout 虽然也能跑,但滑动性能和复用机制差很多,答辩时老师一问"为什么用 RecyclerView",你还能顺带讲出 ViewHolder 复用机制这个考点。
MainActivity 里初始化列表的代码:
RecyclerView recyclerView = findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); recyclerView.setHasFixedSize(true); weatherAdapter = new WeatherAdapter(forecastList); recyclerView.setAdapter(weatherAdapter);setHasFixedSize(true)的作用是告诉 RecyclerView:item 的尺寸不会因为内容变化而改变,让它跳过重新测量这一步,滑动会明显更流畅。
Adapter 的核心逻辑在onBindViewHolder里:
public class WeatherAdapter extends RecyclerView.Adapter<WeatherAdapter.ViewHolder> { private List<WeatherBean.Forecast> list; public WeatherAdapter(List<WeatherBean.Forecast> list) { this.list = list; } @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_weather, parent, false); return new ViewHolder(view); } @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { WeatherBean.Forecast item = list.get(position); holder.dateView.setText(item.date); holder.tempView.setText(item.tempMin + "~" + item.tempMax + "℃"); holder.textView.setText(item.textDay); } @Override public int getItemCount() { return list == null ? 0 : list.size(); } static class ViewHolder extends RecyclerView.ViewHolder { TextView dateView, tempView, textView; ViewHolder(@NonNull View itemView) { super(itemView); dateView = itemView.findViewById(R.id.tv_date); tempView = itemView.findViewById(R.id.tv_temp); textView = itemView.findViewById(R.id.tv_text); } } }onCreateViewHolder负责创建 item 的视图容器,getItemCount返回列表长度,onBindViewHolder是每次滚动到某个位置时把数据填进对应控件。注意getItemCount里要做 null 保护,因为网络数据没回来时forecastList可能是空的,返回 0 就不会画任何东西,也不会崩。
4. 天气课设App避坑手册:5个高频翻车点的现象、原因、解决
4.1 模拟器上定位失败,天气永远显示默认城市
现象:真机上能定位,模拟器上运行却永远显示默认城市。不管怎么点击刷新,城市都不变。
原因:模拟器的 GPS 默认没有坐标值。Android 模拟器不像手机一样有真实的 GPS 模块,LocationManager 拿到的 location 对象可能为空,代码里直接getLatitude()就抛空指针,或者拿到一个默认的 0,0 坐标。
解决:两步。第一步,在模拟器的 Extended Controls 里找到 Location 面板,手动填入一组经纬度,比如你所在城市的坐标,点 Send 发送给模拟器。第二步,代码里对 location 做判空处理,拿不到坐标时直接用默认城市 ID 发起请求。判空代码放在定位回调里:
Location location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); String cityId = DEFAULT_CITY_ID; if (location != null) { cityId = convertLocationToCityId(location.getLatitude(), location.getLongitude()); }convertLocationToCityId这种转换在课设里不需要做得太复杂,用经纬度范围匹配附近城市就行。如果懒得做逆地理编码,也可以简化成"定位成功就更新一次城市名,失败就用默认城市"。
4.2 请求天气接口报 "Cleartext HTTP traffic not permitted"
现象:App 跑起来了,但 Logcat 里出现红色异常,关键字是Cleartext HTTP traffic to api.weather.example.com not permitted,界面上一片空白。
原因:Android 9.0(API 28)开始,系统默认禁止应用使用明文 HTTP 流量。如果天气接口用的是 http:// 而不是 https://,请求会被直接拦下来。
解决:在 Manifest 的 application 标签里打开明文流量开关。这个做法只影响调试阶段,课设足够。
<application android:usesCleartextTraffic="true" ...>如果不想全局放开,也可以只针对某个域名配置网络安全策略,但课设场景没有必要,全局开关最简单。要注意的是,这个属性在application标签下,不是manifest根节点下,写错位置会直接编译报错。
4.3 温度、天气现象全是 null:JSON 字段名和 Bean 不一致
现象:App 能正常运行,列表也能滑,但每一个 item 上面显示的都是 null,温度那栏只有一个"℃"。
原因:Gson 是严格按字段名映射的。接口返回的是tempMax,你 Bean 里写的是tempMax还好;如果手滑写成temp_max或者TempMax,解析结果就是 null。还有一种情况是嵌套层级不对,接口返回的是data.now.temp,你的 Bean 里没有data这一层,直接把now放最外层了。
解决:先用浏览器打开接口地址,把返回的 JSON 原文复制进任意能格式化 JSON 的编辑器里,看清楚层级关系,再对照着改 Bean。最快的排查方法是在解析后打印一行日志:
Log.d("WeatherBean", new Gson().toJson(bean));如果输出{"code":"200","now":null},说明是now那层的字段名或结构对不上,先修 Bean 的嵌套结构,再修字段名。这里花 10 分钟排查,比反复改代码重跑省时间得多。
4.4 首次运行直接闪退:key 没填或者子线程更新了 UI
现象:安装成功,点击图标,应用闪一下就没。
原因:最常见的有两个。一是天气接口的 key 没填或填错了,接口返回错误码,代码没有做异常处理,直接在onResponse里解析了一个异常 JSON,抛 JSON 解析异常。二是忘记回调线程这件事,在 OkHttp 的子线程里直接调了textView.setText(),触发主线程限制崩溃。
解决:看 Logcat 的 FATAL EXCEPTION 那一行,定位是哪一种。如果崩溃信息里有MainThread或Only the original thread,就是线程问题,把 UI 更新代码包进runOnUiThread。如果崩溃信息里有Gson或JSONException,去检查 key 和接口返回。注意把 key 从代码里找出来确认一下,很多人把YOUR_KEY这个占位符原封不动留到运行。
4.5 列表卡片滑动掉帧,快速滑动时卡顿
现象:列表能显示,但上下滑动时明显卡顿,快速滑甚至出现白屏。
原因:天气 item 布局嵌套太深——LinearLayout 套 RelativeLayout 再套 LinearLayout,每个 item 里还实时做一些字符串拼接和日期格式化。RecyclerView 虽然复用 ViewHolder,但每次复用时还是要跑一遍布局里的测量逻辑,嵌套越深越卡。
解决:两个习惯。第一,给 RecyclerView 加setHasFixedSize(true),已经在第 3 章写过;第二,item 布局尽量拍平,外层一个 LinearLayout,里面几个 TextView,避免多重嵌套。另外一个常见误用是在onBindViewHolder里做new SimpleDateFormat(...).format(...)这种耗时操作,把日期格式化放到 Bean 的 getter 里,或者提前在解析阶段就格式化好,不要在滚动回调里现算。
5. 从"能交差"到"满分课设":四个加分项直接写进工程
5.1 多城市管理与本地缓存:离线演示不露怯
课设老师最爱问的问题之一是"这个 App 支持几个城市?"。如果你只有一个固定的默认城市,回答会很尴尬。多城市管理用 SharedPreferences 就能做,不需要引入数据库。
private static final String PREF_CITIES = "pref_cities"; private static final String KEY_CITIES = "key_cities"; public static void saveCityList(Context context, List<String> cityIds) { SharedPreferences sp = context.getSharedPreferences(PREF_CITIES, Context.MODE_PRIVATE); android.text.TextUtils.join(",", cityIds); sp.edit().putString(KEY_CITIES, TextUtils.join(",", cityIds)).apply(); } public static List<String> loadCityList(Context context) { SharedPreferences sp = context.getSharedPreferences(PREF_CITIES, Context.MODE_PRIVATE); String raw = sp.getString(KEY_CITIES, "101010100"); return Arrays.asList(raw.split(",")); }apply()是异步写入,不会阻塞主线程。用一个逗号分隔的字符串保存城市 ID 列表,读取时再拆开,足够支撑课设。
缓存是另一个加分点。把每次请求成功的 JSON 原样存下来,冷启动时先渲染缓存再发请求:
sp.edit().putString("last_json", json).apply();下次启动直接读last_json解析上屏。这个逻辑在答辩现场特别有用:把网络断了,打开 App,天气数据还在,城市还能切换,老师会认为你考虑到了弱网和离线场景。
5.2 下拉刷新与失败重试态:界面不只是"加载成功"一种样子
SwipeRefreshLayout 是官方下拉刷新组件,用法很固定:它包住 RecyclerView,监听刷新事件后重新请求数据,请求结束调用setRefreshing(false)收起刷新动画。
SwipeRefreshLayout refreshLayout = findViewById(R.id.swipe_refresh); refreshLayout.setOnRefreshListener(() -> { fetchWeather(currentCityId); });另一个容易忽略的是请求失败时的界面。加载失败不能干瞪眼看着空白列表,至少要有两种补救态:一种是把上次缓存的数据继续显示,顶部弹一个 Toast 提示"数据已过期";另一种是根本没有缓存,显示一行"加载失败,点击重试"的小布局。重试按钮绑定同一个fetchWeather方法,代码量不大,但界面完整度会高一大截。
5.3 启动页、应用图标和命名规范:答辩的第一印象
很多课设项目没有启动页,点击图标后直接进主界面。但完整的 App 应该有一个启动阶段。不推荐单独写一个 SplashActivity 然后 Handler 延迟跳转——那是在给自己制造麻烦。正确做法是用主题的windowBackground:
<style name="Theme.Weather" parent="Theme.AppCompat.Light.NoActionBar"> <item name="android:windowBackground">@drawable/bg_splash</item> </style>bg_splash是一张带 logo 和 App 名称的图片。应用启动时自动显示,绘制完首帧进入 MainActivity 后自然被覆盖,不需要额外写跳转代码,也没有白屏闪烁的问题。
应用图标直接替换mipmap目录下对应密度的图片,不要用默认的机器人图标交差。还有命名规范:类名首字母大写、资源文件小写下划线、包名改成自己的域名。老师打开工程扫一眼代码,命名规整不规整,印象分已经有了差距。
6. 答辩前一天,我用这三个方法自测App
第一个方法,验证定位链路。模拟器里打开 Extended Controls,手动设置一组你所在位置的经纬度,发送后打开 App 看城市是否变化。真机上更简单,走到窗边打开 App 授权定位,看城市名是否自动更新。这两个动作都通过,定位这条链路才算真的通了。
第二个方法,验证请求和解析。开着抓包工具,或者在 HttpUtil 的onResponse里打一行日志,把返回的 JSON 打出来,核对三个值:code是不是 200、now.temp是不是正常温度、forecast数组有没有内容。我见过太多人界面显示正常,但数据其实是写死在代码里的假数据,这种在答辩时一改城市就露馅。所以要确保你看到的是真实接口返回值。
第三个方法,验证异常态。把手机开飞行模式,再打开 App,看它是否闪退。不闪退,且能显示缓存数据或一个友好的失败提示,才算过关。然后再关掉飞行模式,点重试按钮,看数据能不能恢复。这两个场景跑一遍,比功能正常更让答辩老师放心。
最后聊一个我自己的习惯:每次提交课设前,会把临时调试用的日志、写死的经纬度、多余的测试按钮全部清掉,然后把 key 和默认城市 ID 提到常量类里。这些"看不见的工作"不会出现在功能演示里,但能避免现场翻车。希望这篇文章能帮到你,祝答辩顺利。
本文还有配套的精品资源,点击获取