本文和接下來的幾篇文章為閱讀郭霖先生所著《第一行代碼:Android(篇第2版)》的學習筆記,按照書中的內容順序進行記錄,書中的Demo本人全部都做過了,
每一章節本人都做了詳細的記錄,以下是我學習記錄(包含大量書中內容的整理和自己在學習中遇到的各種bug及解決方案),方便以后閱讀和查閱,最后,非常感激郭霖先生提供這么好的書籍,
第14章 進入實戰——開發酷歐天氣
在本章將撰寫一個功能較為完整的天氣預報程式,那么第一步需要給這個軟體起名字,這里就叫它酷歐天氣吧,英文名就叫作CoolWeather,確定了名字之后,下面就可以開始動手了,
14.1 功能需求及技術可行性分析
在開始編碼之前,需要先對程式進行需求分析,想一想酷歐天氣中應該具備哪些功能,將這些功能全部整理出來,這里我認為酷歐天氣中至少應該具備以下功能:
? 可以羅列出全國所有的省、市、縣;
? 可以查看全國任意城市的天氣資訊;
? 可以自由地切換城市,去查看其他城市的天氣;
? 提供手動更新以及后臺自動更新天氣的功能,
雖然看上去只有4個主要的功能點,但如果想要全部實作這些功能卻需要用到UI、網路、資料存盤、服務等技術,因此還是非常考驗你的綜合應用能力的,
分析完了需求之后,接下來就要進行技術可行性分析了,
首先需要考慮的一個問題就是,我們如何才能得到全國省市縣的資料資訊,以及如何才能獲取到每個城市的天氣資訊,比較遺憾的是,現在網上免費的天氣預報介面已經越來越少,很多之前可以使用的介面都慢慢關閉掉了,包括本書第1版中使用的中國天氣網的介面,
因此,這次我也是特意用心去找了一些更加穩定的天氣預報服務,比如彩云天氣以及和風天氣都非常不錯,這兩個天氣預報服務雖說都是收費的,但它們每天都提供了一定次數的免費天氣預報請求,其中彩云天氣的資料更加實時和專業,可以將天氣預報精確到分鐘級,每天提供1000次免費請求;和風天氣的資料相對簡單一些,比較適合新手學習,每天提供3000次免費請求,
那么簡單起見,這里我們就使用和風天氣來作為天氣預報的資料來源,每天3000次的免費請求對于學習而言已經是相當充足了,
解決了天氣資料的問題,接下來還需要解決全國省市縣資料的問題,同樣,現在網上也沒有一個穩定的介面可以使用,那么為了方便你的學習,我專門架設了一臺服務器用于提供全國所有省市縣的資料資訊,從而幫你把道路都鋪平了,那么下面我們來看一下這些介面的具體用法,比如要想羅列出中國所有的省份,只需訪問如下地址:
http://guolin.tech/api/china
服務器會回傳我們一段JSON格式的資料,其中包含了中國所有的省份名稱以及省份id,如下所示:
[{"id":1,"name":"北京"},{"id":2,"name":"上海"},{"id":3,"name":"天津"},
{"id":4,"name":"重慶"},{"id":5,"name":"香港"},{"id":6,"name":"澳門"},
{"id":7,"name":"臺灣"},{"id":8,"name":"黑龍江"},{"id":9,"name":"吉林"},{"id":10,"name":"遼寧"},{"id":11,"name":"內蒙古"},{"id":12,"name":"河北"},{"id":13,"name":"河南"},{"id":14,"name":"山西"},{"id":15,"name":"山東"},{"id":16,"name":"江蘇"},{"id":17,"name":"浙江"},{"id":18,"name":"福建"},{"id":19,"name":"江西"},{"id":20,"name":"安徽"},{"id":21,"name":"湖北"},{"id":22,"name":"湖南"},{"id":23,"name":"廣東"},{"id":24,"name":"廣西"},{"id":25,"name":"海南"},{"id":26,"name":"貴州"},{"id":27,"name":"云南"},{"id":28,"name":"四川"},{"id":29,"name":"西藏"},{"id":30,"name":"陜西"},{"id":31,"name":"寧夏"},{"id":32,"name":"甘肅"},{"id":33,"name":"青海"},{"id":34,"name":"新疆"}]
可以看到,這是一個JSON陣列,陣列中的每一個元素都代表著一個省份,其中,北京的id是1,上海的id是2,那么如何才能知道某個省內有哪些城市呢?其實也很簡單,比如江蘇的id是16,訪問如下地址即可:
http://guolin.tech/api/china/16
[{"id":113,"name":"南京"},{"id":114,"name":"無錫"},{"id":115,"name":"鎮江"},{"id":116,"name":"蘇州"},{"id":117,"name":"南通"},{"id":118,"name":"揚州"},{"id":119,"name":"鹽城"},{"id":120,"name":"徐州"},{"id":121,"name":"淮安"},{"id":122,"name":"連云港"},{"id":123,"name":"常州"},{"id":124,"name":"泰州"},{"id":125,"name":"宿遷"}]
這樣我們就得到江蘇省內所有城市的資訊了,可以看到,現在回傳的資料格式和剛才查看省份資訊時回傳的資料格式是一樣的,相信此時你已經可以舉一反三了,比如說蘇州的id是116,那么想要知道蘇州市下又有哪些縣和區的時候,只需訪問如下地址:
http://guolin.tech/api/china/16/116
[{"id":937,"name":"蘇州","weather_id":"CN101190401"},{"id":938,"name":"常熟","weather_id":"CN101190402"},{"id":939,"name":"張家港","weather_id":"CN101190403"},{"id":940,"name":"昆山","weather_id":"CN101190404"},{"id":941,"name":"吳中","weather_id":"CN101190405"},{"id":942,"name":"吳江","weather_id":"CN101190407"},{"id":943,"name":"太倉","weather_id":"CN101190408"}]
通過這種方式,我們就能把全國所有的省、市、縣都羅列出來了,那么解決了省市縣資料的獲取,我們又怎樣才能查看到具體的天氣資訊呢?這就必須要用到每個地區對應的天氣id了,觀察上面回傳的資料,你會發現每個縣或區都會有一個weather_id,拿著這個id再去訪問和風天氣的介面,就能夠獲取到該地區具體的天氣資訊了,
下面我們來看一下和風天氣的介面該如何使用,首先你需要注冊一個自己的賬號,注冊地址是:http://guolin.tech/api/weather/register
注冊好了之后使用這個賬號登錄,如圖:

用這個賬號登錄,就能看到自己的API Key,以及每天剩余的訪問次數:

有了API Key,再配合剛才的weather_id,我們就能獲取到任意城市的天氣資訊了,比如說蘇州的weather_id是CN101190401,那么訪問如下介面即可查看蘇州的天氣資訊:
http://guolin.tech/api/weather?cityid=CN101190401&&key=bc0418b57b2d4918819d3974ac1285d9
其中,cityid部分填入的就是待查看城市的weather_id, key部分填入的就是我們申請到的API Key,這樣,服務器就會把蘇州詳細的天氣資訊以JSON格式回傳給我們了,不過,由于回傳的資料過于復雜,這里我做了一下精簡處理,如下所示:
{
"HeWeather":[
{
"status":ok,
"basic":{},
"aqi":{},
"now":{},
"suggestion":{},
"daily_forecast":[]
}
]
}
回傳資料的格式大體上就是這個樣子了,其中status代表請求的狀態,ok表示成功,basic中會包含城市的一些基本資訊,aqi中會包含當前空氣質量的情況,now中會包含當前的天氣資訊,suggestion中會包含一些天氣相關的生活建議,daily_forecast中會包含未來幾天的天氣資訊,
訪問http://guolin.tech/api/weather/doc這個網址可以查看更加詳細的檔案說明(該地址404了):

資料都能獲取到了之后,接下來就是JSON決議的作業了,確定了技術完全可行之后,接下來就可以開始編碼了,不過別著急,我們準備讓酷歐天氣成為一個開源軟體,并使用GitHub來進行代碼托管,因此先讓我們進入到本書最后一次的Git時間,
14.2 Git時間——將代碼托管到GitHub上
GitHub是全球最大的代碼托管網站,主要是借助Git來進行版本控制的,任何開源軟體都可以免費地將代碼提交到GitHub上,以零成本的代價進行代碼托管,GitHub的官網地址是https://github.com/,官網的首頁如圖:

首先你需要有一個GitHub賬號才能使用GitHub的代碼托管功能,點擊Signup for GitHub按鈕進行注冊,然后填入用戶名、郵箱和密碼,(已有賬號,下圖為書中圖)

擊Create an account按鈕來創建賬戶,接下來會讓你選擇個人計劃,收費計劃有創建私人版本庫的權限,而我們的酷歐天氣是開源軟體,所以這里選擇免費計劃就可以了,如圖:

接著點擊Continue按鈕會進入一個問卷調查界面,如圖:

如果你對這個有興趣就填寫一下,沒興趣的話直接點擊最下方的skip thisstep跳過就可以了,這樣我們就把賬號注冊好了,會自動跳轉到GitHub的個人主頁,如圖:

接下來就可以點擊Create a new repository按鈕來創建一個版本庫了,(由于我們是剛剛注冊的賬號,在創建版本庫之前還需要做一下郵箱驗證,驗證成功之后就能開始創建了,)
這里將版本庫命名為coolweather,然后選擇添加一個Android專案型別的.gitignore檔案,并使用Apache License 2.0來作為酷歐天氣的開源協議,如圖:

接著,點擊Create repository按鈕,coolweather這個版本庫就創建完成了,如圖,版本庫主頁地址是:https://github.com/guolindev/coolweather

