Android天气App实战源码:Java实现HTTP多线程请求、XML/JSON双解析、SQLite城市管理与定时后台更新

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这个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自带的XmlPullParserorg.json包实现。为什么?因为新手最需要的不是“怎么最快做出效果”,而是“当网络超时、JSON字段缺失、XML命名空间错乱、SQLite事务没提交、AlarmManager时间不准时,我该去哪一行断点、看哪个变量、改哪段逻辑”。这个项目,就是那个能让你对着Logcat报错信息,顺藤摸瓜找到根源的“活体教材”。

它适合谁?如果你刚学完Java集合和线程基础,正在Android Studio里第一次创建Empty Activity,对AsyncTask已被废弃感到困惑,对WorkManagerAlarmManager的区别还停留在概念层面,那它就是为你量身定做的。它不假设你懂协程,不依赖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个泛型擦除后的类型转换。而用HttpURLConnectionconnection.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的状态机要直观十倍。等你把这个跑通了,再去看WorkManagerConstraintsBackoffPolicy,理解会深刻得多。

第三,知识迁移壁垒最小。Java语法、线程模型、IO流操作、SQL语法——这些是跨平台的基础能力。今天你搞懂ExecutorService如何管理线程池,明天写Spring Boot的异步任务就只需换一个注解;今天你亲手写SQLiteOpenHelperonUpgrade()逻辑处理数据库版本迁移,明天用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()); } });
    这样,天气请求永远走这个池,图片下载可以另起一个池,互不干扰。

  • 生命周期绑定AsyncTaskonPostExecute()回调在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在拿到HttpURLConnectiongetContentType()后,动态创建对应解析器。XmlWeatherParserXmlPullParser,重点处理命名空间(http://weather.example.com/ns)和属性提取(<temperature unit="celsius">25</temperature>);JsonWeatherParserJSONObject,重点处理嵌套对象(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;

为什么这样设计?看三个典型场景:

  1. 添加新城市:用户输入“深圳”,点击添加。流程是:先查cities表确认code不存在(防重复),插入cities记录,然后立即向天气API发起一次请求(同步阻塞,因是用户主动操作),成功后将结果插入weather_cache。这里FOREIGN KEY ... ON DELETE CASCADE确保:如果用户删掉“深圳”这条城市记录,它的缓存数据自动消失,无需额外写删除逻辑。

  2. 设为默认城市:用户长按“北京”,选择“设为默认”。此时不能简单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逻辑混乱。

  3. 启动时加载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);
        }
    }
}

WeatherUpdateIntentServiceonHandleIntent()里执行真正的网络请求、解析、数据库更新,完成后调用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()必须放在finallyHttpURLConnection内部使用连接池,不手动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继承SQLiteOpenHelperonCreate()方法不仅建表,还注入预置数据:

@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: citiesWeatherDatabaseHelper未被正确实例化,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),而非预期XML1. 用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"/>并动态申请
SharedPreferencespref_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=beijing10.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可能因屏幕旋转、内存不足被销毁,但AsyncTaskThreadPoolExecutor里的网络请求还在跑。为避免内存泄漏和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)
保留现有XmlWeatherParserJsonWeatherParser,只替换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
WeatherUpdateReceiverAlarmManager全部废弃,改用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函数,CityAdapterLazyColumn替代,RecyclerViewnotifyItemChanged()调用消失,状态驱动UI更新。你会发现,城市列表拖拽排序从ItemTouchHelper的200行代码,变成rememberDraggableState的20行。

所有这些升级,都不需要重写你的WeatherData实体类、不改变SharedPreferences的键名、不破坏weather.db的数据结构。这个项目就像一座稳固的桥墩,托起你通往现代Android开发的所有桥梁。它存在的意义,从来不是展示“我能做出什么”,而是证明“你也能一步步做到”。当你第一次在真机上看到“北京 25°C 晴”出现在屏幕上,那一刻的成就感,就是所有深夜调试、所有报错日志、所有被disconnect()拯救的连接,共同浇灌出的果实。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这个Android天气应用项目用Java开发,适合刚入门的开发者上手练习。它通过多线程发起HTTP请求获取实时天气数据,兼容XML和JSON两种常见接口格式,并能正确解析展示。城市列表存在本地SQLite数据库里,支持添加、删除、查询和默认城市设置;用户偏好和临时缓存则用SharedPreferences保存。应用内置后台自动更新机制,可按设定时间间隔静默拉取最新天气,无需手动刷新。整个工程结构清晰,包含完整的Gradle构建脚本、Android Studio项目配置、开源许可证文件(LICENSE)、详细README说明文档,以及用于本地调试的简易Python脚本(app.py)和依赖清单(requirements.txt)。资源包里还附带了预置的weather.db示例数据库和基础HTML页面(index.html),方便快速验证数据存储与展示效果。所有代码遵循标准Android开发规范,覆盖网络通信、异步处理、本地持久化、后台任务调度等关键开发场景。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值