我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

AndroidStudio天气预报小程序源码:从网络请求到自定义折线图的完整实践

AndroidStudio天气预报小程序源码:从网络请求到自定义折线图的完整实践 简介面向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 为什么坚持原生组件而不是引入一堆依赖拿到这份源码的第一感觉是“干净”。现在的教学项目往往一上来就是网络库加解析库加图片加载库看起来高大上实际运行起来报错都分不清是哪一层出的问题。这份源码刻意保持轻量网络层用HttpURLConnectionJSON解析用官方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:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / application android:usesCleartextTraffictrue android:label天气助手 activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /applicationINTERNET权限是网络请求的前提这个必须写在Manifest里写在Gradle里没用很多新手第一次跑项目请求报错就是漏了这行。ACCESS_NETWORK_STATE用于判断当前网络是否可用在网络异常时给出提示。usesCleartextTraffictrue是给测试阶段用的因为开发时接口多半是明文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秒而不是用系统默认值。网络请求最怕的就是无限制等待用户看到界面卡住几秒就会直接杀掉App8秒连接超时加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); ListDailyForecast 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); ListString cities getCityList(); adapter new WeatherAdapter(cities); recyclerView.setAdapter(adapter); refreshAll(); } private void refreshAll() { ListString 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.AdapterWeatherAdapter.ViewHolder { private ListCityWeather 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只负责创建布局并缓存到ViewHolderonBindViewHolder负责把数据填充到控件上。这种回收复用机制是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:usesCleartextTraffictrue。生产环境不建议这么粗暴更规范的做法是配置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加LiveDataViewModel在Activity重建后仍然存活。public class WeatherViewModel extends ViewModel { private final MutableLiveDataWeatherBean weatherData new MutableLiveData(); private final WeatherRepository repository new WeatherRepository(); public LiveDataWeatherBean 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把数据状态交给LiveDataActivity里只做Observable Lite观察和数据渲染。这样App在旋转切屏时不会重复发请求代码分层也更清晰。仓库类负责“先读缓存、请求成功写缓存、失败回退缓存”的完整策略界面只管观察结果。6.2 加定位功能自动获取所在城市天气天气App没有定位能力就少了灵魂。改造思路是在刷新入口处加一个判断如果用户没有手动选择城市就用系统定位拿经纬度再请求天气接口。定位权限需要在Manifest里加ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION运行时权限要动态申请。拿到经纬度后可以调接口的城市匹配功能拿到城市名再走原来的查询链路。验证改造是否成功我一般按这个清单自测首次定位是否成功、拒绝授权后是否走默认城市、飞行模式下是否显示缓存数据、下拉刷新是否正常结束。这套源码的价值就在于所有环节都可替换想换网络层就换网络层想换存储就换存储UI不受影响。从那以后我每次接入新的天气类API都会强制走一遍这五件事接口连通性、JSON字段兼容、明文协议开关、主线程切换、缓存兜底。这五个坑基本挡住了九成低级失误验证完再谈界面美化和性能优化。希望帮到你。本文还有配套的精品资源点击获取
返回列表