可以看到,GitHub已經自動幫我們創建了.gitignore、LICENSE和README.md這3個檔案,其中編輯README.md檔案中的內容可以修改酷歐天氣版本庫主頁的描述,
創建好了版本庫之后,我們就需要創建酷歐天氣這個專案了,在AndroidStudio中新建一個Android專案,專案名叫作CoolWeather,包名叫作com.coolweather.android,如圖:

一直點擊Next就可以完成專案的創建,所有選項都使用默認的就好,
接下來的一步非常重要,我們需要將遠程版本庫克隆到本地,首先必須知道遠程版本庫的Git地址,點擊Clone or download按鈕就能夠看到了,如圖:

點擊右邊的復制按鈕,可以將版本庫的Git地址復制到剪貼板,酷歐天氣版本庫的Git地址是:https://github.com/guolindev/coolweather.git(寫自己創建的地址哦)
然后打開Git Bash并切換到CoolWeather的工程目錄下,接著輸入git clone https://github.com/guolindev/coolweather.git(寫自己創建的地址哦)來把遠程版本庫克隆到本地,如圖:
注意:【github】將默認分支由 main 修改為 master 博客地址:https://blog.csdn.net/m0_37697335/article/details/120633567

看到圖中所給的文字提示就表示克隆成功了,并且.gitignore、LICENSE和README.md這3個檔案也已經被復制到了本地,可以進入到coolweather目錄,并使用ls -al命令查看一下,如圖:

現在,我們需要將這個目錄中的所有檔案全部復制粘貼到上一層目錄中,這樣就能將整個CoolWeather工程目錄添加到版本控制中去了,
注意:.git是一個隱藏目錄,在復制的時候千萬不要漏掉,另外,上一層目錄中也有一個.gitignore檔案,我們直接將其覆寫即可,復制完之后可以把coolweather目錄洗掉掉,最終CoolWeather工程的目錄結構如圖:

接下來,我們應該把CoolWeather專案中現有的檔案提交到GitHub上面,這就很簡單了,先將所有檔案添加到版本控制中,如下所示:
git add .
然后在本地執行提交操作:
git commit -m "First commit."
最后,將提交的內容同步到遠程版本庫,也就是GitHub上面:
git push -u origin master
注意,在最后一步的時候GitHub要求輸入用戶名和密碼來進行身份校驗,這里輸入我們注冊時填入的用戶名和密碼就可以了,如圖:

這樣就已經同步完成了,現在重繪一下酷歐天氣版本庫的主頁,會看到剛才提交的那些檔案已經存在了,如圖:

14.3 創建資料庫和表
從本節開始,就要真正地動手編碼了,為了要讓專案能夠有更好的結構,這里需要在com.coolweather.android包下再新建幾個包,如圖:

