AndroidStudio天气预报源码实战:从OkHttp到JSON解析的完整链路
简介这是基于Android Studio开发的天气预报小程序完整源码面向初学Android网络请求、JSON解析与UI数据绑定的开发者也适合用作课程设计或毕业设计的基础项目。项目使用Retrofit Gson访问OpenWeatherMap API利用GET与Query定义接口通过GsonConverterFactory将JSON响应映射为WeatherResponse对象再提取城市温度、湿度等字段并刷新界面控件同时代码中处理了异常、加载态、下拉刷新及本地缓存能够直接体现一个实际App从构建到运行的关键链路。压缩包共1260个文件约9.76MB包含33个Java源码、76个XML布局与配置、120个JSON数据文件以及编译生成的509个flat资源、194个dex、192个class和可直接安装的APK类型覆盖资源编译、字节码生成、打包安装各阶段便于按目录对照学习。该资源已有3876人学习下载无论想快速搭建可用天气应用还是想分析第三方API接入和Gradle配置细节都能在这份代码里找到对应实现。1. 用AndroidStudio跑通一个天气预报源码新手到改包只差三步这个项目是一份用AndroidStudio从零搭建的天气预报小程序源码适合刚学完安卓四大组件、想找一个功能完整的小项目练手的开发者。整包不依赖付费商业服务天气数据走公开接口界面包含定位天气、城市搜索、多城市管理和未来三天预报核心链路是“网络请求 → JSON解析 → UI刷新”。我最初拆这个包是想看一个小项目怎么组织网络层和界面层代码结果发现它比想象中干净请求封装、数据模型、页面展示三个层次分得清清楚楚新手照着走一遍基本就能改出属于自己的天气应用。下面按“读懂架构 → 拆透核心 → 跑通工程 → 上手改造”的顺序来展开。2. 项目架构与技术选型OkHttp、Gson与SQLite的分层思路2.1 技术栈选型为什么这个组合够用这个小程序没有引RxJava也没有上MVVM而是用了Android开发里最稳妥的一套组合OkHttp做网络请求Gson做JSON反序列化SharedPreferences存用户城市列表SQLite做天气数据缓存界面走传统XML布局加RecyclerView列表。这种选型对天气预报这种中小规模项目是合理的核心诉求是“快速请求、干净解析、稳定展示”引入协程或者RxJava反而会抬高理解门槛。尤其是对还在AndroidStudio调试阶段摸索的新手传统的回调式异步请求读起来更直白断点进去看得懂变量是怎么流动的。为什么不直接用RetrofitRetrofit确实在类型转换和注解式接口定义上更省事但多包一层convert-factory之后新手容易把“网络请求”和“接口定义”混在一起理解。OkHttp裸写反而能看清URL拼接、请求头、回调线程这些底层细节。等把这份源码吃透了再去换Retrofit你会明白它省掉的到底是什么。从架构上看项目做了三层拆分数据层负责和天气接口打交道模型层定义天气实体UI层负责展示和交互。三层之间通过方法返回值传递数据没有引入EventBus之类的消息组件。我一般会建议新手看项目时先找这三层的边界边界清楚了改功能才不容易把界面代码和网络代码搅在一起。2.2 目录结构与模块职责下面是去掉多余空目录之后的工程结构app/src/main/ ├── java/com/example/weather/ │ ├── MainActivity.java // 应用入口负责城市管理和页面切换 │ ├── WeatherActivity.java // 天气展示主界面 │ ├── CityManageActivity.java // 多城市管理页面 │ ├── adapter/ │ │ ├── CityAdapter.java // 城市列表适配器 │ │ └── WeatherAdapter.java // 多日天气预报适配器 │ ├── model/ │ │ ├── WeatherData.java // 天气响应实体 │ │ ├── Realtime.java // 实时天气内部类 │ │ └── Daily.java // 逐日预报内部类 │ ├── util/ │ │ ├── HttpUtil.java // OkHttp请求封装 │ │ └── CityStorage.java // SharedPreferences持久化 │ └── db/ │ └── CityDBHelper.java // SQLite城市缓存辅助类 ├── res/ │ ├── layout/ │ │ ├── activity_weather.xml │ │ ├── activity_city_manage.xml │ │ ├── item_city.xml │ │ └── item_daily_weather.xml │ └── drawable/ │ ├── ic_sunny.xml │ ├── ic_cloudy.xml │ ├── ic_rain.xml │ └── ic_snow.xml └── AndroidManifest.xml注意这是我把源码里实际类按职责梳理之后的呈现包名和具体文件名以你下载到的为准。这里值得重点看的是util包和db包HttpUtil管网络入口CityStorage管用户城市列表的本地读写CityDBHelper管天气数据缓存。这三处是改包时最常动的文件。drawable目录下的XML矢量图是天气图标的本地映射没有走网络图片离线也能显示这个设计后面细说。2.3 数据流设计一次天气查询怎么流转一次完整的天气查询流程是这样走的用户在天气主界面输入或选择一个城市WeatherActivity拿到城市名称后先查SQLite缓存看有没有该城市的天气数据如果有且没有过期直接展示如果缓存为空或者超过设定的刷新时间源码里写的是10分钟就走HttpUtil请求天气接口。服务端返回JSON后Gson把JSON解析成WeatherData实体适配器拿到实体里的Realtime和Daily列表逐个绑定到界面上。整个数据流是单向的没有跨模块跳转通信出了问题很好定位。这个流程里有个细节值得注意天气数据的缓存和用户城市列表走的是两条持久化通路。天气数据用SQLite存因为体量大、要按城市批量检索城市列表用SharedPreferences存一组字符串数组因为用户添加的城市最多也就十几个用键值对反而更简单。很多新手会把所有状态都塞进SharedPreferences等数据变复杂以后读写逻辑就乱套了。这份源码的划分方式是一个不错的参照结构化、量大的走SQLite量小、键值型的走SharedPreferences。2.4 生命周期与页面交互应用的主界面不是单Activity结构而是MainActivity作为容器用Fragment承载天气展示与城市管理两个页面。切换城市时先把选中的城市名写入CityStorage再通过回调通知天气Fragment从缓存或网络刷新数据。这个交互模式避免了Activity之间反复传Intent的麻烦也让多城市状态有了一个统一的管理入口。如果之后想加“删除城市后自动切换到第一个城市”的逻辑只需要在MainActivity的列表回调里判断一下选中城市是否还存在不存在就默认取列表第一个改动范围很小。3. 核心模块拆解天气数据从网络请求到界面渲染的完整链路3.1 网络层HttpUtil的封装与参数细节HttpUtil是整套代码里最值得抄的模块它把OkHttp的异步请求封装成了一个带回调的工具类调用方不用关心线程切换和请求头只需要把接口地址和回调传进去。public class HttpUtil { private static final int CONNECT_TIMEOUT 10; private static final int READ_TIMEOUT 10; private static OkHttpClient client; private static OkHttpClient getClient() { if (client null) { synchronized (HttpUtil.class) { if (client null) { client new OkHttpClient.Builder() .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) .build(); } } } return client; } public static void get(String url, Callback callback) { Request request new Request.Builder() .url(url) .addHeader(Accept, application/json) .build(); getClient().newCall(request).enqueue(callback); } }这段代码的逻辑很直白connectTimeout控制建立连接的超时时间readTimeout控制读取响应的超时时间都是10秒对天气接口这种响应速度中等的公共接口是够用的。addHeader(Accept, application/json)是告诉服务端只接受JSON格式响应避免拿到一段纯文本导致Gson解析报错。getClient方法用了双重检查锁保证全局只有一个OkHttpClient实例因为OkHttp内部维护了连接池复用同一个实例能减少重复建连的开销。这个工具类没有封装post请求因为项目里所有数据获取都是GET够用就好。值得注意的一点是OkHttp的enqueue是异步回调回调线程在工作线程而非主线程所以拿到响应结果后不能直接在回调里改UI要么用runOnUiThread要么用主线程Handler做切换。源码里用的是runOnUiThread后面碰到网络回调里直接操作TextView的情况要警惕这类线程问题。如果你把回调里拿到response后直接setTextAndroidStudio会抛Only the original thread that created a view hierarchy can touch its views这是一个很典型的新手翻车点。3.2 数据模型与Gson解析天气接口返回的JSON通常是嵌套结构外层是响应状态和城市信息里层是当天的实时温度、天气现象描述和未来几天的预报数组。这份源码没有把JSON里所有字段全部建出来只保留了界面展示真正需要的部分。这个取舍很重要因为接口里往往有大量用不到的字段模型字段建多了Gson只会在解析时多消耗一点时间但字段少了会导致运行时空指针所以“够用但不过量”是基本原则。public class WeatherData { private String city; // 城市名称 private String updateTime; // 数据更新时间 private Realtime realtime; // 实时天气 private ListDaily daily; // 逐日预报列表 public static class Realtime { private String temperature; // 当前温度 private String info; // 天气现象描述 private String humidity; // 相对湿度 } public static class Daily { private String date; // 预报日期 private String dayWeather; // 白天天气现象 private String nightWeather; // 夜间天气现象 private String high; // 最高气温 private String low; // 最低气温 } }Gson在解析时会把JSON里的字段名和Java类字段名逐一匹配所以这里的字段名要和接口返回的键名保持一致。如果接口返回的键名是tmp_highJava里用驼峰high解析出来就是null这是JSON解析最常见的一类坑。为了避免这种问题我一般会在字段上加SerializedName注解把JSON键名显式标注出来SerializedName(tmp_high) private String high;注意加了注解之后Gson优先按注解里的名称匹配JSON键这样Java字段名就可以随便取了不再受接口返回命名风格约束。解析调用本身只有一行WeatherData data new Gson().fromJson(response, WeatherData.class);。但这里有个细节容易漏response必须是JSON字符串而不是OkHttp的ResponseBody对象需要先response.body().string()转成字符串。这一步很多人会忘记导致Gson直接抛JsonSyntaxException日志看起来像JSON格式问题其实只是入参类型不对。3.3 UI展示层从数据到卡片的渲染逻辑天气主界面用的是RecyclerView加多类型布局第一项是当前城市的实时天气卡片展示温度、天气现象和湿度后面的每一项是未来几天的预报展示日期、白天夜间天气和温度区间。WeatherAdapter在onBindViewHolder里根据不同的viewType做不同的数据绑定。Override public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { if (holder instanceof TodayViewHolder) { TodayViewHolder todayHolder (TodayViewHolder) holder; todayHolder.tvTemperature.setText(data.getRealtime().getTemperature() ℃); todayHolder.tvInfo.setText(data.getRealtime().getInfo()); todayHolder.tvCity.setText(data.getCity()); } else if (holder instanceof DailyViewHolder) { DailyViewHolder dailyHolder (DailyViewHolder) holder; // position从0开始0号位置是实时天气卡片预报从1开始 WeatherData.Daily daily data.getDaily().get(position - 1); dailyHolder.tvDate.setText(daily.getDate()); dailyHolder.tvTempRange.setText(daily.getLow() ℃~ daily.getHigh() ℃); dailyHolder.tvDayWeather.setText(daily.getDayWeather()); } }position的换算是个容易出错的地方。RecyclerView的position从0开始而Daily列表下标也是从0开始但0号position被实时天气卡片占用了所以预报数据必须用position - 1去取。如果直接拿position当下标访问daily列表第一次访问的是0号位置但界面已经显示实时天气卡片数据就对不上了。改这段代码时要注意只要在item类型里多插一种卡片这个偏移量就要同步重新算一遍源码里用getItemViewType区分类型而viewType的总数决定了偏移关系。天气图标这一块源码没有用网络图片而是把天气现象描述和本地drawable资源做了映射写在了一个静态方法里。比如“晴”对应ic_sunny“多云”对应ic_cloudy“小雨”对应ic_rain适配器在绑定数据时通过getInfo()去匹配图标ID。这样做的好处是不依赖图片CDN离线也能显示缺点是图标资源只能是有限的几种新增一种天气现象时要同步在映射方法里补一个case分支。如果你想换成SVG图标直接替换drawable目录下的矢量文件即可适配器代码一行都不用动。3.4 缓存模块SQLite与SharedPreferences的配合CityDBHelper负责天气数据的缓存。每次网络请求成功后会把城市名、JSON字符串、时间戳写入SQLite查询时先按城市名取数据再用当前时间减去存储时间戳超过10分钟就视为过期重新请求网络。这个“时间戳比对”逻辑虽然简单却是缓存模块能不能真正省流量的关键。public WeatherData getCachedWeather(String cityName) { Cursor cursor db.query(weather_cache, null, city ?, new String[]{cityName}, null, null, null); if (cursor ! null cursor.moveToFirst()) { long cacheTime cursor.getLong(cursor.getColumnIndex(update_time)); if (System.currentTimeMillis() - cacheTime CACHE_EXPIRED) { String json cursor.getString(cursor.getColumnIndex(json_data)); return new Gson().fromJson(json, WeatherData.class); } } return null; }这段代码把SQLite查询、时间校验、JSON反序列化放在了一个方法里调用方不用关心缓存细节拿不到就直接走网络。注意CACHE_EXPIRED的单位是毫秒源码里写的是10 * 60 * 1000即10分钟。这里有个常见误用有人会把expired时间直接写成1000乘60忘记补两个零导致缓存永远不命中每次都走网络。我碰到过好几次这种问题排查方式是在日志里打一条缓存的命中记录用Log.d(cache, hit or miss)标记一下几秒内就能看出逻辑对不对。CityStorage则用SharedPreferences存用户添加的城市列表。它内部处理的是字符串数组的读写存的时候把List转为逗号分隔的字符串读取时按逗号拆分回List。选逗号分隔而不是JSON序列化是因为城市名里出现逗号的概率极低怎么简单怎么来。如果你的城市列表里要存更多字段比如城市ID加城市名就得换JSON或者SQLite这个要根据业务量级来定不要一上来就上重方案。4. 跑通路上的常见问题与避坑指南网络、JSON与模拟器三座山4.1 问题一请求报IOException连接不上天气接口现象模拟器里运行App点击查询后Logcat打印“Failed to connect to...”页面上没有任何天气数据也不弹任何错误提示。原因两个因素叠加导致。第一Android 9及以上默认禁止明文HTTP流量如果接口地址是http://开头系统会直接拦截根本走不到连接逻辑第二模拟器访问宿主机服务有特殊的IP规则如果接口地址写的是localhost模拟器里的localhost指向的是模拟器自己不是开发电脑连接自然失败。解决先在AndroidManifest.xml的application节点加android:usesCleartextTraffictrue允许明文流量。如果接口本身已经是https就略过这一步。再检查接口地址是公网域名还是局域网IP模拟器里不能用localhost要换成局域网IP或者直接用公网域名。注意usesCleartextTraffic这个属性上线前要关掉比较规范的做法是只在debug包开启release包走networkSecurityConfig配置只允许特定域名走明文避免数据裸奔。4.2 问题二Gson解析抛JsonSyntaxExceptionApp直接闪退现象网络请求成功日志显示拿到了JSON字符串但Gson在fromJson时抛出异常界面还没渲染就崩了堆栈指向解析代码那一行。原因绝大多数情况是模型字段和JSON键名不一致或者JSON里某个字段是null而Java模型用了基本类型int接收。比如接口返回visibility:10km模型里写的是int visibilityGson尝试把字符串转成int转不过去就抛异常。还有一种情况是JSON里明确写了nullGson把null赋给Integer没问题但赋给int会直接NPE。解决把模型里可能为空的字段全部用包装类型Integer、String定义避免拆箱空指针。对照接口返回的实际JSON逐层检查字段名必要时加SerializedName注解强制映射。还有一个小技巧在fromJson外面包一层try-catch解析失败时打印完整JSON和异常堆栈方便快速定位是哪一层字段出了问题而不是崩溃后一屏红。日志里看不到关键信息是最浪费时间的。4.3 问题三城市中文名传到URL里乱码查询不到数据现象查询“北京”返回空数据查询“beijing”却正常或者日志里看到请求URL里的城市参数是一串百分号加数字看着像加密字符串。原因HTTP URL里不能直接携带中文必须做URL编码。很多新手直接把城市名拼接进url字符串服务端收到的中文已经乱码自然匹配不到城市ID。AndroidStudio里日志可能显示正常因为Logcat对中文做了编码处理肉眼看着没问题但实际发出的请求包已经变形。解决用URLEncoder.encode(cityName, UTF-8)对中文城市名编码后再拼URL服务端按UTF-8解码就恢复正常。同样从SharedPreferences读出的城市名也要在拼接前编码。这条经验在改造成支持拼音搜索时一样适用。建议把编码逻辑固定在HttpUtil内部统一处理而不是散落在每个调用方这样后续维护不用在多个Activity里找哪里漏了编码。我后来给这套代码加城市搜索功能时就因为只在Adaper里编码、网络层漏了一步排查了快一个小时。4.4 问题四缓存不生效每次冷启动都重新请求接口现象退出App再进来天气数据仍然是重新拉取的SQLite缓存形同虚设每次打开都转圈加载。原因SQLite确实存了数据但读之前缺少“缓存是否过期”的判断或者写入时用的城市名和查询时不一致。比如写入缓存时城市名是“北京市”查询时用的是“北京”主键对不上就永远走网络。另一个容易被忽略的原因是写入缓存用的键和查询用的键用了不同来源一个来自输入框一个来自列表选择表面上都是中文名内部可能有一个带了省后缀另一个没有。解决在查询缓存时增加过期时间字段存数据时记录System.currentTimeMillis()超过10分钟视为过期城市名统一经过同一个归一化函数处理后再做主键比如统一去掉“市”“省”后缀避免“北京市”和“北京”变成两条记录。这个小改动能把冷启动的加载时间从两三秒降到几百毫秒体验提升非常明显。我给这个项目做国内城市适配时特意把所有城市名都过了一遍归一化处理列表和缓存才对齐。4.5 问题五模拟器运行正常真机却拿不到定位城市现象天气功能在模拟器里一切正常安装到手机上运行时定位天气一直转圈最后显示获取定位失败其他城市手动查询却是好的。原因模拟器里的定位坐标是模拟器自带的默认位置生态完整时不会暴露问题真机上App缺少定位权限或者没有在代码里做运行时权限申请。Android 6.0以上定位权限需要在运行时动态申请冷启动时弹窗询问用户一旦拒绝后续代码再拿locationmanager.getLastKnownLocation()就返回null。解决检查AndroidManifest.xml里有没有声明ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION再检查MainActivity里有没有在onCreate里发起运行时权限申请。源码里用ActivityCompat.requestPermissions走的是动态申请逻辑申请回调里要同时处理“同意”和“拒绝”两种分支拒绝时降级到默认城市北京不要直接崩溃。这条经验告诉我们模拟器只能验证网络和UI逻辑定位相关代码一定要上真机测。5. 从源码到真机Gradle导入、接口替换与多城市管理调试5.1 工程导入与Gradle配置拿到源码后不要直接在AndroidStudio里双击打开目录而是用File → Open选中项目根目录等待Gradle同步。第一次构建会下载依赖耗时取决于网络环境国内建议提前配好镜像仓库在build.gradle的repositories里把google()和mavenCentral()替换成阿里云镜像能省不少时间。android { compileSdkVersion 33 defaultConfig { applicationId com.example.weather minSdkVersion 21 targetSdkVersion 33 } }minSdkVersion 21意味着Android 5.0以上的设备都能跑覆盖了绝大多数存量机型。targetSdkVersion 33决定了运行时权限模型的严格程度如果你手头的测试机是Android 10以上需要注意源码里的动态权限申请代码是否完整。构建报错时先看两处一是Gradle版本号是否和本机AndroidStudio匹配二是compileSdkVersion本地是否已安装。AndroidStudio的SDK Manager里装一下对应的SDK Platform即可一般不用动代码就能解决。5.2 接口地址与KEY的替换位置源码里的天气接口地址和私钥参数集中在HttpUtil或者一个独立的Constants类里改包前需要替换成你自己申请到的接口凭证。常见做法是“城市ID加请求key”的方式调用公共天气服务拿到JSON后字段名和模型里的SerializedName一一对应。替换时注意三个地方接口域名、请求路径、key参数名有的服务商把key叫作“apikey”或者“token”参数名不同会导致401。我的建议是别在第一遍就急着跑通全部功能先只配置好一个城市验证网络请求和数据展示确认链路通了之后再逐个打开多城市管理。多城市管理的入口在MainActivity底部有一个城市列表长按可以删除点击加号会弹输入框添加城市。添加城市的逻辑要注意输入框里可能是中文名也可能是拼音源码里没有做中英文自动识别需要靠接口自身的匹配能力。如果返回空列表多半是城市名编码或者城市ID传参不对对照第4章的排查思路走一遍。这个模块改起来最费心思的是城市列表的增删时机添加成功后要刷新天气Fragment当前展示的城市被删除时要自动切换源码里这块处理得比较顺值得学习它的事件回调写法。5.3 真机调试时的网络定位真机调试比模拟器多一个坑手机和电脑不在同一网络时访问内网服务会失败用公网接口则不受影响。所以改包阶段建议先用公网接口等到了联调阶段再用内网服务。测试时用AndroidStudio的Logcat过滤标签“OkHttp”可以看到每次请求的耗时和状态码。403多半是key过期或参数名不对404是城市ID不存在或接口路径错误500是服务端问题重试即可。如果日志里看不到网络请求先确认是不是Android 9的明文流量限制把请求拦了这个拦在系统层面Logcat往往只打一行W/System.err非常隐蔽。还有一种情况接口通了但JSON解析失败页面没有数据此时断点打在Gson解析那一行看捕获的异常信息比看堆栈更直观。真机上调试定位失败时记得在开发者选项里把“仅定位模拟位置”关掉有些机型默认开启这个选项会导致真机一直在跑模拟坐标。这个小开关排查了我一个下午记录下来提醒大家先查这个。5.4 构建常见报错与处理Gradle同步报错里最常见的是依赖下载失败报错信息形如Could not resolve io.reactivex。这种问题要么是网络无法访问maven仓库要么是仓库地址被墙。解决方式是换镜像源或者下载源码包里自带的libs目录下的jar包手动引入。这份天气预报源码依赖项不算多核心就是OkHttp、Gson、RecyclerView实在不行可以把gradle依赖改成本地jar形式减少外网依赖。另一个高频报错是Manifest merger failed原因是本地某个依赖库的minSdkVersion比项目的高。比如项目里写minSdkVersion 21某个依赖要求24合并时直接失败。解决方式是看报错提示具体是哪个库把项目的minSdkVersion调高到依赖要求的版本或者去掉那个库换一个低版本兼容的。这个思路适用于所有Android依赖冲突不只是这个天气项目。6. 二次开发思路给天气小程序加一个生活指数卡片把核心链路跑通后可以试着给这个天气小程序加一个“生活指数”模块这是最贴合天气预报场景的改造方向也是完整覆盖“数据模型、网络请求、UI展示”三个层面的练习。实现思路不复杂在WeatherData模型里增加一个LifeIndex内部类字段包括穿衣建议、紫外线强度、运动指数网络层在请求时追加一个可选的接口参数或者在原JSON里直接取服务端已返回的指数字段UI层在RecyclerView的item类型里增加一种“指数卡片”放在实时天气卡片下方。public static class LifeIndex { SerializedName(dressing) private String dressing; // 穿衣指数 SerializedName(uv) private String uv; // 紫外线强度 SerializedName(sport) private String sport; // 运动指数 }适配器里新增一个LifeViewHolder绑定三条文本代码只有十几行。关键点在于index偏移量RecyclerView当前位置多了一项需要把“实时天气、指数卡片、逐日预报”三类item的下标换算重新算一遍否则逐日预报会跟着错位。这个改造的核心难点不在写新代码而在理解原有的position偏移逻辑正好逼着你把上一章的代码真正读懂。还有一条我从这个项目上得来的血泪经验每次改完模型字段记得先用接口返回的真实JSON做一次Gson解析验证而不是只在界面上看效果。因为界面上没显示出来的字段解析失败不会崩但隐藏的字段错误会在下一次调用时变成空指针。从那以后我每次做完接口对接都强制走一遍“抓JSON → 跑Gson → 看日志”三步确认无误再回到界面验证这个习惯让我少调了很多次“看起来正常但一换城市就闪退”的bug。希望帮到你。本文还有配套的精品资源点击获取