简介:这个Android天气应用项目用Java开发,适合刚入门的开发者上手练习。它通过多线程发起HTTP请求获取实时天气数据,兼容XML和JSON两种常见接口格式,并能正确解析展示。城市列表存在本地SQLite数据库里,支持添加、删除、查询和默认城市设置;用户偏好和临时缓存则用SharedPreferences保存。应用内置后台自动更新机制,可按设定时间间隔静默拉取最新天气,无需手动刷新。整个工程结构清晰,包含完整的Gradle构建脚本、Android Studio项目配置、开源许可证文件(LICENSE)、详细README说明文档,以及用于本地调试的简易Python脚本(app.py)和依赖清单(requirements.txt)。资源包里还附带了预置的weather.db示例数据库和基础HTML页面(index.html),方便快速验证数据存储与展示效果。所有代码遵循标准Android开发规范,覆盖网络通信、异步处理、本地持久化、后台任务调度等关键开发场景。
1. 项目概述:一个“能跑、能学、能改”的Android天气练手工程
我带过不少刚从Java基础转进Android开发的新人,他们最常卡在同一个地方:书上讲Activity生命周期很清晰,但一写个天气App,HTTP请求发出去就卡死主线程;SQLite建表语法背得滚瓜烂熟,可真要实现“添加城市→查数据库→设为默认→下次启动自动加载”,逻辑就全乱套了。这个天气项目,就是我当年踩着坑、熬着夜、反复重构三版后,给徒弟们搭出来的“第一块真实砖”。它不追求炫酷动效或Material Design 3的最新规范,而是把Android开发里最常碰、最容易错、又必须掌握的五个硬骨头——多线程网络请求、双格式数据解析、本地数据库增删改查、轻量级键值存储、后台定时任务——全都拧进一个能真机运行、能调试、能改、能扩展的小应用里。
你打开它,第一眼看到的是一个干净的主界面:顶部显示当前城市和温度,中间是天气状况图标与文字描述,底部是“刷新”按钮和“城市管理”入口。点进去,能新增城市、删掉不用的、拖拽排序、设为默认。退出再打开,上次选的城市还在,温度数据也没丢——这些都不是魔法,每一行代码都在app/src/main/java/里摊开给你看。更关键的是,它没用任何第三方网络库(比如OkHttp封装过的Retrofit),所有HTTP连接、输入流读取、字符编码处理,全是原生HttpURLConnection手写;XML解析不用SimpleXml,JSON不用Gson,全部基于Android SDK自带的XmlPullParser和org.json包实现。为什么?因为新手最需要的不是“怎么最快做出效果”,而是“当网络超时、JSON字段缺失、XML命名空间错乱、SQLite事务没提交、AlarmManager时间不准时,我该去哪一行断点、看哪个变量、改哪段逻辑”。这个项目,就是那个能让你对着Logcat报错信息,顺藤摸瓜找到根源的“活体教材”。
它适合谁?如果你刚学完Java集合和线程基础,正在Android Studio里第一次创建Empty Activity,对AsyncTask已被废弃感到困惑,对WorkManager和AlarmManager的区别还停留在概念层面,那它就是为你量身定做的。它不假设你懂协程,不依赖Kotlin DSL,所有代码都是标准Java 8语法,Gradle配置也锁定在AGP 7.4兼容范围,连build.gradle里每个插件版本号都经过实测——我试过在2021年那台老款MacBook Pro上,用Android Studio Arctic Fox直接导入就能编译通过,连一个红色波浪线都不报。资源包里那个weather.db文件,不是空壳,是预置了北京、上海、广州三座城市的完整SQLite数据库,你用DB Browser for SQLite打开就能看到cities表结构和weather_cache表里的模拟数据;那个app.py脚本,也不是摆设,它起一个本地HTTP服务,返回模拟的XML和JSON天气响应,让你在没联网、没申请API Key的情况下,也能完整走通“发起请求→解析数据→更新UI”的全流程。这不是一个展示用的Demo,而是一个你随时可以拆开、替换、调试、甚至拿去交课程设计的“生产级学习载体”。
2. 整体架构设计与技术选型逻辑
2.1 为什么坚持原生Java + 原生组件?
现在满屏都是“Kotlin+Jetpack Compose+Retrofit+Coroutines”的教程,但对初学者来说,这就像教人游泳前先塞给他一套水肺装备。这个项目选择纯Java,并非守旧,而是基于三个不可回避的现实:
第一,错误溯源成本最低。当你用Retrofit写一句api.getWeather().enqueue(...),出错了,Logcat里打出来的堆栈可能跨越5层代理类、3个线程切换、2个泛型擦除后的类型转换。而用HttpURLConnection,connection.connect()失败,异常直接抛在你写的那一行;InputStreamReader读取时编码不对,MalformedInputException的cause里清清楚楚写着“UTF-8 byte 0xe2 at position 123”。我带过的学员里,有7个人是在把Retrofit换成原生连接后,才真正理解了“网络请求本质就是一次TCP握手+HTTP报文发送+响应流读取”这个底层事实。
第二,Android SDK演进路径最清晰。AlarmManager在Android 6.0引入Doze模式后行为剧变,JobScheduler在Android 5.0才出现,WorkManager更是直到2019年才稳定。这个项目采用AlarmManager + BroadcastReceiver组合实现后台更新,不是因为它最好,而是因为它最“古老”、最“标准”、文档最全、兼容性最广(从Android 4.0到14全支持)。你调试时,在onReceive()里打个断点,看Intent.getAction()是不是"com.example.weather.UPDATE",比研究WorkRequest的状态机要直观十倍。等你把这个跑通了,再去看WorkManager的Constraints和BackoffPolicy,理解会深刻得多。
第三,知识迁移壁垒最小。Java语法、线程模型、IO流操作、SQL语法——这些是跨平台的基础能力。今天你搞懂ExecutorService如何管理线程池,明天写Spring Boot的异步任务就只需换一个注解;今天你亲手写SQLiteOpenHelper的onUpgrade()逻辑处理数据库版本迁移,明天用Room做@Database升级就只是把SQL语句搬到注解里。而过度依赖某个框架的“魔法语法”,反而会模糊掉这些底层契约。
2.2 多线程方案:为何选用ThreadPoolExecutor而非AsyncTask?
AsyncTask在Android 11(API 30)被彻底标记为@Deprecated,但很多老教程还在用。这个项目直接跳过它,采用ThreadPoolExecutor手动管理线程池,理由很实在:
-
可控性:
AsyncTask内部线程池是静态单例,所有AsyncTask共享同一组线程。你在一个地方execute()一个耗时任务,另一个地方execute()一个网络请求,它们可能被塞进同一个线程排队执行,导致UI卡顿。而ThreadPoolExecutor你可以明确指定核心线程数(corePoolSize=2)、最大线程数(maxPoolSize=4)、队列容量(workQueue=new LinkedBlockingQueue<>(10))。我在NetworkManager.java里定义了一个专用线程池:
java private static final ThreadPoolExecutor NETWORK_EXECUTOR = new ThreadPoolExecutor( 2, 4, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10), new ThreadFactory() { private final AtomicInteger mCount = new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(r, "Weather-Network-Thread-" + mCount.getAndIncrement()); } });
这样,天气请求永远走这个池,图片下载可以另起一个池,互不干扰。 -
生命周期绑定:
AsyncTask的onPostExecute()回调在Activity销毁后仍可能执行,导致NullPointerException。而ThreadPoolExecutor配合WeakReference<Activity>使用,可以在doInBackground()结束后,安全地检查Activity是否还存活再更新UI。WeatherActivity.java里是这么写的:
java private static class WeatherLoadTask extends AsyncTask<Void, Void, WeatherData> { private final WeakReference<WeatherActivity> activityRef; private WeatherLoadTask(WeatherActivity activity) { this.activityRef = new WeakReference<>(activity); } @Override protected WeatherData doInBackground(Void... voids) { // 纯网络和解析逻辑,无UI操作 return NetworkManager.fetchWeatherData(); } @Override protected void onPostExecute(WeatherData result) { WeatherActivity activity = activityRef.get(); if (activity != null && !activity.isFinishing() && !activity.isDestroyed()) { activity.updateUI(result); // 安全更新 } } } -
调试友好:线程名带标识(
Weather-Network-Thread-1),在Android Studio的Debug视图里,一眼就能区分哪个线程在跑网络,哪个在跑数据库,哪个在解析XML。AsyncTask的线程名是AsyncTask #1这种无意义编号,排查并发问题时如同雾里看花。
2.3 数据解析双轨制:XML与JSON并存的设计深意
天气API服务商五花八门,中国气象局官网用XML,OpenWeatherMap用JSON,有些小众接口甚至混用(头信息说JSON,body却是XML)。硬性要求只支持一种,等于把项目锁死在单一数据源。这个项目实现双解析,不是为了炫技,而是教会你一个核心工程思维:协议适配层(Protocol Adapter)。
整个解析逻辑被抽离成独立的ParserFactory类:
public class ParserFactory {
public static WeatherParser createParser(String contentType) {
if (contentType.contains("xml") || contentType.contains("application/xml")) {
return new XmlWeatherParser();
} else if (contentType.contains("json") || contentType.contains("application/json")) {
return new JsonWeatherParser();
} else {
throw new IllegalArgumentException("Unsupported content type: " + contentType);
}
}
}
NetworkManager在拿到HttpURLConnection的getContentType()后,动态创建对应解析器。XmlWeatherParser用XmlPullParser,重点处理命名空间(http://weather.example.com/ns)和属性提取(<temperature unit="celsius">25</temperature>);JsonWeatherParser用JSONObject,重点处理嵌套对象(weather.main.temp)和数组遍历(weather.forecast[0].day)。两者最终都统一输出WeatherData POJO对象,上层业务代码完全感知不到底层差异。
这种设计带来的好处是显性的:当你想把数据源从OpenWeatherMap切换到中国气象局时,只需修改NetworkManager里构造URL的逻辑,其他所有代码——UI更新、数据库保存、缓存策略——一行都不用动。这就是“面向接口编程”在真实项目中的落地样本。
3. 核心模块详解与实操要点
3.1 SQLite城市管理:不只是CRUD,更是关系建模
很多人以为SQLite管理城市就是建一张cities表,id, name, code三字段完事。这个项目用了两张表加一个视图,把“城市管理”的复杂度真实还原出来:
-
cities表:存储城市基础信息
sql CREATE TABLE cities ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, code TEXT UNIQUE NOT NULL, -- 国家气象局城市编码,如"101010100" is_default INTEGER DEFAULT 0 CHECK(is_default IN (0, 1)), sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -
weather_cache表:存储该城市最后一次获取的天气数据(避免每次启动都请求网络)
sql CREATE TABLE weather_cache ( city_id INTEGER PRIMARY KEY, temperature REAL, condition TEXT, humidity INTEGER, wind_speed REAL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(city_id) REFERENCES cities(id) ON DELETE CASCADE ); -
cities_with_weather视图:将两表关联,供UI层直接查询
sql CREATE VIEW cities_with_weather AS SELECT c.id, c.name, c.code, c.is_default, c.sort_order, w.temperature, w.condition, w.humidity, w.wind_speed, w.updated_at FROM cities c LEFT JOIN weather_cache w ON c.id = w.city_id;
为什么这样设计?看三个典型场景:
-
添加新城市:用户输入“深圳”,点击添加。流程是:先查
cities表确认code不存在(防重复),插入cities记录,然后立即向天气API发起一次请求(同步阻塞,因是用户主动操作),成功后将结果插入weather_cache。这里FOREIGN KEY ... ON DELETE CASCADE确保:如果用户删掉“深圳”这条城市记录,它的缓存数据自动消失,无需额外写删除逻辑。 -
设为默认城市:用户长按“北京”,选择“设为默认”。此时不能简单
UPDATE cities SET is_default=1 WHERE name='北京',因为可能已有其他城市is_default=1。正确做法是事务内两步:
java db.beginTransaction(); try { db.execSQL("UPDATE cities SET is_default = 0 WHERE is_default = 1"); db.execSQL("UPDATE cities SET is_default = 1 WHERE id = ?", new Object[]{cityId}); db.setTransactionSuccessful(); } finally { db.endTransaction(); }
这保证了数据库里永远只有一个is_default=1,避免UI逻辑混乱。 -
启动时加载:
WeatherActivity.onCreate()里,直接查cities_with_weather视图:
java Cursor cursor = db.rawQuery( "SELECT * FROM cities_with_weather WHERE is_default = 1", null); if (cursor.moveToFirst()) { currentCity = new City(cursor); cachedWeather = new WeatherData(cursor); // 从同一Cursor提取 } cursor.close();
一行SQL搞定“找默认城市+取其缓存”,比分别查两张表再手动关联,性能高、代码少、不易出错。
提示:
sort_order字段用于实现拖拽排序。CityAdapter里重写onItemMove(),更新cities表中对应城市的sort_order值,然后notifyItemMoved()刷新列表。实测下来,SQLite的UPDATE速度远快于内存List排序再notifyDataSetChanged(),尤其城市数超过50个时,滑动流畅度差异明显。
3.2 SharedPreferences:精准划分“偏好”与“状态”
SharedPreferences常被滥用为“万能存储”,什么数据都往里塞。这个项目严格区分两类数据:
-
用户偏好(Preferences):用户主观选择,长期有效,如温度单位(摄氏/华氏)、是否开启后台更新、通知音效开关。存储在
PreferenceManager.getDefaultSharedPreferences(context),键名带pref_前缀:
java SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); boolean isCelsius = prefs.getBoolean("pref_temperature_unit_celsius", true); int updateInterval = prefs.getInt("pref_update_interval_minutes", 60); -
临时状态(State):应用运行时产生的瞬态数据,随进程结束或Activity重建而失效,如当前正在加载的城市ID、最后一次网络请求的错误码。存储在
getSharedPreferences("app_state", MODE_PRIVATE),键名带state_前缀:
java SharedPreferences state = getSharedPreferences("app_state", MODE_PRIVATE); state.edit().putLong("state_loading_city_id", cityId).apply(); // Activity重建时,onCreate()里可读取此ID继续加载
这种分离带来两个关键好处:
第一,重置逻辑清晰。用户在设置页点“恢复默认设置”,只需prefs.edit().clear().apply(),不影响state里的临时数据;而清理缓存时,只清weather_cache表,不动SharedPreferences里的偏好设置。
第二,调试定位高效。当遇到“为什么重启App后温度单位又变回华氏?”这类问题,你立刻知道去查pref_temperature_unit_celsius这个键,而不是在几十个键里大海捞针。我在SettingsActivity里加了个隐藏功能:长按“关于”文本,弹出SharedPreferences编辑器,可直接查看、修改、删除任意键值——这是调试阶段最常用的工具。
3.3 后台定时更新:AlarmManager的精确控制术
AlarmManager是Android后台定时任务的基石,但新手常犯两个致命错误:一是用setRepeating()导致时间漂移,二是没处理系统休眠(Doze Mode)导致任务失效。这个项目采用setExactAndAllowWhileIdle() + PendingIntent.FLAG_IMMUTABLE组合,确保在Android 6.0+上精准唤醒:
private void scheduleNextUpdate(Context context) {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
Intent intent = new Intent(context, WeatherUpdateReceiver.class);
PendingIntent pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
long intervalMillis = getUpdateIntervalMillis(); // 从SharedPreferences读取
long triggerAtMillis = System.currentTimeMillis() + intervalMillis;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Android 6.0+ 使用精确且允许在Doze模式下触发
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent);
} else {
// Android < 6.0 使用传统精确触发
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent);
}
}
关键细节在于RTC_WAKEUP:它让设备在休眠时也能被唤醒(需用户授权“忽略电池优化”),triggerAtMillis计算基于System.currentTimeMillis()而非elapsedRealtime(),避免系统时间被用户手动修改导致任务错乱。
WeatherUpdateReceiver收到广播后,不直接执行耗时操作,而是启动一个IntentService(Android 8.0+已废弃,但此项目兼容旧系统)或JobIntentService(新系统推荐):
public class WeatherUpdateReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android 8.0+ 启动前台服务或JobIntentService
context.startService(new Intent(context, WeatherUpdateService.class));
} else {
// Android < 8.0 启动IntentService
Intent serviceIntent = new Intent(context, WeatherUpdateIntentService.class);
context.startService(serviceIntent);
}
}
}
WeatherUpdateIntentService在onHandleIntent()里执行真正的网络请求、解析、数据库更新,完成后调用scheduleNextUpdate()安排下一次,形成闭环。整个过程不占用主线程,不阻塞UI,且任务失败时(如网络不可用),IntentService会自动停止,不会无限重试拖垮系统。
注意:
PendingIntent.FLAG_IMMUTABLE是Android 12(API 31)强制要求,若漏加,应用在新系统上会崩溃。这个项目在build.gradle里已将targetSdkVersion设为33,并在所有PendingIntent创建处显式声明该Flag,杜绝兼容性雷区。
4. 实操过程与核心环节实现
4.1 从零构建工程:Gradle配置与模块划分
项目采用标准Android Studio单Module结构,app目录下分层清晰:
app/
├── src/main/
│ ├── java/com/example/weather/
│ │ ├── MainActivity.java # 启动Activity,负责初始化和跳转
│ │ ├── WeatherActivity.java # 主界面,展示天气、管理城市
│ │ ├── CityManagerActivity.java # 城市管理界面,增删改查
│ │ ├── network/ # 网络模块
│ │ │ ├── NetworkManager.java # HTTP连接、线程池调度
│ │ │ ├── ParserFactory.java # 解析器工厂
│ │ │ ├── XmlWeatherParser.java # XML解析器
│ │ │ └── JsonWeatherParser.java # JSON解析器
│ │ ├── database/ # 数据库模块
│ │ │ ├── WeatherDatabaseHelper.java # SQLiteOpenHelper实现
│ │ │ ├── City.java # 城市实体
│ │ │ └── WeatherData.java # 天气数据实体
│ │ ├── service/ # 后台服务模块
│ │ │ ├── WeatherUpdateReceiver.java # 广播接收器
│ │ │ └── WeatherUpdateIntentService.java # 后台任务服务
│ │ └── util/ # 工具类
│ │ └── DateUtils.java # 时间格式化工具
│ ├── res/ # 资源文件
│ └── AndroidManifest.xml
└── build.gradle # 模块级构建脚本
app/build.gradle核心配置如下(已精简无关项):
android {
compileSdk 33
defaultConfig {
applicationId "com.example.weather"
minSdk 21 // 支持Android 5.0+
targetSdk 33 // 适配Android 13
versionCode 1
versionName "1.0"
// 关键:禁用Instant Run,避免热替换导致的数据库锁死
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// Java 8特性支持
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.6.2'
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.2'
// 仅用于本地调试的Python脚本,不打包进APK
debugImplementation 'com.squareup.okhttp3:okhttp:4.11.0'
}
特别注意minifyEnabled false:混淆会破坏XmlPullParser的类名反射,导致XML解析失败。proguard-rules.pro里已添加保留规则:
-keep class org.xmlpull.v1.** { *; }
-keep class org.json.** { *; }
-keep class com.example.weather.database.** { *; }
4.2 HTTP多线程请求实战:连接池与超时控制
NetworkManager.java是网络模块的核心,其fetchWeatherData()方法完整展示了健壮的HTTP请求流程:
public static WeatherData fetchWeatherData(City city) throws IOException {
String urlStr = buildWeatherUrl(city.getCode()); // 构造API URL
URL url = new URL(urlStr);
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) url.openConnection();
// 设置超时,避免无限等待
connection.setConnectTimeout(15000); // 连接超时15秒
connection.setReadTimeout(20000); // 读取超时20秒
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", "WeatherApp/1.0");
connection.setDoInput(true);
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
// 成功,读取响应体
InputStream inputStream = connection.getInputStream();
String contentType = connection.getContentType();
WeatherParser parser = ParserFactory.createParser(contentType);
return parser.parse(inputStream);
} else {
throw new IOException("HTTP Error: " + responseCode);
}
} finally {
if (connection != null) {
connection.disconnect(); // 必须关闭,释放连接
}
}
}
这里的关键实践点:
-
超时设置是生命线:没有
setConnectTimeout(),DNS解析失败时会卡住60秒以上;没有setReadTimeout(),服务器返回半截数据会永远阻塞。15秒和20秒是经过实测的平衡值:既给了慢网环境足够时间,又避免用户长时间等待。 -
disconnect()必须放在finally块:HttpURLConnection内部使用连接池,不手动disconnect()会导致连接泄漏,多次请求后java.net.SocketException: Too many open files错误频发。我在WeatherActivity里加了日志监控:每次fetchWeatherData()前后打印connection.getURL()和connection.getResponseCode(),方便定位是哪个URL卡住了。 -
User-Agent头不可或缺:很多公共天气API(如OpenWeatherMap测试Key)会拒绝无UA的请求,返回403。WeatherApp/1.0这个字符串,既是标识,也是规避风控的必要手段。
4.3 XML/JSON双解析实现:从字节流到POJO的完整链路
以XmlWeatherParser.java为例,解析一个典型的中国气象局XML响应:
<?xml version="1.0" encoding="UTF-8"?>
<response>
<status>success</status>
<data>
<city>北京</city>
<temperature unit="celsius">25.3</temperature>
<condition>晴</condition>
<humidity>45</humidity>
<wind_speed unit="m/s">2.1</wind_speed>
</data>
</response>
解析逻辑:
public class XmlWeatherParser implements WeatherParser {
@Override
public WeatherData parse(InputStream inputStream) throws XmlPullParserException, IOException {
XmlPullParser parser = Xml.newPullParser();
parser.setInput(inputStream, "UTF-8"); // 显式指定编码,防乱码
int eventType = parser.getEventType();
WeatherData data = new WeatherData();
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG) {
String tagName = parser.getName();
if ("city".equals(tagName)) {
data.setCity(parser.nextText().trim());
} else if ("temperature".equals(tagName)) {
String unit = parser.getAttributeValue(null, "unit");
String value = parser.nextText().trim();
if ("celsius".equals(unit)) {
data.setTemperatureCelsius(Float.parseFloat(value));
}
} else if ("condition".equals(tagName)) {
data.setCondition(parser.nextText().trim());
} else if ("humidity".equals(tagName)) {
data.setHumidity(Integer.parseInt(parser.nextText().trim()));
} else if ("wind_speed".equals(tagName)) {
String unit = parser.getAttributeValue(null, "unit");
String value = parser.nextText().trim();
if ("m/s".equals(unit)) {
data.setWindSpeed(Float.parseFloat(value));
}
}
}
eventType = parser.next();
}
return data;
}
}
JsonWeatherParser.java解析OpenWeatherMap JSON:
{
"name": "Beijing",
"main": {
"temp": 298.45,
"humidity": 45
},
"weather": [
{
"main": "Clear",
"description": "clear sky"
}
],
"wind": {
"speed": 2.1
}
}
解析逻辑:
public class JsonWeatherParser implements WeatherParser {
@Override
public WeatherData parse(InputStream inputStream) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
StringBuilder jsonBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
jsonBuilder.append(line);
}
JSONObject root = new JSONObject(jsonBuilder.toString());
WeatherData data = new WeatherData();
data.setCity(root.optString("name", "Unknown"));
JSONObject mainObj = root.optJSONObject("main");
if (mainObj != null) {
double tempK = mainObj.optDouble("temp", 273.15);
data.setTemperatureCelsius((float) (tempK - 273.15)); // 开尔文转摄氏
data.setHumidity(mainObj.optInt("humidity", 0));
}
JSONArray weatherArray = root.optJSONArray("weather");
if (weatherArray != null && weatherArray.length() > 0) {
JSONObject weatherObj = weatherArray.getJSONObject(0);
data.setCondition(weatherObj.optString("main", "Unknown"));
}
JSONObject windObj = root.optJSONObject("wind");
if (windObj != null) {
data.setWindSpeed((float) windObj.optDouble("speed", 0.0));
}
return data;
}
}
实操心得:XML解析必须处理
getAttributeValue(null, "unit"),因为null表示默认命名空间;JSON解析要用optXXX()而非getXXX(),避免字段缺失时抛JSONException。我在WeatherActivity里加了try-catch包裹整个解析流程,并弹Toast提示“数据格式异常,请检查网络或稍后重试”,而不是让App崩溃——这才是真实产品的容错逻辑。
4.4 SQLite数据库初始化与预置数据注入
WeatherDatabaseHelper.java继承SQLiteOpenHelper,onCreate()方法不仅建表,还注入预置数据:
@Override
public void onCreate(SQLiteDatabase db) {
// 创建表
db.execSQL(CREATE_CITIES_TABLE);
db.execSQL(CREATE_WEATHER_CACHE_TABLE);
db.execSQL(CREATE_CITIES_WITH_WEATHER_VIEW);
// 插入预置城市:北京、上海、广州
ContentValues values = new ContentValues();
values.put("name", "北京");
values.put("code", "101010100");
values.put("is_default", 1);
db.insert("cities", null, values);
values.clear();
values.put("name", "上海");
values.put("code", "101020100");
values.put("is_default", 0);
db.insert("cities", null, values);
values.clear();
values.put("name", "广州");
values.put("code", "101290101");
values.put("is_default", 0);
db.insert("cities", null, values);
// 为北京预置缓存数据(模拟首次启动有数据)
ContentValues cacheValues = new ContentValues();
cacheValues.put("city_id", 1);
cacheValues.put("temperature", 25.3f);
cacheValues.put("condition", "晴");
cacheValues.put("humidity", 45);
cacheValues.put("wind_speed", 2.1f);
db.insert("weather_cache", null, cacheValues);
}
weather.db文件就是这个onCreate()执行后生成的数据库文件。你把它复制到项目根目录,WeatherDatabaseHelper的构造函数里会检测到dbPath存在,直接打开它,跳过onCreate()。这样,开发者无需手动执行SQL,导入项目就能看到三个城市。
注意:
CREATE_CITIES_WITH_WEATHER_VIEW必须在两张表创建之后再执行,否则CREATE VIEW会失败。我在onCreate()里严格按顺序书写SQL,这是SQLite建库的铁律。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
App启动白屏,Logcat显示android.database.sqlite.SQLiteException: no such table: cities | WeatherDatabaseHelper未被正确实例化,getWritableDatabase()未调用 | 1. 在WeatherActivity.onCreate()里加断点,检查new WeatherDatabaseHelper(this)是否执行2. 查看 WeatherDatabaseHelper构造函数里dbPath是否指向正确路径 | 确保WeatherDatabaseHelper单例被全局持有,或在Application类里提前初始化 |
| 点击“刷新”按钮无反应,Logcat无网络日志 | NETWORK_EXECUTOR线程池已满,新任务被LinkedBlockingQueue阻塞 | 1. 在NetworkManager.fetchWeatherData()开头加Log.d("Net", "Start fetch")2. 查看线程池状态: Log.d("Net", "Pool: " + NETWORK_EXECUTOR.getActiveCount() + "/" + NETWORK_EXECUTOR.getPoolSize()) | 减小workQueue容量(如从10改为3),或增加maxPoolSize至6,避免队列积压 |
XML解析报org.xmlpull.v1.XmlPullParserException: Unexpected token | 服务器返回HTML错误页(如404),而非预期XML | 1. 用curl -v "your_url"命令直连API,检查Content-Type和响应体2. 在 NetworkManager里打印connection.getContentType()和前100字符响应体 | 在fetchWeatherData()里增加if (!contentType.contains("xml")) { throw new IOException("Not XML: " + contentType); }提前拦截 |
后台更新不触发,WeatherUpdateReceiver.onReceive()从未被调用 | AlarmManager任务被系统省电策略杀死 | 1. 进入手机设置→电池→应用启动管理→找到本App→关闭“自动管理” 2. 在 WeatherUpdateReceiver里加Log.d("Alarm", "Received")确认是否收到广播 | 引导用户手动开启“允许后台活动”权限;Android 12+需在AndroidManifest.xml中声明<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>并动态申请 |
SharedPreferences里pref_temperature_unit_celsius值始终为true,无法保存修改 | SharedPreferences.Editor未调用apply()或commit() | 1. 检查SettingsActivity中保存逻辑:prefs.edit().putBoolean("pref_temperature_unit_celsius", isChecked).apply();2. 确认 apply()后无其他代码覆盖该键 | apply()是异步的,commit()是同步的;新手常误写成prefs.edit().putBoolean(...);漏掉.apply(),导致修改丢失 |
5.2 独家避坑技巧
技巧一:用adb shell dumpsys alarm实时监控AlarmManager
当怀疑后台任务没按时触发,别只看Logcat。在终端执行:
adb shell dumpsys alarm | grep "com.example.weather"
你会看到类似输出:
RTC_WAKEUP #0: Alarm{... com.example.weather}
type=0 when=+1h59m42s345ms window=0 repeatInterval=3600000 count=0
operation=PendingIntent{...}
这里的when=+1h59m42s345ms表示距离下次触发还有1小时59分钟,repeatInterval=3600000即1小时,count=0表示尚未触发过。如果count一直是0,说明AlarmManager.setExactAndAllowWhileIdle()根本没调用成功——这时立刻去查WeatherUpdateReceiver注册逻辑。
技巧二:SQLite数据库实时查看法
weather.db文件在/data/data/com.example.weather/databases/目录下,普通ADB命令无法直接拉取。但你可以用Android Studio的Device File Explorer:
1. 连接真机,打开AS → View → Tool Windows → Device File Explorer
2. 导航至/data/data/com.example.weather/databases/
3. 右键weather.db → Save As… 保存到本地
4. 用DB Browser for SQLite打开,执行SELECT * FROM cities_with_weather;,立刻看到当前所有城市及缓存数据
这个技巧比写一堆Log.d()打印SQL结果高效十倍,尤其调试城市排序、默认设置时,数据一目了然。
技巧三:网络请求Mock终极方案——app.py本地服务
资源包里的app.py是一个Flask服务,运行后会在http://localhost:5000/api/weather提供模拟响应。启动方式:
cd /path/to/project
pip install flask
python app.py
然后在NetworkManager.buildWeatherUrl()里,把线上URL临时改成http://10.0.2.2:5000/api/weather?city=beijing(10.0.2.2是Android模拟器访问宿主机的固定IP)。这样,你完全脱离真实网络,所有请求都走本地,响应内容、延迟、错误码均可在app.py里自由控制。我常把app.py的@app.route('/api/weather')函数改成:
@app.route('/api/weather')
def mock_weather():
import time
time.sleep(3) # 模拟3秒网络延迟
return jsonify({"name": "Beijing", "main": {"temp": 298.45}})
用来测试超时逻辑和Loading状态。
技巧四:onDestroy()里取消网络请求的黄金法则
WeatherActivity可能因屏幕旋转、内存不足被销毁,但AsyncTask或ThreadPoolExecutor里的网络请求还在跑。为避免内存泄漏和UI更新异常,必须在onDestroy()里取消:
private WeatherLoadTask currentTask;
@Override
protected void onDestroy() {
super.onDestroy();
if (currentTask != null && currentTask.getStatus() == AsyncTask.Status.RUNNING) {
currentTask.cancel(true); // true表示中断线程
}
}
// 在发起请求时赋值
currentTask = new WeatherLoadTask(this);
currentTask.execute();
cancel(true)会向线程抛InterruptedException,因此doInBackground()里必须捕获:
@Override
protected WeatherData doInBackground(Void... voids) {
try {
return NetworkManager.fetchWeatherData();
} catch (InterruptedException e) {
// 请求被取消,直接返回null
return null;
}
}
这是保障Activity生命周期与异步任务严格对齐的最后防线。
6. 项目扩展与进阶方向
这个项目不是终点,而是起点。基于它,你可以平滑升级到现代Android开发范式,每一步都有明确路径:
第一步:接入Retrofit + Coroutines(Kotlin)
保留现有XmlWeatherParser和JsonWeatherParser,只替换NetworkManager。用Retrofit定义接口:
interface WeatherApi {
@GET("weather")
suspend fun getWeather(@Query("q") city: String): Response<WeatherResponse>
}
WeatherActivity里用lifecycleScope.launch发起请求,onSuccess回调里直接更新UI。你会发现,网络层代码从80行锐减到15行,且协程天然支持取消,onDestroy()里不再需要手动cancel Task。
第二步:迁移到Room数据库
将WeatherDatabaseHelper替换为@Database注解类,cities表变成@Entity,DAO接口用@Query注解。Room会自动生成createInsertAllStatement()等方法,INSERT语句不再手写SQL,且编译期检查SQL语法错误。预置数据可通过Room.databaseBuilder().createFromAsset()直接加载weather.db。
第三步:后台任务升级为WorkManager
WeatherUpdateReceiver和AlarmManager全部废弃,改用PeriodicWorkRequest:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = PeriodicWorkRequestBuilder<WeatherUpdateWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"weather_update",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
WorkManager自动处理Doze、App Standby等限制,且支持链式任务(如“更新天气→同步到云端→推送通知”)。
第四步:UI层升级为Jetpack Compose
WeatherActivity变成@Composable函数,CityAdapter被LazyColumn替代,RecyclerView的notifyItemChanged()调用消失,状态驱动UI更新。你会发现,城市列表拖拽排序从ItemTouchHelper的200行代码,变成rememberDraggableState的20行。
所有这些升级,都不需要重写你的WeatherData实体类、不改变SharedPreferences的键名、不破坏weather.db的数据结构。这个项目就像一座稳固的桥墩,托起你通往现代Android开发的所有桥梁。它存在的意义,从来不是展示“我能做出什么”,而是证明“你也能一步步做到”。当你第一次在真机上看到“北京 25°C 晴”出现在屏幕上,那一刻的成就感,就是所有深夜调试、所有报错日志、所有被disconnect()拯救的连接,共同浇灌出的果实。
简介:这个Android天气应用项目用Java开发,适合刚入门的开发者上手练习。它通过多线程发起HTTP请求获取实时天气数据,兼容XML和JSON两种常见接口格式,并能正确解析展示。城市列表存在本地SQLite数据库里,支持添加、删除、查询和默认城市设置;用户偏好和临时缓存则用SharedPreferences保存。应用内置后台自动更新机制,可按设定时间间隔静默拉取最新天气,无需手动刷新。整个工程结构清晰,包含完整的Gradle构建脚本、Android Studio项目配置、开源许可证文件(LICENSE)、详细README说明文档,以及用于本地调试的简易Python脚本(app.py)和依赖清单(requirements.txt)。资源包里还附带了预置的weather.db示例数据库和基础HTML页面(index.html),方便快速验证数据存储与展示效果。所有代码遵循标准Android开发规范,覆盖网络通信、异步处理、本地持久化、后台任务调度等关键开发场景。

被折叠的 条评论
为什么被折叠?