其中,
- db包用于存放資料庫模型相關的代碼;
- gson包用于存放GSON模型相關的代碼;
- service包用于存放服務相關的代碼;
- util包用于存放工具相關的代碼,
根據14.1節進行的技術可行性分析,第一階段我們要做的就是創建好資料庫和表,這樣從服務器獲取到的資料才能夠存盤到本地,關于資料庫和表的創建方式,我們早在第6章中就已經學過了,
簡化資料庫的操作,這里我準備使用LitePal來管理酷歐天氣的資料庫,首先需要將專案所需的各種依賴庫進行宣告,編輯app/build.gradle檔案,在dependencies閉包中添加如下內容:
dependencies {
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
implementation 'org.litepal.guolindev:core:3.2.3'
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.github.bumptech.glide:glide:4.13.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0'
}
LitePal用于對資料庫進行操作,OkHttp用于進行網路請求,GSON用于決議JSON資料,Glide用于加載和展示圖片,
注意:當時,我的下面代碼Province類在繼承LitePalSupport類時LitePalSupport一直爆紅,加了導包也不行,最后,修改了settings.gradle檔案,才OK:
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
jcenter()
maven { url 'https://jitpack.io'}
}
}
rootProject.name = "CoolWeather"
include ':app'
然后,來設計一下資料庫的表結構,表的設計當然是仁者見仁智者見智,并不是說哪種設計就是最規范最完美的,這里我準備建立3張表:province、city、county,分別用于存放省、市、縣的資料資訊,對應到物體類中的話,就應該建立Province、City、County這3個類,
那么,在db包下新建一個Province類,代碼如下所示:
package com.coolweather.android.db;
import org.litepal.crud.LitePalSupport;
//書中繼承的是DataSupport(已經棄用)
public class Province extends LitePalSupport {
//id是每個物體類中都應該有的欄位
private int id;
//provinceName記錄省的名字
private String provinceName;
//provinceCode記錄省的代號
private int provinceCode;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getProvinceName() {
return provinceName;
}
public void setProvinceName(String provinceName) {
this.provinceName = provinceName;
}
public int getProvinceCode() {
return provinceCode;
}
public void setProvinceCode(int provinceCode) {
this.provinceCode = provinceCode;
}
}
接著,在db包下新建一個City類,代碼如下所示:
package com.coolweather.android.db;
import org.litepal.crud.LitePalSupport;
public class City extends LitePalSupport {
private int id;
//cityName記錄市的名字
private String cityName;
//cityCode記錄市的代號
private int cityCode;
//provinceId記錄當前市所屬省的id值
private int provinceId;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getCityName() {
return cityName;
}
public void setCityName(String cityName) {
this.cityName = cityName;
}
public int getCityCode() {
return cityCode;
}
public void setCityCode(int cityCode) {
this.cityCode = cityCode;
}
public int getProvinceId() {
return provinceId;
}
public void setProvinceId(int provinceId) {
this.provinceId = provinceId;
}
}
然后,在db包下新建一個County類,代碼如下所示:
package com.coolweather.android.db;
import org.litepal.crud.LitePalSupport;
public class County extends LitePalSupport {
private int id;
//countyName記錄縣的名字
private String countyName;
//weatherId記錄縣所對應的天氣id
private String weatherId;
//cityId記錄當前縣所屬市的id值
private int cityId;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getCountyName() {
return countyName;
}
public void setCountyName(String countyName) {
this.countyName = countyName;
}
public String getWeatherId() {
return weatherId;
}
public void setWeatherId(String weatherId) {
this.weatherId = weatherId;
}
public int getCityId() {
return cityId;
}
public void setCityId(int cityId) {
this.cityId = cityId;
}
}
可以看到,物體類的內容都非常簡單,就是宣告了一些需要的欄位,并生成相應的getter和setter方法就可以了,接下來需要配置litepal.xml檔案,
右擊app/src/main目錄→New→Directory,創建一個assets目錄,然后在assets目錄下再新建一個litepal.xml檔案,接著編輯litepal.xml檔案中的內容,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<litepal>
<dbname value="https://www.cnblogs.com/1693977889zz/p/cool_weather" />
<version value="https://www.cnblogs.com/1693977889zz/p/1" />
<list>
<mapping />
<mapping />
<mapping />
</list>
</litepal>
這里將資料庫名指定成cool_weather,資料庫版本指定成1,并將Province、City和County這3個物體類添加到映射串列當中,
最后,還需要再配置一下LitePalApplication,修改AndroidManifest.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.coolweather.android">
<application
android:name="org.litepal.LitePalApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CoolWeather">
...
</application>
</manifest>
這樣我們就將所有的配置都完成了,資料庫和表會在首次執行任意資料庫操作的時候自動創建,
現在提交一下,首先,將所有新增的檔案添加到版本控制中:
git add .
接著,執行提交操作:
git commit -m "加入創建資料庫和表的各項操作,"
最后,將提交同步到GitHub上面:
git push origin master
第一階段完工,
14.4 遍歷全國省市縣資料
在第二階段中,我們準備把遍歷全國省市縣的功能加入,這一階段需要撰寫的代碼量比較大,
我們已經知道,全國所有省市縣的資料都是從服務器端獲取到的,因此這里和服務器的互動是必不可少的,所以我們可以在util包下先增加一個HttpUtil類,代碼如下所示:
package com.coolweather.android.util;
import okhttp3.OkHttpClient;
import okhttp3.Request;
public class HttpUtil {
public static void sendOkHttpRequest(String address,okhttp3.Callback callback) {
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(address).build();
client.newCall(request).enqueue(callback);
}
}
由于OkHttp的出色封裝,這里和服務器進行互動的代碼非常簡單,僅僅3行就完成了,
現在,我們發起一條HTTP請求只需要呼叫sendOkHttpRequest()方法,傳入請求地址,并注冊一個回呼來處理服務器回應就可以了,
另外,由于服務器回傳的省市縣資料都是JSON格式的,所以我們最好再提供一個工具類來決議和處理這種資料,在util包下新建一個Utility類,代碼如下所示:
package com.coolweather.android.util;
import android.text.TextUtils;
import com.coolweather.android.db.City;
import com.coolweather.android.db.County;
import com.coolweather.android.db.Province;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
public class Utility {
/**
* 決議和處理服務器回傳的省級資料
*/
public static boolean handleProvinceResponse(String response) {
if (! TextUtils.isEmpty(response)) {
try {
JSONArray allProvinces = new JSONArray(response);
for (int i = 0;i < allProvinces.length();i++) {
JSONObject provinceObject = allProvinces.getJSONObject(i);
Province province = new Province();
province.setProvinceName(provinceObject.getString("name"));
province.setProvinceCode(provinceObject.getInt("id"));
province.save();
}
return true;
} catch (JSONException e) {
e.printStackTrace();
}
}
return false;
}
/**
* 決議和處理服務器回傳的市級資料
*/
public static boolean handleCityResponse(String response,int provinceId) {
if (! TextUtils.isEmpty(response)) {
try {
JSONArray allCities = new JSONArray(response);
for (int i = 0; i < allCities.length(); i++) {
JSONObject cityObject = allCities.getJSONObject(i);
City city = new City();
city.setCityName(cityObject.getString("name"));
city.setCityCode(cityObject.getInt("id"));
city.setProvinceId(provinceId);
city.save();
}
return true;
} catch (JSONException e) {
e.printStackTrace();
}
}
return false;
}
/**
* 決議和處理服務器回傳的縣級資料
*/
public static boolean handleCountyResponse(String response,int cityId) {
if (! TextUtils.isEmpty(response)) {
try {
JSONArray allCounties = new JSONArray(response);
for (int i = 0; i < allCounties.length(); i++) {
JSONObject countyObject = allCounties.getJSONObject(i);
County county = new County();
county.setCountyName(countyObject.getString("name"));
county.setWeatherId(countyObject.getString("weather_id"));
county.setCityId(cityId);
county.save();
}
return true;
} catch (JSONException e) {
e.printStackTrace();
}
}
return false;
}
}
可以看到,我們提供了handleProvinceResponse()、handleCityResponse()、handleCountyResponse()這3個方法,分別用于決議和處理服務器回傳的省級、市級和縣級資料,處理的方式都是類似的,先使用JSONArray和JSONObject將資料決議出來,然后組裝成物體類物件,再呼叫save()方法將資料存盤到資料庫當中,
需要準備的工具類就這么多,現在可以開始寫界面了,由于遍歷全國省市縣的功能我們在后面還會復用,因此就不寫在活動里面了,而是寫在碎片里面,這樣需要復用的時候直接在布局里面參考碎片就可以了,
在res/layout目錄中新建choose_area.xml布局,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_
android:layout_height="match_parent"
android:background="#fff">
<RelativeLayout
android:layout_
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary">
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/title_text"
android:layout_centerInParent="true"
android:textColor="#fff"
android:textSize="20sp"/>
<Button
android:layout_
android:layout_height="25dp"
android:id="@+id/back_button"
android:layout_marginLeft="10dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:background="@drawable/ic_back"/>
</RelativeLayout>
<ListView
android:layout_
android:layout_height="match_parent"
android:id="@+id/list_view"/>
</LinearLayout>
先是定義了一個頭布局來作為標題欄,將布局高度設定為actionBar的高度,背景色設定為colorPrimary,然后在頭布局中放置了一個TextView用于顯示標題內容,放置了一個Button用于執行回傳操作,注意我已經提前準備好了一張ic_back.png圖片用于作為按鈕的背景圖,這里之所以要自己定義標題欄,是因為碎片中最好不要直接使用ActionBar或Toolbar,不然在復用的時候可能會出現一些你不想看到的效果,
接下來,在頭布局的下面定義了一個ListView,省市縣的資料就將顯示在這里,之所以這次使用了ListView,是因為它會自動給每個子項之間添加一條分隔線,而如果使用RecyclerView想實作同樣的功能則會比較麻煩,這里我們總是選擇最優的實作方案,
接下來也是最關鍵的一步,我們需要撰寫用于遍歷省市縣資料的碎片了,新建ChooseAreaFragment繼承自Fragment,代碼如下所示:
package com.coolweather.android;
import android.app.ProgressDialog;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.coolweather.android.db.City;
import com.coolweather.android.db.County;
import com.coolweather.android.db.Province;
import com.coolweather.android.util.HttpUtil;
import com.coolweather.android.util.Utility;
import org.litepal.LitePal;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Response;
public class ChooseAreaFragment extends Fragment {
public static final int LEVEL_PROVINCE = 0;
public static final int LEVEL_CITY = 1;
public static final int LEVEL_COUNTY = 2;
private ProgressDialog progressDialog;
private TextView titleText;
private Button backButton;
private ListView listView;
private ArrayAdapter<String> adapter;
private List<String> dataList = new ArrayList<>();
/**
* 省串列
*/
private List<Province> provinceList;
/**
* 市串列
*/
private List<City> cityList;
/**
* 縣串列
*/
private List<County> countyList;
/**
* 選中的省份
*/
private Province selectedProvince;
/**
* 選中的市
*/
private City selectedCity;
/**
* 當前選中的級別
*/
private int currentLevel;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
//在onCreateView()方法中先是獲取到了一些控制元件的實體,然后去初始化了ArrayAdapter,并將它設定為ListView的配接器,
View view = inflater.inflate(R.layout.choose_area,container,false);
titleText = (TextView) view.findViewById(R.id.title_text);
backButton = (Button) view.findViewById(R.id.back_button);
listView = (ListView) view.findViewById(R.id.list_view);
adapter = new ArrayAdapter<>(getContext(),android.R.layout.simple_list_item_1,dataList);
listView.setAdapter(adapter);
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
//在onActivityCreated()方法中給ListView和Button設定了點擊事件
super.onActivityCreated(savedInstanceState);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
/**
* 當你點擊了某個省的時候會進入到ListView的onItemClick()方法中,
* 這個時候會根據當前的級別來判斷是去呼叫queryCities()方法還是queryCounties()方法,
* queryCities()方法是去查詢市級資料,而queryCounties()方法是去查詢縣級資料,
* 這兩個方法內部的流程和queryProvinces()方法基本相同
*/
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (currentLevel == LEVEL_PROVINCE) {
selectedProvince = provinceList.get(position);
queryCities();
} else if (currentLevel == LEVEL_CITY) {
selectedCity = cityList.get(position);
queryCounties();
}
}
});
/**
* 在回傳按鈕的點擊事件里,會對當前ListView的串列級別進行判斷,
* 如果當前是縣級串列,那么就回傳到市級串列,
* 如果當前是市級串列,那么就回傳到省級表串列,
* 當回傳到省級串列時,回傳按鈕會自動隱藏,從而也就不需要再做進一步的處理了,
*/
backButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (currentLevel == LEVEL_COUNTY) {
queryCities();
} else if (currentLevel == LEVEL_CITY) {
queryProvince();
}
}
});
//呼叫了queryProvinces()方法,也就是從這里開始加載省級資料的,
queryProvince();
}
/**
* 查詢全國所有的省,優先從資料庫查詢,如果沒有查詢到再去服務器查詢
*/
private void queryProvince() {
//queryProvinces()方法中首先會將頭布局的標題設定成中國,將回傳按鈕隱藏起來,因為省級串列已經不能再回傳了,
titleText.setText("中國");
backButton.setVisibility(View.GONE);
//呼叫LitePal的查詢介面來從資料庫中讀取省級資料,如果讀取到了就直接將資料顯示到界面上,
//如果沒有讀取到就按照14.1節講述的介面組裝出一個請求地址,然后呼叫queryFromServer()方法來從服務器上查詢資料,
provinceList = LitePal.findAll(Province.class);
if (provinceList.size() > 0) {
dataList.clear();
for (Province province : provinceList) {
dataList.add(province.getProvinceName());
}
adapter.notifyDataSetChanged();
listView.setSelection(0);
currentLevel = LEVEL_PROVINCE;
} else {
String address = "http://guolin.tech/api/china";
queryFromService(address,"province");
}
}
/**
* 查詢全國所有的市,優先從資料庫查詢,如果沒有查詢到再去服務器查詢
*/
private void queryCities() {
titleText.setText(selectedProvince.getProvinceName());
backButton.setVisibility(View.VISIBLE);
cityList = LitePal.where("provinceid = ?",String.valueOf(selectedProvince.getId())).find(City.class);
if (cityList.size() > 0) {
dataList.clear();
for (City city : cityList) {
dataList.add(city.getCityName());
}
adapter.notifyDataSetChanged();
listView.setSelection(0);
currentLevel = LEVEL_CITY;
} else {
int provinceCode = selectedProvince.getProvinceCode();
String address = "http://guolin.tech/api/china/" + provinceCode;
queryFromService(address,"city");
}
}
/**
* 查詢全國所有的縣,優先從資料庫查詢,如果沒有查詢到再去服務器查詢
*/
private void queryCounties() {
titleText.setText(selectedCity.getCityName());
backButton.setVisibility(View.VISIBLE);
countyList = LitePal.where("cityid = ?",String.valueOf(selectedCity.getId())).find(County.class);
if (countyList.size() > 0) {
dataList.clear();
for (County county : countyList) {
dataList.add(county.getCountyName());
}
adapter.notifyDataSetChanged();
listView.setSelection(0);
currentLevel = LEVEL_COUNTY;
} else {
int provinceCode = selectedProvince.getProvinceCode();
int cityCode = selectedCity.getCityCode();
String address = "http://guolin.tech/api/china/" + provinceCode + "/" + cityCode;
queryFromService(address,"county");
}
}
/**
* 根據傳入的地址和型別從服務器上查詢省市縣的資料
* queryFromServer()方法中會呼叫HttpUtil的sendOkHttpRequest()方法來向服務器發送請求,
* 回應的資料會回呼到onResponse()方法中,然后去呼叫Utility的handleProvincesResponse()方法,
* 來決議和處理服務器回傳的資料,并存盤到資料庫中,
*/
private void queryFromService(String address,final String type) {
showProgressDialog();
HttpUtil.sendOkHttpRequest(address, new Callback() {
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
String responseText = response.body().string();
boolean result = false;
if ("province".equals(type)) {
result = Utility.handleProvinceResponse(responseText);
} else if ("city".equals(type)) {
result = Utility.handleCityResponse(responseText,selectedProvince.getId());
} else if ("county".equals(type)) {
result = Utility.handleCountyResponse(responseText,selectedCity.getId());
}
/**
* 在決議和處理完資料之后,再次呼叫了queryProvinces()方法來重新加載省級資料,
* 由于queryProvinces()方法牽扯到了UI操作,因此必須要在主執行緒中呼叫,
* 這里借助了runOnUiThread()方法來實作從子執行緒切換到主執行緒,
* 現在資料庫中已經存在了資料,因此呼叫queryProvinces()就會直接將資料顯示到界面上了,
*/
if (result) {
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
closeProgressDialog();
if ("province".equals(type)) {
queryProvince();
} else if ("city".equals(type)) {
queryCities();
} else if ("county".equals(type)) {
queryCounties();
}
}
});
}
}
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
//通過runOnUiThread()方法回到主執行緒處理邏輯
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
closeProgressDialog();
Toast.makeText(getContext(), "加載失敗", Toast.LENGTH_SHORT).show();
}
});
}
});
}
/**
* 顯示進度條對話框
*/
private void showProgressDialog() {
if (progressDialog == null) {
progressDialog = new ProgressDialog(getActivity());
progressDialog.setMessage("正在加載...");
progressDialog.setCanceledOnTouchOutside(false);
}
progressDialog.show();
}
/**
* 關閉進度條
*/
private void closeProgressDialog() {
if (progressDialog != null) {
progressDialog.dismiss();
}
}
}
這樣<我們就把遍歷全國省市縣的功能完成了,可是碎片是不能直接顯示在界面上的,因此我們還需要把它添加到活動里才行,修改activity_main.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="match_parent">
<fragment
android:layout_
android:layout_height="match_parent"
android:id="@+id/choose_area_fragment"
android:name="com.coolweather.android.db.ChooseAreaFragment"/>
</FrameLayout>
布局檔案很簡單,只是定義了一個FrameLayout,然后將ChooseAreaFragment添加進來,并讓它充滿整個布局,另外,我們剛才在碎片的布局里面已經自定義了一個標題欄,因此就不再需要原生的ActionBar了,修改res/values/themes.xml中的代碼,如下所示:
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.CoolWeather" parent="Theme.AppCompat.Light.NoActionBar">
...
</resources>
接著,要宣告程式所需要的權限,修改AndroidManifest.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.coolweather.android">
<uses-permission android:name="android.permission.INTERNET"/>
...
</manifest>
由于我們是通過網路介面來獲取全國省市縣資料的,因此必須要添加訪問網路的權限才行,現在可以運行一下程式了,結果如圖:

