简介:面向Android开发初学者的天气预报小程序完整工程源码,基于Android Studio构建,演示如何从零创建项目、集成Retrofit与Gson,并通过OpenWeatherMap等第三方接口获取实时天气,最终将城市、温度、湿度、天气状况绑定到界面,适合课程设计、毕业设计或入门练手。压缩包约9.76MB,共1260个文件,以flat、dex、class、json、xml、java等类型为主,其中java源文件包含网络接口、响应模型和Activity逻辑,xml对应界面布局与清单配置,json为接口返回示例及构建配置,flat、dex等为编译中间产物和APK相关文件,整体结构完整,可直接导入Android Studio查看运行。已有3876人学习下载。资源覆盖网络请求接口定义、异步回调、界面数据绑定、下拉刷新、错误处理与本地缓存等关键环节,同时提供多城市切换、天气图标展示、加载进度提示等扩展思路,帮助读者从搭建工程到运行调试一气呵成,摸清安卓网络编程与项目组织方式。
1. AndroidStudio天气预报小程序源码:先弄清楚它帮你解决什么问题
很多人第一次想完整跑一个安卓App,卡住的不是语法,而是“数据从哪来”。AndroidStudio里这份天气预报小程序源码,解决的就是这件事:它把一次网络请求、一段JSON解析、一张RecyclerView列表和一个自定义折线图串成了完整闭环。你启动App,选择一个城市,就能看到当前温度、湿度、风力,以及未来几天的最高温和最低温趋势。整个工程用Java和XML布局写成,没有花哨的第三方依赖,核心网络用原生HTTP请求完成,本地用SQLite做缓存,适合刚学完Android基础、想动手做第一个完整App的读者,也适合想快速搭建一套客户端数据链路做演示的开发者。需要先说清,这里的“小程序”指功能聚焦、体积可控的独立App工程,不是嵌入聊天软件里那种需要宿主运行的内容,它导入AndroidStudio就能直接构建运行。
2. 项目架构与目录:一条数据从网络到界面的完整路径
2.1 为什么坚持原生组件而不是引入一堆依赖
拿到这份源码的第一感觉是“干净”。现在的教学项目往往一上来就是网络库加解析库加图片加载库,看起来高大上,实际运行起来报错都分不清是哪一层出的问题。这份源码刻意保持轻量,网络层用HttpURLConnection,JSON解析用官方org.json包,列表用RecyclerView,图表用自定义View手绘,全程没有引入第三方框架。
这带来的直接好处是,你打开任意一个类都能从头看到尾,整条链路的依赖关系十分钟就能画清楚。从学习的角度讲,原生实现能暴露很多平时被框架藏住的问题:连接超时怎么设置、响应码怎么判断、JSONObject怎么处理缺字段、缓存数据怎么落地。这些问题你亲手踩过一遍,后面换任何网络库、解析库都不慌,因为你已经知道底层在做什么了。商用项目当然可以换更成熟的库,但理解基础永远比堆依赖更重要。
2.2 工程目录与核心类职责
源码包里的代码组织得很清晰,主工程结构如下:
app/src/main/java/com/example/weather/ ├── MainActivity.java # 唯一入口Activity,负责组装UI、触发请求、刷新列表 ├── model/ │ └── WeatherBean.java # 天气实体,包含当前天气和未来几天预报 ├── network/ │ └── HttpWeatherFetcher.java # 原生HTTP请求封装,返回JSON字符串 ├── cache/ │ └── WeatherCacheDB.java # SQLite缓存,网络失败时读取 ├── adapter/ │ └── WeatherAdapter.java # RecyclerView适配器,绑定天气卡片 └── view/ └── TempTrendView.java # 自绘温度折线图,展示7天温度走势MainActivity是入口,负责把其它模块串起来。WeatherBean是数据模型,承载解析后的天气数据。HttpWeatherFetcher只管网络请求,把结果转换成字符串返回。WeatherCacheDB负责落库和读取。WeatherAdapter把数据绑定到每个城市的卡片上。TempTrendView是自定义View,画温度折线图。每个类的职责都很单一,没有出现一个类既做网络请求又做界面刷新的混乱写法。配合res/layout/activity_main.xml和item_weather.xml两个布局文件,整个工程的代码量控制在千行级别。
2.3 Manifest权限与Gradle依赖配置
先看AndroidManifest.xml里最关键的配置:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application android:usesCleartextTraffic="true" android:label="天气助手"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application>INTERNET权限是网络请求的前提,这个必须写在Manifest里,写在Gradle里没用,很多新手第一次跑项目请求报错就是漏了这行。ACCESS_NETWORK_STATE用于判断当前网络是否可用,在网络异常时给出提示。usesCleartextTraffic="true"是给测试阶段用的,因为开发时接口多半是明文HTTP地址,Android 9及以上默认禁止明文流量,不开这个开关请求会直接被拦截。
app/build.gradle里的依赖非常克制,只用到了官方组件库:
dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.recyclerview:recyclerview:1.3.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' }recyclerview负责城市列表的展示,swiperefreshlayout提供下拉刷新能力,appcompat提供向后兼容的Activity基类。没有引入网络库和图片库,因为这份源码的初衷就是让你看清网络请求和JSON解析的原始面貌,等你理解透彻了,再按自己的技术偏好替换成开源库不迟。targetSdkVersion如果设置到28以上,明文流量默认是禁用的,这个在第五章节会单独展开讲。
3. 核心代码拆解:网络请求、JSON解析与缓存
3.1 网络请求层:用HttpURLConnection做一个小而稳的请求封装
网络层是这份源码里最值得读的部分。先说明一个背景:天气数据一般来自第三方接口,传入城市参数,返回固定结构的JSON。源码把请求逻辑封装在HttpWeatherFetcher里,核心方法如下:
public class HttpWeatherFetcher { private static final int CONNECT_TIMEOUT = 8000; private static final int READ_TIMEOUT = 8000; public static String getJson(String apiUrl) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection(); try { conn.setRequestMethod("GET"); conn.setConnectTimeout(CONNECT_TIMEOUT); conn.setReadTimeout(READ_TIMEOUT); conn.setRequestProperty("Accept", "application/json"); int code = conn.getResponseCode(); if (code != 200) { throw new IOException("HTTP状态异常: " + code); } BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), "UTF-8")); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line); } return sb.toString(); } finally { conn.disconnect(); } } }这段代码有三个设计点值得留意。第一,超时时间设置了8秒,而不是用系统默认值。网络请求最怕的就是无限制等待,用户看到界面卡住几秒就会直接杀掉App,8秒连接超时加8秒读取超时是移动端比较常规的配置。第二,请求方式用了GET,天气查询属于典型的只读操作,不需要POST传参数。第三,finally里做了disconnect(),避免连接资源泄漏,这个问题在长时间运行的应用里会被无限放大。
请求方法是静态方法,调用方需要自己放到子线程里执行。为什么必须子线程?Android从入门到进阶都绕不开这条规则:网络操作不允许出现在主线程,否则会触发NetworkOnMainThreadException,更严重的直接ANR。常见做法是用Executors线程池或新建Thread,拿到结果后通过runOnUiThread切回主线程更新UI。
3.2 JSON解析:把响应字符串变成可靠的WeatherBean
接口返回的JSON结构大致如下,这里把字段做了简化处理:
{ "code": 200, "data": { "city": "示例城市", "lastUpdate": "2024-01-01 12:00", "current": {"temp": 3, "humidity": 30, "wind": "西北风3级"}, "forecast": [ {"date": "2024-01-01", "high": 5, "low": -3, "type": "晴"}, {"date": "2024-01-02", "high": 2, "low": -6, "type": "多云"} ] } }data字段下分两部分,current放当前天气数据,forecast数组放未来几天的预报。源码里的WeatherBean解析方法用的是官方JSONObject,核心逻辑如下:
public static WeatherBean parseWeather(String json) { if (json == null || json.isEmpty()) return null; try { JSONObject root = new JSONObject(json); JSONObject data = root.optJSONObject("data"); if (data == null) return null; WeatherBean bean = new WeatherBean(); bean.setCity(data.optString("city", "未知城市")); bean.setUpdateTime(data.optString("lastUpdate", "")); JSONObject current = data.optJSONObject("current"); if (current != null) { bean.setCurrentTemp(current.optInt("temp", 0)); bean.setHumidity(current.optInt("humidity", 0)); bean.setWind(current.optString("wind", "")); } JSONArray forecast = data.optJSONArray("forecast"); List<DailyForecast> list = new ArrayList<>(); if (forecast != null) { for (int i = 0; i < forecast.length(); i++) { JSONObject day = forecast.getJSONObject(i); list.add(new DailyForecast( day.optString("date", ""), day.optInt("high", 0), day.optInt("low", 0), day.optString("type", "晴") )); } } bean.setDailyForecasts(list); return bean; } catch (JSONException e) { Log.e("parseWeather", "JSON解析失败", e); return null; } }这里的解析方式很有讲究。用了optString、optInt而不是getString、getInt,区别在于opt系列方法在字段缺失时返回默认值,不会抛异常。天气接口偶尔会出现某个字段缺失的情况,比如夜间数据里没有wind,如果用get系列直接就会崩。解析失败时返回null而不是抛异常,由调用方决定下一步动作,比如走缓存或者重新请求,这就是一个合格的数据层该有的容错设计。DailyForecast这个内部类只承载一条一天的预报数据,避免直接用二维数组或者两个平行数组传递,结构更清晰。
3.3 缓存策略:SQLite让App离线时也有后悔药
天气数据有很强的时效性,但用户也可能在电梯、地下车库里突然打开App。源码用SQLite做了一层缓存,数据库实现很简单:
public class WeatherCacheDB extends SQLiteOpenHelper { public static final String DB_NAME = "weather_cache.db"; public static final int DB_VERSION = 1; public WeatherCacheDB(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE cache (" + "city TEXT PRIMARY KEY, " + "json TEXT NOT NULL, " + "update_time INTEGER NOT NULL)"); } public void saveWeather(String city, String json) { SQLiteDatabase db = getWritableDatabase(); ContentValues values = new ContentValues(); values.put("city", city); values.put("json", json); values.put("update_time", System.currentTimeMillis()); db.insertWithOnConflict("cache", null, values, SQLiteDatabase.CONFLICT_REPLACE); } public String loadWeather(String city) { SQLiteDatabase db = getReadableDatabase(); Cursor cursor = db.query("cache", new String[]{"json"}, "city = ?", new String[]{city}, null, null, null); String json = null; if (cursor.moveToFirst()) { json = cursor.getString(0); } cursor.close(); return json; } }表结构就三个字段:city做主键,保证每个城市只存一份数据;json存整个接口响应原文;update_time存时间戳,方便后续做过期判断。saveWeather使用insertWithOnConflict加CONFLICT_REPLACE,城市已存在时直接覆盖,不需要先查再更新。缓存的是原始JSON字符串而不是解析后的对象,这样换接口版本或者加字段时,旧的缓存数据依然能被新解析器读取,兼容性更好。
加载逻辑是:网络请求成功就saveWeather再刷新UI,失败则loadWeather尝试读取缓存,有缓存就用旧数据展示,没有就提示用户检查网络。这套策略不复杂,但足以保证App在弱网环境下不会白屏,属于实用主义的做法。
4. UI层实战:RecyclerView、天气卡片与自定义折线图
4.1 MainActivity如何组装主界面
Android界面搭建的老路径是setContentView加findViewById。MainActivity的职责是初始化列表、启动数据加载、监听下拉刷新。核心骨架如下:
public class MainActivity extends AppCompatActivity { private RecyclerView recyclerView; private SwipeRefreshLayout swipeRefreshLayout; private WeatherAdapter adapter; private WeatherCacheDB cacheDB; private ExecutorService executor; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); recyclerView = findViewById(R.id.rv_city_list); recyclerView.setLayoutManager(new LinearLayoutManager(this)); swipeRefreshLayout = findViewById(R.id.swipe_refresh); swipeRefreshLayout.setOnRefreshListener(() -> refreshAll()); cacheDB = new WeatherCacheDB(this); executor = Executors.newFixedThreadPool(2); List<String> cities = getCityList(); adapter = new WeatherAdapter(cities); recyclerView.setAdapter(adapter); refreshAll(); } private void refreshAll() { List<String> cities = getCityList(); for (String city : cities) { executor.execute(() -> { try { String json = HttpWeatherFetcher.getJson(buildApiUrl(city)); WeatherBean bean = WeatherParser.parseWeather(json); if (bean != null) { cacheDB.saveWeather(city, json); runOnUiThread(() -> adapter.updateItem(city, bean)); } } catch (IOException e) { String cached = cacheDB.loadWeather(city); if (cached != null) { WeatherBean old = WeatherParser.parseWeather(cached); runOnUiThread(() -> adapter.updateItem(city, old)); } } finally { runOnUiThread(() -> swipeRefreshLayout.setRefreshing(false)); } }); } } }RecyclerView必须先设置LayoutManager,这一点新手特别容易漏。设了LinearLayoutManager,列表才会纵向排列。executor是固定两个线程的线程池,同时发起两个城市的请求,避免串行排队拖慢速度。runOnUiThread把数据更新切回主线程,因为adapter.updateItem涉及界面刷新,不能在子线程里直接操作。下拉刷新时调用同样的refreshAll逻辑,保证刷新和首次加载走同一条数据路径。
4.2 WeatherAdapter与天气卡片复用
列表的每一项是一张天气卡片,包含城市名、当前温度、风力、更新时间,底部嵌一个温度趋势折线图。适配器核心逻辑:
public class WeatherAdapter extends RecyclerView.Adapter<WeatherAdapter.ViewHolder> { private List<CityWeather> data = new ArrayList<>(); @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) { CityWeather item = data.get(position); holder.tvCity.setText(item.city); holder.tvTemp.setText(item.bean.getCurrentTemp() + "℃"); holder.tvWind.setText(item.bean.getWind()); holder.tvUpdate.setText("更新于 " + item.bean.getUpdateTime()); holder.trendView.updateData(item.bean.getDailyForecasts()); } public void updateItem(String city, WeatherBean bean) { for (int i = 0; i < data.size(); i++) { if (data.get(i).city.equals(city)) { data.get(i).bean = bean; notifyItemChanged(i); return; } } } static class ViewHolder extends RecyclerView.ViewHolder { TextView tvCity, tvTemp, tvWind, tvUpdate; TempTrendView trendView; ViewHolder(View itemView) { super(itemView); tvCity = itemView.findViewById(R.id.tv_city); tvTemp = itemView.findViewById(R.id.tv_temp); tvWind = itemView.findViewById(R.id.tv_wind); tvUpdate = itemView.findViewById(R.id.tv_update); trendView = itemView.findViewById(R.id.trend_view); } } }onCreateViewHolder只负责创建布局并缓存到ViewHolder,onBindViewHolder负责把数据填充到控件上。这种回收复用机制是RecyclerView的核心优势,Item滑出屏幕后不会反复创建视图。updateItem做了个简单的按城市名查找并notifyItemChanged的局部刷新,比notifyDataSetChanged全量刷新开销小很多,也不用重新绑整个列表。每次更新数据时同时把DailyForecasts传给TempTrendView,折线图会跟随下拉刷新实时重绘。
4.3 手写TempTrendView:温度折线图的绘制原理
图表是天气App里最直观的展示形式。源码没有引入图表库,而是在TempTrendView的onDraw里直接绘制,核心代码:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (latitudes == null || latitudes.length < 2) return; float left = dp(16); float right = getWidth() - dp(16); float top = dp(24); float bottom = getHeight() - dp(24); int max = Integer.MIN_VALUE, min = Integer.MAX_VALUE; for (int t : latitudes) { max = Math.max(max, t); min = Math.min(min, t); } if (max == min) max = min + 1; float stepX = (right - left) / (latitudes.length - 1); Paint linePaint = new Paint(Paint.ANTI_ALIAS_FLAG); linePaint.setColor(0xFF4FC3F7); linePaint.setStrokeWidth(dp(2)); linePaint.setStyle(Paint.Style.STROKE); Path path = new Path(); for (int i = 0; i < latitudes.length; i++) { float x = left + stepX * i; float y = top + (bottom - top) * ((max - latitudes[i]) / (float) (max - min)); if (i == 0) path.moveTo(x, y); else path.lineTo(x, y); } canvas.drawPath(path, linePaint); }绘制前先算出温度数组的最大值和最小值,把这两个值映射到View的上下边界,中间的每个温度点按比例计算Y坐标,这样不管温度是零下20度还是零上40度,折线都会完整落在View范围内。stepX是每两个点之间的水平间距,用(右边界减左边界)除以(点数减一),保证第一个点落在左边界,最后一个点落在右边界。dp()方法做密度转换,保证在不同屏幕像素密度的设备上,线条粗细和留白物理尺寸一致。
手绘折线的优势在于轻量和完全可控。依赖图表库虽然省事,但为了画一条简单的温度曲线引入几十个方法的重型库,在这个项目里就是过度设计。想加渐变填充、顶点圆点、温度标注,直接在onDraw里扩展就行,这个自由度是第三方库给不了的。
5. 避坑:跑这个天气源码我踩过的五个坑
5.1 模拟器上请求不通,一直抛Connection refused
现象:模拟器里启动App,转圈很久后提示加载失败,Logcat里报Connection refused。真机同一条接口却能正常返回。
原因:Android模拟器里写localhost或127.0.0.1,指向的是模拟器自己,而不是你的开发机。模拟器有自己的网络栈,要访问宿主机的服务,必须用专用的别名地址。
解决:把接口地址里的Host换成10.0.2.2,这是Android模拟器给宿主机预留的映射地址。真机调试时不能这么写,需要改成开发机的局域网IP。我一般先在模拟器里用adb shell配合curl测一下接口连通性,确认通了再回App里跑,减少问题排查成本。
5.2 接口返回正常,Log有数据,界面却一片空白
现象:打印日志能看到JSON内容,解析也没报错,但RecyclerView就是一个空列表,一点内容都不显示。
原因:这个坑最有玄学气质。大多数情况是RecyclerView设置了Adapter但忘了设置LayoutManager,或者布局文件里RecyclerView的高度是wrap_content且父布局给了它零空间。列表没LayoutManager就不会测量和摆放子项,高度为0自然什么都看不见。
解决:在代码里补上recyclerView.setLayoutManager(new LinearLayoutManager(this)),检查布局高度用match_parent或0dp加权重。查日志时别只盯着网络层,界面层的配置错误往往更隐蔽。
5.3 Android 9以上明文HTTP被静默拦截
现象:接口地址是http://开头的明文地址,Android 9及以上系统的真机请求直接失败,但模拟器上旧版本系统跑得好好的,头部还没加https。
原因:targetSdkVersion到28以后,系统默认禁止应用使用明文流量,拦截方式很隐蔽,没有弹窗也没有明确报错,只是请求直接失败。
解决:测试阶段在AndroidManifest.xml的<application>上加android:usesCleartextTraffic="true"。生产环境不建议这么粗暴,更规范的做法是配置networkSecurityConfig,只对特定域名放行明文流量。
5.4 中文城市名在请求URL里变成乱码
现象:接口文档明确说支持中文城市名,但请求返回400或找不到城市,看一眼日志发现URL里的中文变成了乱码。
原因:手动拼接URL时直接把中文字符放进去了,网络请求要求URL必须是ASCII字符集,中文必须经过编码后再拼接。
解决:对城市名做一次URLEncoder.encode(city, "UTF-8"),再把编码后的字符串拼到URL上。有一点要小心,URLEncoder会把空格编码成+,而URL里的空格一般要编码成%20,所以编码后最好替换一下:encoded.replace("+", "%20")。不同接口服务对编码格式要求不一样,有的要UTF-8,有的要GBK,务必先看接口文档。
5.5 一进App就卡顿,几秒后弹ANR
现象:首次进入主界面卡死,点屏幕没反应,过一会儿弹出“应用无响应”对话框。
原因:把网络请求直接写在了onCreate里,网络操作在子线程,但UI更新还在主线程。onCreate里同步阻塞等网络结果,主线程就被堵死了。
解决:网络请求全部放到线程池或独立线程,UI更新通过runOnUiThread切回主线程。我自己习惯在创建线程池时设置一个shutdown时机,防止Activity销毁后线程还在跑,造成内存泄漏。用Lifecycle或协程的viewModelScope是更现代的解法,后面改造章节会提到。
6. 二次开发:从这版源码出发的改造思路
6.1 把网络层从线程池升级为ViewModel加LiveData
原版代码用线程池加runOnUiThread,简单但有个隐患:Activity旋转重建时,线程还在飞,结果回来时往旧的界面实例上写数据,容易出错。现在官方推荐的写法是ViewModel加LiveData,ViewModel在Activity重建后仍然存活。
public class WeatherViewModel extends ViewModel { private final MutableLiveData<WeatherBean> weatherData = new MutableLiveData<>(); private final WeatherRepository repository = new WeatherRepository(); public LiveData<WeatherBean> getWeather(String city) { loadFromRepository(city); return weatherData; } private void loadFromRepository(String city) { new Thread(() -> { WeatherBean bean = repository.fetchWeather(city, cacheDB); weatherData.postValue(bean); }).start(); } }改造时把MainActivity里的网络逻辑抽到WeatherRepository,把数据状态交给LiveData,Activity里只做Observable Lite观察和数据渲染。这样App在旋转切屏时不会重复发请求,代码分层也更清晰。仓库类负责“先读缓存、请求成功写缓存、失败回退缓存”的完整策略,界面只管观察结果。
6.2 加定位功能:自动获取所在城市天气
天气App没有定位能力就少了灵魂。改造思路是在刷新入口处加一个判断:如果用户没有手动选择城市,就用系统定位拿经纬度,再请求天气接口。定位权限需要在Manifest里加ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION,运行时权限要动态申请。拿到经纬度后可以调接口的城市匹配功能,拿到城市名再走原来的查询链路。
验证改造是否成功,我一般按这个清单自测:首次定位是否成功、拒绝授权后是否走默认城市、飞行模式下是否显示缓存数据、下拉刷新是否正常结束。这套源码的价值就在于所有环节都可替换,想换网络层就换网络层,想换存储就换存储,UI不受影响。
从那以后,我每次接入新的天气类API,都会强制走一遍这五件事:接口连通性、JSON字段兼容、明文协议开关、主线程切换、缓存兜底。这五个坑基本挡住了九成低级失误,验证完再谈界面美化和性能优化。希望帮到你。
本文还有配套的精品资源,点击获取