可以看到,全國所有省級資料都顯示出來了,還可以繼續查看市級資料,比如點擊浙江省,結果如圖:

這個時候標題欄上會出現一個回傳按鈕,用于回傳上一級串列,然后再點擊杭州市查看縣級資料,結果如圖:

這樣第二階段的開發作業也都完成了,仍然要把代碼提交一下,
git add .
git commit -m "完成遍歷省市縣三級串列的功能,"
git push origin master
14.5 顯示天氣資訊
在第三階段中,我們就要開始去查詢天氣,并且把天氣資訊顯示出來了,由于和風天氣回傳的JSON資料結構非常復雜,如果還使用JSONObject來決議就會很麻煩,這里我們就準備借助GSON來對天氣資訊進行決議了,
14.5.1 定義GSON物體類
GSON的用法很簡單,決議資料只需要一行代碼就能完成了,但前提是要先將資料對應的物體類創建好,由于和風天氣回傳的資料內容非常多,這里我們不可能將所有的內容都利用起來,因此我篩選了一些比較重要的資料來進行決議,首先我們回顧一下回傳資料的大致格式:
{
"HeWeather": [
{
"status": "ok",
"basic": {},
"aqi": {},
"now": {},
"suggestion": {},
"daily_forecast" :[]
}
]
}
其中,basic、aqi、now、suggestion和daily_forecast的內部又都會有具體的內容,那么我們就可以將這5個部分定義成5個物體類,
下面開始來一個個看,basic中具體內容如下所示:
"basic": {
"city":"蘇州",
“id:"CN101190401",
"update":{
"loc":"2016-08-08 21:58"
}
}
其中,city表示城市名,id表示城市對應的天氣id, update中的loc表示天氣的更新時間,我們按照此結構就可以在gson包下建立一個Basic類,代碼如下所示:
package com.coolweather.android.gson;
import com.google.gson.annotations.SerializedName;
public class Basic {
@SerializedName("city")
public String cityName;
@SerializedName("id")
public String weatherId;
public Update update;
public class Update {
@SerializedName("loc")
public String updateTime;
}
}
由于JSON中的一些欄位可能不太適合直接作為Java欄位來命名,因此這里使用了@SerializedName注解的方式來讓JSON欄位和Java欄位之間建立映射關系,
這樣就將Basic類定義好了,其余的幾個物體類也是類似的,使用同樣的方式來定義就可以了,比如aqi中的具體內容如下如示:
"aqi":{
"city":{
"aqi":"44",
"pm25":"13"
}
}
那么,在gson包下新建一個AQI類,代碼如下所示:
package com.coolweather.android.gson;
public class AQI {
public AQICity city;
public class AQICity {
public String aqi;
public String pm25;
}
}
now中的具體內容如下所示:
"now":{
"tmp":"29",
"cond":{
"txt":"陣雨"
}
}
那么,在gson包下新建一個Now類,代碼如下所示:
package com.coolweather.android.gson;
import com.google.gson.annotations.SerializedName;
public class Now {
@SerializedName("tmp")
public String temperature;
@SerializedName("cond")
public More more;
public class More {
@SerializedName("txt")
public String info;
}
}
suggestion中的具體內容如下所示:
"suggestion":{
"comf":{
"txt":"白天天氣較熱,雖然有雨,但仍然無法消弱較高氣溫給人們帶來的暑意,這種天氣會讓您感到不是很舒適,"
},
"cw":{
"txt":"不宜洗車,未來24小時內有雨,如果在此期間洗車,雨水和路上的泥水可能弄臟您的愛車,"
},
"sport":{
"txt":"有降雨,且風力較強,推薦您在室內進行低強度運動,若堅持戶外運動,請選擇避雨防風的地點,"
}
}
那么,在gson包下新建一個Suggestion類,代碼如下所示:
package com.coolweather.android.gson;
import com.google.gson.annotations.SerializedName;
public class Suggestion {
@SerializedName("comf")
public Comfort comfort;
@SerializedName("cw")
public CarWash carWash;
public Sport sport;
public class Comfort{
@SerializedName("txt")
public String info;
}
public class CarWash {
@SerializedName("txt")
public String info;
}
public class Sport {
@SerializedName("txt")
public String info;
}
}
接下來的一項資料就有點特殊了,daily_forecast中的具體內容如下所示:
"daily_forecast":[
{
"date":"2016-08-08",
"cond":{
"txt_d":"陣雨"
},
"tmp":{
"max":"34",
"min":"27"
}
},
{
"date":"2016-08-09",
"cond":{
"txt_d":"多云"
},
"tmp":{
"max":"35",
"min":"29"
}
},
...
]
可以看到,daily_forecast中包含的是一個陣列,陣列中的每一項都代表著未來一天的天氣資訊,針對于這種情況,我們只需要定義出單日天氣的物體類就可以了,然后在宣告物體類參考的時候使用集合型別來進行宣告,那么在gson包下新建一個Forecast類,代碼如下所示:
package com.coolweather.android.gson;
import com.google.gson.annotations.SerializedName;
public class Forecast {
public String date;
@SerializedName("tmp")
public Temperature temperature;
@SerializedName("cond")
public More more;
public class Temperature {
public String max;
public String min;
}
public class More {
@SerializedName("txt_d")
public String info;
}
}
這樣我們就把basic、aqi、now、suggestion和daily_forecast對應的物體類全部都創建好了,接下來還需要再創建一個總的實體類來參考剛剛創建的各個物體類,在gson包下新建一個Weather類,代碼如下所示:
package com.coolweather.android.gson;
import com.google.gson.annotations.SerializedName;
import java.util.List;
public class Weather {
public String status;
public Basic basic;
public AQI aqi;
public Now now;
public Suggestion suggestion;
@SerializedName("daily_forecast")
public List<Forecast> forecastList;
}
在Weather類中,我們對Basic、AQI、Now、Suggestion和Forecast類進行了參考,其中,由于daily_forecast中包含的是一個陣列,因此這里使用了List集合來參考Forecast類,
另外,回傳的天氣資料中還會包含一項status資料,成功回傳ok,失敗則會回傳具體的原因,那么這里也需要添加一個對應的status欄位,現在所有的GSON物體類都定義好了,接下來我們開始撰寫天氣界面,
14.5.2 撰寫天氣界面
首先創建一個用于顯示天氣資訊的活動,右擊com.coolweather.android包→New→Activity→Empty Activity,創建一個WeatherActivity,并將布局名指定成activity_weather.xml,
由于所有的天氣資訊都將在同一個界面上顯示,因此activity_weather.xml會是一個很長的布局檔案,
那么,為了讓里面的代碼不至于混亂不堪,這里我準備使用3.4.1小節學過的引入布局技術,即將界面的不同部分寫在不同的布局檔案里面,再通過引入布局的方式集成到activity_weather.xml中,這樣整個布局檔案就會顯得非常工整,
右擊res/layout→New→Layout resource file,新建一個title.xml作為頭布局,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="?attr/actionBarSize">
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/title_city"
android:layout_centerInParent="true"
android:textColor="#fff"
android:textSize="20sp"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/title_update_time"
android:layout_marginRight="10dp"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:textColor="#fff"
android:textSize="16sp"/>
</RelativeLayout>
頭布局中放置了兩個TextView,一個居中顯示城市名,一個居右顯示更新時間,然后,新建一個now.xml作為當前天氣資訊的布局,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_
android:layout_height="match_parent"
android:layout_margin="15dp">
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/degree_text"
android:layout_gravity="end"
android:textColor="#fff"
android:textSize="60sp"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/weather_info_text"
android:layout_gravity="end"
android:textColor="#fff"
android:textSize="20sp"/>
</LinearLayout>
當前天氣資訊的布局中也是放置了兩個TextView,一個用于顯示當前氣溫,一個用于顯示天氣概況,然后新建forecast.xml作為未來幾天天氣資訊的布局,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:background="#8000">
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_marginLeft="15dp"
android:layout_marginTop="15dp"
android:text="預報"
android:textColor="#fff"
android:textSize="20sp"/>
<LinearLayout
android:layout_
android:layout_height="wrap_content"
android:orientation="vertical"
android:id="@+id/forecast_layout"/>
</LinearLayout>
最外層使用LinearLayout定義了一個半透明的背景,然后使用TextView定義了一個標題,接著又使用一個LinearLayout定義了一個用于顯示未來幾天天氣資訊的布局,不過這個布局中并沒有放入任何內容,因為這是要根據服務器回傳的資料在代碼中動態添加的,
為此,我們還需要再定義一個未來天氣資訊的子項布局,創建forecast_item.xml檔案,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="wrap_content"
android:layout_margin="15dp">
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:id="@+id/date_text"
android:layout_weight="2"
android:textColor="#fff"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:id="@+id/info_text"
android:layout_weight="1"
android:gravity="center"
android:textColor="#fff"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_gravity="center"
android:id="@+id/max_text"
android:layout_weight="1"
android:gravity="right"
android:textColor="#fff"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_gravity="center"
android:id="@+id/min_text"
android:layout_weight="1"
android:gravity="right"
android:textColor="#fff"/>
</LinearLayout>
子項布局中放置了4個TextView,一個用于顯示天氣預報日期,一個用于顯示天氣概況,另外兩個分別用于顯示當天的最高溫度和最低溫度,然后,新建aqi.xml作為空氣質量資訊的布局,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:background="#8000">
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_marginLeft="15dp"
android:layout_marginTop="15dp"
android:text="空氣質量"
android:textColor="#fff"
android:textSize="20sp"/>
<LinearLayout
android:layout_
android:layout_height="wrap_content"
android:layout_margin="15dp">
<RelativeLayout
android:layout_
android:layout_height="match_parent"
android:layout_weight="1">
<LinearLayout
android:layout_
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_centerInParent="true">
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/aqi_text"
android:layout_gravity="center"
android:textColor="#fff"
android:textSize="40sp"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="AQI指數"
android:textColor="#fff"/>
</LinearLayout>
</RelativeLayout>
<RelativeLayout
android:layout_
android:layout_height="match_parent"
android:layout_weight="1">
<LinearLayout
android:layout_
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_centerInParent="true">
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/pm25_text"
android:layout_gravity="center"
android:textColor="#fff"
android:textSize="40sp"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="PM2.5指數"
android:textColor="#fff"/>
</LinearLayout>
</RelativeLayout>
</LinearLayout>
</LinearLayout>
使用LinearLayout定義了一個半透明的背景,然后使用TextView定義了一個標題,接下來,這里使用LinearLayout和RelativeLayout嵌套的方式實作了一個左右平分并且居中對齊的布局,分別用于顯示AQI指數和PM 2.5指數,然后,新建suggestion.xml作為生活建議資訊的布局,代碼如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#8000"
android:layout_margin="15dp">
<TextView
android:layout_
android:layout_height="wrap_content"
android:layout_marginLeft="15dp"
android:layout_marginTop="15dp"
android:text="生活建議"
android:textColor="#fff"
android:textSize="20sp"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/comfort_text"
android:layout_margin="15dp"
android:textColor="#fff"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/car_wash_text"
android:layout_margin="15dp"
android:textColor="#fff"/>
<TextView
android:layout_
android:layout_height="wrap_content"
android:id="@+id/sport_text"
android:layout_margin="15dp"
android:textColor="#fff"/>
</LinearLayout>
同樣也是先定義了一個半透明的背景和一個標題,然后下面使用了3個TextView分別用于顯示舒適度、洗車指數和運動建議的相關資料,
這樣,我們就把天氣界面上每個部分的布局檔案都撰寫好了,接下來的作業就是將它們引入到activity_weather.xml當中,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="match_parent"
android:background="@color/design_default_color_primary">
<ScrollView
android:layout_
android:layout_height="match_parent"
android:id="@+id/weather_layout"
android:scrollbars="none"
android:overScrollMode="never">
<LinearLayout
android:layout_
android:layout_height="wrap_content"
android:orientation="vertical">
<include layout="@layout/title"/>
<include layout="@layout/now"/>
<include layout="@layout/forecast"/>
<include layout="@layout/aqi"/>
<include layout="@layout/suggestion"/>
</LinearLayout>
</ScrollView>
</FrameLayout>
可以看到,首先最外層布局使用了一個FrameLayout,并將它的背景色設定成@color/design_default_color_primary,然后,在FrameLayout中嵌套了一個ScrollView,這是因為天氣界面中的內容比較多,使用ScrollView可以允許我們通過滾動的方式查看螢屏以外的內容,
由于ScrollView的內部只允許存在一個直接子布局,因此這里又嵌套了一個垂直方向的LinearLayout,然后在LinearLayout中將剛才定義的所有布局逐個引入,這樣我們就將天氣界面撰寫完成了,接下來開始撰寫業務邏輯,將天氣顯示到界面上,
14.5.3 將天氣顯示到界面上
首先需要在Utility類中添加一個用于決議天氣JSON資料的方法,如下所示:
public class Utility {
...
/**
* 將回傳的JSON資料決議成Weather物體類
*/
public static Weather handleWeatherResponse(String response) {
try {
JSONObject jsonObject = new JSONObject(response);
JSONArray jsonArray = jsonObject.getJSONArray("HeWeather");
String weatherContent = jsonArray.getJSONObject(0).toString();
return new Gson().fromJson(weatherContent,Weather.class);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
可以看到,handleWeatherResponse()方法中先是通過JSONObject和JSONArray將天氣資料中的主體內容決議出來,即如下內容:
{
"status":"ok",
"basic":{},
"aqi":{},
"now":{},
"suggestion":{},
"daily_forecast":[]
}
由于我們之前已經按照上面的資料格式定義過相應的GSON物體類,因此只需要通過呼叫fromJson()方法就能直接將JSON資料轉換成Weather物件了,
接下來的作業是我們如何在活動中去請求天氣資料,以及將資料展示到界面上,修改WeatherActivity中的代碼,如下所示:
package com.coolweather.android;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import com.coolweather.android.gson.Forecast;
import com.coolweather.android.gson.Weather;
import com.coolweather.android.util.HttpUtil;
import com.coolweather.android.util.Utility;
import java.io.IOException;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Response;
public class WeatherActivity extends AppCompatActivity {
private ScrollView weatherLayout;
private TextView titleCity;
private TextView titleUpdateTime;
private TextView degreeText;
private TextView weatherInfoText;
private LinearLayout forecastLayout;
private TextView aqiText;
private TextView pm25Text;
private TextView comfortText;
private TextView carWashText;
private TextView sportText;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_weather);
//初始化各種控制元件
weatherLayout = (ScrollView) findViewById(R.id.weather_layout);
titleCity = (TextView) findViewById(R.id.title_city);
titleUpdateTime = (TextView) findViewById(R.id.title_update_time);
degreeText = (TextView) findViewById(R.id.degree_text);
weatherInfoText =(TextView) findViewById(R.id.weather_info_text);
forecastLayout = (LinearLayout) findViewById(R.id.forecast_layout);
aqiText = (TextView) findViewById(R.id.aqi_text);
pm25Text = (TextView) findViewById(R.id.pm25_text);
comfortText = (TextView) findViewById(R.id.comfort_text);
carWashText = (TextView) findViewById(R.id.car_wash_text);
sportText = (TextView) findViewById(R.id.sport_text);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String weatherString = prefs.getString("weather",null);
if (weatherString != null) {
//有快取是直接決議天氣資料
Weather weather = Utility.handleWeatherResponse(weatherString);
showWeatherInfo(weather);
} else {
//無快取時去服務器查詢天氣
String weatherId = getIntent().getStringExtra("weather_id");
weatherLayout.setVisibility(View.INVISIBLE);
requestWeather(weatherId);
}
}
/**
* 根據天氣id請求城市天氣資訊
* @param weatherId
*/
public void requestWeather(final String weatherId) {
String weatherUrl = "http://guolin.tech/api/weather?cityid=" +
weatherId + "&key=你申請的KEY值";
HttpUtil.sendOkHttpRequest(weatherUrl, new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
Toast.makeText(WeatherActivity.this, "獲取天氣資訊失敗", Toast.LENGTH_SHORT).show();
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
final String responseText = response.body().string();
final Weather weather = Utility.handleWeatherResponse(responseText);
runOnUiThread(new Runnable() {
@Override
public void run() {
if (weather != null && "ok".equals(weather.status)) {
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(WeatherActivity.this).edit();
editor.putString("weather",responseText);
editor.apply();
showWeatherInfo(weather);
} else {
Toast.makeText(WeatherActivity.this, "獲取天氣資訊失敗", Toast.LENGTH_SHORT).show();
}
}
});
}
});
}
/**
* 處理并展示Weather物體類中的資料
* @param weather
*/
private void showWeatherInfo(Weather weather) {
String cityName = weather.basic.cityName;
String updateTime = weather.basic.update.updateTime.split(" ")[1];
String degree = weather.now.temperature + "℃";
String weatherInfo = weather.now.more.info;
titleCity.setText(cityName);
degreeText.setText(degree);
weatherInfoText.setText(weatherInfo);
titleUpdateTime.setText(updateTime);
forecastLayout.removeAllViews();
for (Forecast forecast : weather.forecastList) {
View view = LayoutInflater.from(this).inflate(R.layout.forecast_item,forecastLayout,false);
TextView dateText = (TextView) view.findViewById(R.id.date_text);
TextView infoText = (TextView) view.findViewById(R.id.info_text);
TextView maxText = (TextView) view.findViewById(R.id.max_text);
TextView minText = (TextView) view.findViewById(R.id.min_text);
dateText.setText(forecast.date);
infoText.setText(forecast.more.info);
maxText.setText(forecast.temperature.max);
minText.setText(forecast.temperature.min);
forecastLayout.addView(view);
}
if (weather.aqi != null) {
aqiText.setText(weather.aqi.city.aqi);
pm25Text.setText(weather.aqi.city.pm25);
}
String comfort = "舒適度:" + weather.suggestion.comfort.info;
String carWash = "洗車指數:" + weather.suggestion.carWash.info;
String sport = "運動建議:" + weather.suggestion.sport.info;
comfortText.setText(comfort);
carWashText.setText(carWash);
sportText.setText(sport);
weatherLayout.setVisibility(View.VISIBLE);
}
}
在onCreate()方法中仍然先是去獲取一些控制元件的實體,然后會嘗試從本地快取中讀取天氣資料,那么第一次肯定是沒有快取的,因此就會從Intent中取出天氣id,并呼叫requestWeather()方法來從服務器請求天氣資料,注意,請求資料的時候先將ScrollView進行隱藏,不然空資料的界面看上去會很奇怪,
requestWeather()方法中先是使用了引數中傳入的天氣id和我們之前申請好的API Key拼裝出一個介面地址,接著呼叫HttpUtil.sendOkHttpRequest()方法來向該地址發出請求,服務器會將相應城市的天氣資訊以JSON格式回傳,然后我們在onResponse()回呼中先呼叫Utility. handleWeatherResponse()方法將回傳的JSON資料轉換成Weather物件,再將當前執行緒切換到主執行緒,然后進行判斷,如果服務器回傳的status狀態是ok,就說明請求天氣成功了,此時將回傳的資料快取到SharedPreferences當中,并呼叫showWeatherInfo()方法來進行內容顯示,
showWeatherInfo()方法就是從Weather物件中獲取資料,然后顯示到相應的控制元件上,注意:在未來幾天天氣預報的部分我們使用了一個for回圈來處理每天的天氣資訊,在回圈中動態加載forecast_item.xml布局并設定相應的資料,然后添加到父布局當中,設定完了所有資料之后,記得要將ScrollView重新變成可見,
當下一次再進入WeatherActivity時,由于快取已經存在了,因此會直接決議并顯示天氣資料,而不會再次發起網路請求了,處理完了WeatherActivity中的邏輯,接下來我們要做的,就是如何從省市縣串列界面跳轉到天氣界面了,修改ChooseAreaFragment中的代碼,如下所示:
public class ChooseAreaFragment extends Fragment {
...
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (currentLevel == LEVEL_PROVINCE) {
selectedProvince = provinceList.get(position);
queryCities();
} else if (currentLevel == LEVEL_CITY) {
selectedCity = cityList.get(position);
queryCounties();
} else if (currentLevel == LEVEL_COUNTY) {
String weatherId = countyList.get(position).getWeatherId();
Intent intent = new Intent(getActivity(), WeatherActivity.class);
intent.putExtra("weather_id",weatherId);
startActivity(intent);
getActivity().finish();
}
}
});
...
}
...
}
在onItemClick()方法中加入了一個if判斷,如果當前級別是LEVEL_COUNTY,就啟動WeatherActivity,并把當前選中縣的天氣id傳遞過去,另外,還需要在MainActivity中加入一個快取資料的判斷才行,修改MainActivity中的代碼,如下所示:
package com.coolweather.android;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
if (prefs.getString("weather",null) != null) {
Intent intent = new Intent(this,WeatherActivity.class);
startActivity(intent);
finish();
}
}
}
可以看到,這里在onCreate()方法的一開始先從SharedPreferences檔案中讀取快取資料,如果不為null就說明之前已經請求過天氣資料了,那么就沒必要讓用戶再次選擇城市,而是直接跳轉到WeatherActivity即可,好了,現在重新運行一下程式,然后選擇江蘇→蘇州→昆山,結果如圖:

然后我們還可以向下滑動查看更多天氣資訊,如圖:

14.5.4 獲取必應每日一圖
出色的天氣軟體不會像我們現在這樣使用一個固定的背景色,而是會根據不同的城市或者天氣情況展示不同的背景圖片,
當然實作這個功能并不復雜,最重要的是需要有服務器的介面支持,不過我實在是沒有精力去準備這樣一套完善的服務器介面,那么為了不讓我們的天氣界面過于單調,這里我準備使用一個巧妙的辦法,
必應是一個由微軟開發的搜索引擎網站,這個網站除了提供強大的搜索功能之外,還有一個非常有特色的地方,就是它每天都會在首頁展示一張精美的背景圖片,如圖:

由于這些圖片都是由必應精挑細選出來的,并且每天都會變化,如果我們使用它們來作為天氣界面的背景圖,不僅可以讓界面變得更加美觀,而且解決了界面一成不變、過于單調的問題,
為此我專門準備了一個獲取必應每日一圖的介面:http://guolin.tech/api/bing_pic,
訪問這個介面,服務器會回傳今日的必應背景圖鏈接:http://cn.bing.com/az/hprichbg/rb/ChicagoHarborLH_ZH-CN9974330969_1920x1080.jpg,
然后我們再使用Glide去加載這張圖片就可以了,
總體思路就是這么簡單,下面開始來動手實作吧,首先修改activity_weather.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="match_parent"
android:background="@color/design_default_color_primary">
<ImageView
android:layout_
android:layout_height="match_parent"
android:id="@+id/pic_img"
android:scaleType="centerCrop"/>
<ScrollView
...
</ScrollView>
</FrameLayout>
在FrameLayout中添加了一個ImageView,并且將它的寬和高都設定成match_parent,由于FrameLayout默認情況下會將控制元件都放置在左上角,因此ScrollView會完全覆寫住ImageView,從而ImageView也就成為背景圖片了,接著修改WeatherActivity中的代碼,如下所示:
public class WeatherActivity extends AppCompatActivity {
...
private ImageView bingPicImg;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_weather);
//初始化各種控制元件
bingPicImg = (ImageView) findViewById(R.id.pic_img);
...
String bingPic = prefs.getString("bing_pic",null);
if (bingPic != null) {
Glide.with(this).load(bingPic).into(bingPicImg);
} else {
loadBingPic();
}
}
/**
* 加載必應每日一圖
*/
private void loadBingPic() {
String requestBingPic = "http://guolin.tech/api/bing_pic";
HttpUtil.sendOkHttpRequest(requestBingPic, new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
final String bingPic = response.body().string();
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(WeatherActivity.this).edit();
editor.putString("bing_pic",bingPic);
editor.apply();
runOnUiThread(new Runnable() {
@Override
public void run() {
Glide.with(WeatherActivity.this).load(bingPic).into(bingPicImg);
}
});
}
});
}
/**
* 根據天氣id請求城市天氣資訊
* @param weatherId
*/
public void requestWeather(final String weatherId) {
...
loadBingPic();
}
/**
* 處理并展示Weather物體類中的資料
* @param weather
*/
private void showWeatherInfo(Weather weather) {
String cityName = weather.basic.cityName;
String updateTime = weather.basic.update.updateTime.split(" ")[1];
String degree = weather.now.temperature + "℃";
String weatherInfo = weather.now.more.info;
titleCity.setText(cityName);
degreeText.setText(degree);
weatherInfoText.setText(weatherInfo);
titleUpdateTime.setText(updateTime);
forecastLayout.removeAllViews();
for (Forecast forecast : weather.forecastList) {
View view = LayoutInflater.from(this).inflate(R.layout.forecast_item,forecastLayout,false);
TextView dateText = (TextView) view.findViewById(R.id.date_text);
TextView infoText = (TextView) view.findViewById(R.id.info_text);
TextView maxText = (TextView) view.findViewById(R.id.max_text);
TextView minText = (TextView) view.findViewById(R.id.min_text);
dateText.setText(forecast.date);
infoText.setText(forecast.more.info);
maxText.setText(forecast.temperature.max);
minText.setText(forecast.temperature.min);
forecastLayout.addView(view);
}
if (weather.aqi != null) {
aqiText.setText(weather.aqi.city.aqi);
pm25Text.setText(weather.aqi.city.pm25);
}
String comfort = "舒適度:" + weather.suggestion.comfort.info;
String carWash = "洗車指數:" + weather.suggestion.carWash.info;
String sport = "運動建議:" + weather.suggestion.sport.info;
comfortText.setText(comfort);
carWashText.setText(carWash);
sportText.setText(sport);
weatherLayout.setVisibility(View.VISIBLE);
}
}
可以看到,首先在onCreate()方法中獲取了新增控制元件ImageView的實體,然后嘗試從SharedPreferences中讀取快取的背景圖片,如果有快取的話就直接使用Glide來加載這張圖片,如果沒有的話就呼叫loadBingPic()方法去請求今日的必應背景圖,
loadBingPic()方法中的邏輯就非常簡單了,先是呼叫了HttpUtil.sendOkHttpRequest()方法獲取到必應背景圖的鏈接,然后將這個鏈接快取到SharedPreferences當中,再將當前執行緒切換到主執行緒,最后使用Glide來加載這張圖片就可以了,
另外需要注意,在requestWeather()方法的最后也需要呼叫一下loadBingPic()方法,這樣在每次請求天氣資訊的時候同時也會重繪背景圖片,現在重新運行一下程式,效果如圖:

雖說只是換了一張背景圖而已,但是整個界面的視覺體驗就完全不一樣了,瞬間提升了好幾個檔次,而且我們的背景圖并不是一成不變的,每天都會是不同的圖片,永遠給人一種耳目一新的感覺,
不過,你會發現背景圖并沒有和狀態欄融合到一起,這樣的話視覺體驗就還是沒有達到最佳的效果,雖說我們在12.7.2小節已經學習過如何將背景圖和狀態欄融合到一起,但當時是借助Design Support庫完成的,而我們這個專案中并沒有引入Design Support庫,
當然如果還是模仿12.7.2小節的做法,引入Design Support庫,然后嵌套CoordinatorLayout、AppBarLayout、CollapsingToolbarLayout等布局,也能實作背景圖和狀態欄融合到一起的效果,不過這樣做就過于麻煩了,這里我準備教你另外一種更簡單的實作方式,修改WeatherActivity中的代碼,如下所示:
public class WeatherActivity extends AppCompatActivity {
...
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= 21) {
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN|View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
getWindow().setStatusBarColor(Color.TRANSPARENT);
}
setContentView(R.layout.activity_weather);
...
}
...
}
由于這個功能是Android 5.0及以上的系統才支持的,因此我們先在代碼中做了一個系統版本號的判斷,只有當版本號大于或等于21,也就是5.0及以上系統時才會執行后面的代碼,
接著,呼叫了getWindow().getDecorView()方法拿到當前活動的DecorView,再呼叫它的setSystemUiVisibility()方法來改變系統UI的顯示,這里傳入View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN和View.SYSTEM_UI_FLAG_LAYOUT_STABLE就表示活動的布局會顯示在狀態欄上面,最后呼叫一下setStatusBarColor()方法將狀態欄設定成透明色,
僅僅這些代碼就可以實作讓背景圖和狀態欄融合到一起的效果了,不過,如果運行一下程式,你會發現還是有些問題,天氣界面的頭布局幾乎和系統狀態欄緊貼到一起了,如圖:

這是由于系統狀態欄已經成為我們布局的一部分,因此沒有單獨為它留出空間,當然,這個問題也是非常好解決的,借助android:fitsSystemWindows屬性就可以了,修改activity_weather.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="match_parent"
android:background="@color/design_default_color_primary">
...
<ScrollView
android:layout_
android:layout_height="match_parent"
android:id="@+id/weather_layout"
android:scrollbars="none"
android:overScrollMode="never">
<LinearLayout
android:layout_
android:layout_height="wrap_content"
android:orientation="vertical"
android:fitsSystemWindows="true">
<include layout="@layout/title"/>
<include layout="@layout/now"/>
<include layout="@layout/forecast"/>
<include layout="@layout/aqi"/>
<include layout="@layout/suggestion"/>
</LinearLayout>
</ScrollView>
</FrameLayout>
這里在ScrollView的LinearLayout中增加了android:fitsSystemWindows屬性,設定成true就表示會為系統狀態欄留出空間,現在重新運行一下代碼,效果如圖:

OK,這樣第三階段的開發作業也都完成了,我們把代碼提交一下,
git add .
git commit -m "加入顯示天氣資訊的功能,"
git push origin master
14.6 手動更新天氣和切換城市
經過第三階段的開發,現在酷歐天氣的主體功能已經有了,不過你會發現目前存在著一個比較嚴重的bug,就是當你選中了某一個城市之后,就沒法再去查看其他城市的天氣了,即使退出程式,下次進來的時候還會直接跳轉到WeatherActivity,
因此,在第四階段中我們要加入切換城市的功能,并且為了能夠實時獲取到最新的天氣,我們還會加入手動更新天氣的功能,
14.6.1 手動更新天氣
先來實作一下手動更新天氣的功能,由于我們在上一節中對天氣資訊進行了快取,目前每次展示的都是快取中的資料,因此現在非常需要一種方式能夠讓用戶手動更新天氣資訊,
至于如何觸發更新事件呢?準備采用下拉重繪的方式,
首先,在應用或模塊的 build.gradle 檔案中添加所需工件的依賴項:
dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
}
其次,修改activity_weather.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="match_parent"
android:background="@color/design_default_color_primary">
...
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:layout_
android:layout_height="match_parent"
android:id="@+id/swipe_refresh">
<ScrollView
...
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>
這里在ScrollView的外面又嵌套了一層SwipeRefreshLayout,這樣ScrollView就自動擁有下拉重繪功能了,
然后,修改WeatherActivity中的代碼,加入更新天氣的處理邏輯,如下所示:
public class WeatherActivity extends AppCompatActivity {
...
public SwipeRefreshLayout swipeRefreshLayout;
private String mWeatherId;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= 21) {
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN|View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
getWindow().setStatusBarColor(Color.TRANSPARENT);
}
setContentView(R.layout.activity_weather);
//初始化各種控制元件
...
swipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_refresh);
swipeRefreshLayout.setColorSchemeResources(com.google.android.material.R.color.design_default_color_primary);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String weatherString = prefs.getString("weather",null);
String bingPic = prefs.getString("bing_pic",null);
if (bingPic != null) {
Glide.with(this).load(bingPic).into(bingPicImg);
} else {
loadBingPic();
}
if (weatherString != null) {
//有快取是直接決議天氣資料
Weather weather = Utility.handleWeatherResponse(weatherString);
mWeatherId = weather.basic.weatherId;
showWeatherInfo(weather);
} else {
//無快取時去服務器查詢天氣
mWeatherId = getIntent().getStringExtra("weather_id");
//String weatherId = getIntent().getStringExtra("weather_id");
weatherLayout.setVisibility(View.INVISIBLE);
requestWeather(mWeatherId);
}
swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh() {
requestWeather(mWeatherId);
}
});
}
...
/**
* 根據天氣id請求城市天氣資訊
* @param weatherId
*/
public void requestWeather(final String weatherId) {
String weatherUrl = "http://guolin.tech/api/weather?cityid=" +
weatherId + "&key=你的KEY的值";
HttpUtil.sendOkHttpRequest(weatherUrl, new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
Toast.makeText(WeatherActivity.this, "獲取天氣資訊失敗", Toast.LENGTH_SHORT).show();
swipeRefreshLayout.setRefreshing(false);
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
final String responseText = response.body().string();
final Weather weather = Utility.handleWeatherResponse(responseText);
runOnUiThread(new Runnable() {
@Override
public void run() {
if (weather != null && "ok".equals(weather.status)) {
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(WeatherActivity.this).edit();
editor.putString("weather",responseText);
editor.apply();
mWeatherId = weather.basic.weatherId;
showWeatherInfo(weather);
} else {
Toast.makeText(WeatherActivity.this, "獲取天氣資訊失敗", Toast.LENGTH_SHORT).show();
}
swipeRefreshLayout.setRefreshing(false);
}
});
}
});
loadBingPic();
}
...
}
首先,在onCreate()方法中獲取到了SwipeRefreshLayout的實體,然后呼叫setColorSchemeResources()方法來設定下拉重繪進度條的顏色.,接著,定義了一個mWeatherId變數,用于記錄城市的天氣id,然后呼叫setOnRefreshListener()方法來設定一個下拉重繪的監聽器,當觸發了下拉重繪操作的時候,就會回呼這個監聽器的onRefresh()方法,我們在這里去呼叫requestWeather()方法請求天氣資訊就可以了,
不要忘記,當請求結束后,還需要呼叫SwipeRefreshLayout的setRefreshing()方法并傳入false,用于表示重繪事件結束,并隱藏重繪進度條,
現在重新運行一下程式,并在螢屏的主界面向下拖動,效果如圖:

更新完天氣資訊之后,下拉進度潭訓自動消失,
14.6.2 切換城市
既然是要切換城市,那么就肯定需要遍歷全國省市縣的資料,而這個功能我們早在14.4節就已經完成了,并且當時考慮為了方便后面的復用,特意選擇了在碎片當中實作,因此,我們其實只需要在天氣界面的布局中引入這個碎片,就可以快速集成切換城市功能了,
雖說實作原理很簡單,但是顯然我們也不可能讓引入的碎片把天氣界面遮擋住,這又該怎么辦呢?還記得12.3節學過的滑動選單功能嗎?將碎片放入到滑動選單中真是再合適不過了,正常情況下它不占據主界面的任何空間,想要切換城市的時候只需要通過滑動的方式將選單顯示出來就可以了,
下面我們就按照這種思路來實作,首先按照Material Design的建議,我們需要在頭布局中加入一個切換城市的按鈕,不然的話用戶可能根本就不知道螢屏的左側邊緣是可以拖動的,修改title.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="?attr/actionBarSize">
<Button
android:layout_
android:layout_height="30dp"
android:id="@+id/nav_button"
android:layout_marginLeft="10dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:background="@drawable/ic_home"/>
...
</RelativeLayout>
這里添加了一個Button作為切換城市的按鈕,并且讓它居左顯示,另外,我提前準備好了一張圖片來作為按鈕的背景圖,接著修改activity_weather.xml布局來加入滑動選單功能,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_
android:layout_height="match_parent"
android:background="@color/design_default_color_primary">
...
<androidx.drawerlayout.widget.DrawerLayout
android:layout_
android:layout_height="match_parent"
android:id="@+id/drawer_layout">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:layout_
android:layout_height="match_parent"
android:id="@+id/swipe_refresh">
...
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<fragment
android:id="@+id/choose_area_fragment"
android:name="com.coolweather.android.ChooseAreaFragment"
android:layout_
android:layout_height="match_parent"
android:layout_gravity="start" />
</androidx.drawerlayout.widget.DrawerLayout>
</FrameLayout>
在SwipeRefreshLayout的外面又嵌套了一層DrawerLayout,DrawerLayout中的第一個子控制元件用于作為主螢屏中顯示的內容,第二個子控制元件用于作為滑動選單中顯示的內容,因此這里我們在第二個子控制元件的位置添加了用于遍歷省市縣資料的碎片,
接下來,需要在WeatherActivity中加入滑動選單的邏輯處理,修改WeatherActivity中的代碼,如下所示:
public class WeatherActivity extends AppCompatActivity {
...
public DrawerLayout drawerLayout;
private Button navButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
...
drawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout);
navButton = (Button) findViewById(R.id.nav_button);
...
navButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
drawerLayout.openDrawer(GravityCompat.START);
}
});
}
...
}
首先在onCreate()方法中獲取到新增的DrawerLayout和Button的實體,然后在Button的點擊事件中呼叫DrawerLayout的openDrawer()方法來打開滑動選單就可以了,
不過現在還沒有結束,因為這僅僅是打開了滑動選單而已,我們還需要處理切換城市后的邏輯才行,這個作業就必須要在ChooseAreaFragment中進行了,因為之前選中了某個城市后是跳轉到WeatherActivity的,而現在由于我們本來就是在WeatherActivity當中的,因此并不需要跳轉,只是去請求新選擇城市的天氣資訊就可以了,
那么很顯然這里我們需要根據ChooseAreaFragment的不同狀態來進行不同的邏輯處理,修改ChooseAreaFragment中的代碼,如下所示:
public class ChooseAreaFragment extends Fragment {
...
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (currentLevel == LEVEL_PROVINCE) {
selectedProvince = provinceList.get(position);
queryCities();
} else if (currentLevel == LEVEL_CITY) {
selectedCity = cityList.get(position);
queryCounties();
} else if (currentLevel == LEVEL_COUNTY) {
String weatherId = countyList.get(position).getWeatherId();
if (getActivity() instanceof MainActivity) {
Intent intent = new Intent(getActivity(), WeatherActivity.class);
intent.putExtra("weather_id",weatherId);
startActivity(intent);
getActivity().finish();
} else if (getActivity() instanceof WeatherActivity) {
WeatherActivity activity = (WeatherActivity) getActivity();
activity.drawerLayout.closeDrawers();
activity.swipeRefreshLayout.setRefreshing(true);
activity.requestWeather(weatherId);
}
}
}
});
...
}
...
}
這里使用了一個Java中的小技巧,instanceof關鍵字可以用來判斷一個物件是否屬于某個類的實體,
在碎片中呼叫getActivity()方法,然后配合instanceof關鍵字,就能輕松判斷出該碎片是在MainActivity當中,還是在WeatherActivity當中,如果是在MainActivity當中,那么處理邏輯不變,如果是在WeatherActivity當中,那么就關倍訓動選單,顯示下拉重繪進度條,然后請求新城市的天氣資訊,
這樣我們就把切換城市的功能全部完成了,現在可以重新運行一下程式,效果如圖:

可以看到,標題欄上多出了一個用于切換城市的按鈕,點擊該按鈕,或者在螢屏的左側邊緣進行拖動,就能讓滑動選單界面顯示出來了,如圖:

然后我們就可以在這里切換其他城市了,選中城市之后滑動選單會自動關閉,并且主界面上的天氣資訊也會更新成你選擇的那個城市,這樣,第四階段的開發任務也完成了,當然,仍然不要忘記提交代碼,
git add .
git commit -m "新增切換城市和手動更新天氣的功能,"
git push origin master
14.7 后臺自動更新天氣
為了要讓酷歐天氣更加智能,在第五階段我們準備加入后臺自動更新天氣的功能,這樣就可以盡可能地保證用戶每次打開軟體時看到的都是最新的天氣資訊,
要想實作上述功能,就需要創建一個長期在后臺運行的定時任務,這個功能肯定是難不倒你的,因為我們在13.5節中就已經學習過了,
首先在service包下新建一個服務,右擊com.coolweather.android.service→New→Service→Service,創建一個AutoUpdateService,并將Exported和Enabled這兩個屬性都勾中,然后修改AutoUpdateService中的代碼,如下所示:
package com.coolweather.android.service;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.IBinder;
import android.os.SystemClock;
import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import com.coolweather.android.gson.Weather;
import com.coolweather.android.util.HttpUtil;
import com.coolweather.android.util.Utility;
import java.io.IOException;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Response;
public class AutoUpdateService extends Service {
@Override
public IBinder onBind(Intent intent) {
// TODO: Return the communication channel to the service.
//throw new UnsupportedOperationException("Not yet implemented");
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
updateWeather();
updateBingPic();
AlarmManager manager = (AlarmManager) getSystemService(ALARM_SERVICE);
int anHour = 8 * 60 * 60 * 1000;//8小時的毫秒數
long triggerAtTime = SystemClock.elapsedRealtime() + anHour;
Intent i = new Intent(this,AutoUpdateService.class);
PendingIntent pendingIntent = PendingIntent.getService(this,0,i,0);
manager.cancel(pendingIntent);
manager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP,triggerAtTime,pendingIntent);
return super.onStartCommand(intent,flags,startId);
}
/**
* 更新必應每日一圖
*/
private void updateBingPic() {
String requestBingPic = "http://guolin.tech/api/bing_pic";
HttpUtil.sendOkHttpRequest(requestBingPic, new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
e.printStackTrace();
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
String bingPic = response.body().string();
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(AutoUpdateService.this).edit();
editor.putString("bing_pic",bingPic);
editor.apply();
}
});
}
/**
* 更新天氣資訊
*/
private void updateWeather() {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
String weatherString = prefs.getString("weather",null);
if (weatherString != null) {
//有快取時直接決議天氣資料
Weather weather = Utility.handleWeatherResponse(weatherString);
String weatherId = weather.basic.weatherId;
String weatherUrl = "http://guolin.tech/api/weather?cityid=" +
weatherId + "key=你申請的KEY值";
HttpUtil.sendOkHttpRequest(weatherUrl, new Callback() {
@Override
public void onFailure(@NonNull Call call, @NonNull IOException e) {
e.printStackTrace();
}
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
String responseText = response.body().string();
Weather weather = Utility.handleWeatherResponse(responseText);
if (weather != null && "ok".equals(weather.status)) {
SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(AutoUpdateService.this).edit();
editor.putString("weather",responseText);
editor.apply();
}
}
});
}
}
}
可以看到,在onStartCommand()方法中先是呼叫了updateWeather()方法來更新天氣,然后呼叫了updateBingPic()方法來更新背景圖片,這里我們將更新后的資料直接存盤到SharedPreferences檔案中就可以了,因為打開WeatherActivity的時候都會優先從SharedPreferences快取中讀取資料,
之后就是學習過的創建定時任務的技巧,為了保證軟體不會消耗過多的流量,這里將時間間隔設定為8小時,8小時后AutoUpdateReceiver的onStartCommand()方法就會重新執行,這樣也就實作后臺定時更新的功能了,
不過,還需要在代碼某處去激活AutoUpdateService這個服務才行,修改WeatherActivity中的代碼,如下所示:
public class WeatherActivity extends AppCompatActivity {
...
/**
* 處理并展示Weather物體類中的資料
* @param weather
*/
private void showWeatherInfo(Weather weather) {
...
weatherLayout.setVisibility(View.VISIBLE);
Intent intent = new Intent(this, AutoUpdateService.class);
startService(intent);
}
}
可以看到,這里在showWeatherInfo()方法的最后加入啟動AutoUpdateService這個服務的代碼,這樣只要一旦選中了某個城市并成功更新天氣之后,AutoUpdateService就會一直在后臺運行,并保證每8小時更新一次天氣,現在可以再提交一下代碼:
git add .
git commit -m "增加后臺自動更新天氣功能,"
git push origin master
14.8 修改圖示和名稱
目前的酷歐天氣一直使用Android Studio自動生成的圖示確實不太合適,是時候需要換一下了,
這里我事先準備好了一張圖片來作為軟體圖示,如圖:

理論上來講,我們應該給這個圖示提供幾種不同解析度的版本,然后分別放入到相應解析度的mipmap目錄下,這里簡單起見,我就都使用同一張圖了,將這張圖片命名成logo.png,放入到所有以mipmap開頭的目錄下,然后修改AndroidManifest.xml中的代碼,如下所示:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.coolweather.android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name="org.litepal.LitePalApplication"
android:allowBackup="true"
android:icon="@mipmap/logo"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CoolWeather">
...
</application>
</manifest>
這里將<application>標簽的android:icon屬性指定成@mipmap/logo就可以修改程式圖示了,
接下來我們還需要修改一下程式的名稱,打開res/values/string.xml檔案,其中app_name對應的就是程式名稱,將它修改成酷歐天氣即可,如下所示:
<resources>
<string name="app_name">酷歐天氣</string>
</resources>
現在重新運行一遍程式,這時觀察酷歐天氣的桌面圖示,如圖:

養成良好的習慣,仍然不要忘記提交代碼,
git add .
git commit -m "修改程式圖示和名稱,"
git push origin master
14.9 你還可以做的事情
經過五個階段的開發,現在的酷歐天氣只能說是具備了一些最基本的功能,和那些商用的天氣軟體比起來還有很大的差距,因此你仍然還有非常巨大的發揮空間來對它進行完善,
比如說以下功能是你可以考慮加入到酷歐天氣中的,
? 增加設定選項,讓用戶選擇是否允許后臺自動更新天氣,以及設定更新的頻率,
? 優化軟體界面,提供多套與天氣對應的圖片,讓程式可以根據不同的天氣自動切換背景圖,
? 允許選擇多個城市,可以同時觀察多個城市的天氣資訊,不用來回切換,
? 提供更加完整的天氣資訊,目前我們只使用了和風天氣回傳的一小部分資料而已,
另外,由于酷歐天氣的原始碼已經托管在了GitHub上面,如果你想在現有代碼的基礎上繼續對這個專案進行完善,就可以使用GitHub的Fork功能,
首先登錄你自己的GitHub賬號,然后打開酷歐天氣版本庫的主頁:https://github.com/guolindev/coolweather,這時在頁面頭部的最右側會有一個Fork按鈕,如圖:

點擊一下Fork按鈕,就可以將酷歐天氣這個專案復制一份到你的賬號下,再使用git clone命令將它克隆到本地,然后你就可以在現有代碼的基礎上隨心所欲地添加任何功能并提交了,
個人學習筆記,針對本人在自學中遇到的問題,
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/472414.html
標籤:Android
