显示天气信息

实现逻辑层代码

获取实时天气接口时返回的JSON数据格式,简化后的内容如下所示:

{ 
"status": "ok",
"result": {
"realtime": {
"temperature": 23.16,
"skycon": "WIND",
"air_quality": {
"aqi": { "chn": 17.0 }
}
}
}
}

那么我们只需要按照这种JSON格式来定义响应的数据模型即可。在logic/model包下新建一个RealtimeResponse.kt文件,并在这个文件中编写如下代码:

data class RealtimeResponse(val status: String, val Result){

data class Result(val realtime: Realtime)

data class Realtime(val skycon: String, val temperature: Float,
@SerializedName("air_quality") val airQuality: AirQuality)

data class AirQuality(val aqi: AQI)

data class AQI(val chn: Float)
}

注意,这里我们将所有的数据模型类都定义在了RealtimieResponse的内部,这样可以防止出现和其他接口的数据模型类有同名冲突的情况

获取未来几天天气信息接口所返回的JSON数据格式,简化后的内容如下 所示:

{ 
"status": "ok",
"result": {
"daily": {
"temperature": [ {"max": 25.7, "min": 20.3}, ... ],
"skycon": [ {"value": "CLOUDY", "date":"2019-10-20T00:00+08:00"}, ... ],
"life_index": {
"coldRisk": [ {"desc": "易发"}, ... ],
"carWashing": [ {"desc": "适宜"}, ... ],
"ultraviolet": [ {"desc": "无"}, ... ],
"dressing": [ {"desc": "舒适"}, ... ]
}
}
}
}

这段JSON数据格式最大的特别之处在于,它返回的天气数据全部是数组形式的,数组中的每个元素都对应这一天的数据。在数据模型中,我们可以使用List集合来对JSON中的数组元素进行映射。同样在logic/model包下新建一个DailyResponse.kt文件,并编写如下代码:

data class DailyResponse(val status: String, val result: Result) { 

data class Result(val daily: Daily)

data class Daily(val temperature: List<Temperature>, val skycon: List<Skycon>,
@SerializedName("life_index") val lifeIndex: LifeIndex)

data class Temperature(val max: Float, val min: Float)

data class Skycon(val value: String, val date: Date)

data class LifeIndex(val coldRisk: List<LifeDescription>, val carWashing:
List<LifeDescription>, val ultraviolet: List<LifeDescription>,
val dressing: List<LifeDescription>)

data class LifeDescription(val desc: String)

}

这次我们将所有的数据模型类都定义在了DailyResponse的内部,你会发现,虽然它和RealtimeResponse内部都包含了一个REsult类,但是它们之间完全不会冲突。

另外,我们还需要在logic/model包下再定义一个Weather类,用于将Realtime和Daily对象封装起来,代码如下所示:

data class Weather(val realtime: RealtimeResponse.Realtime, val daily: DailyResponse.Daily)

将数据模型都定义好了之后,接下来又该开始编写网络层相关的代码了。

定义一个用于访问天气信息API的Retrofit接口,在logic/network包下新建WeatherService接口,代码如下所示:

interface WeatherService{
@GET("v2.5/{$SunnyWeatherApplication.TOKEN}/{lng},{lat}/realtime.json")
fun getRealtimeWeather(@Path("lng") lng: String, @Path("lat") lat: String):
Call<RealtimeResponse>

@GET("v2.5/${SunnyWeatherApplication.TOKEN}/{lng},{lat}/daily.json")
fun getDailyWeather(@Path("lng") lng: String, @Path("lat" )lat: String):
Call<DailyResponse>
}

可以看到,这里我们定义了两个方法:getRealtimeWeather()方法用于获取实时的天气信息,getDailyWeather()方法用于获取未来的天气信息。在每个方法的上面任然还是使用@GET注解来声明要访问的API接口,并且我们还使用了@Path注解来向请求接口中的动态传入经纬度坐标。这两个方法的返回值分别被声明成了我们定义好的两个数据模型类。

接下来我们需要在SunnyWeatherNetwork这个网络数据源访问入口对新增的WeatherService接口进行封装。修改SunnyWeatherNetwork中的代码,如下所示:

object SunnyWeatherNetwork{

private val placeService = ServiceCreator.create<PlaceService>()

suspend fun searchPlaces = (query: String) = placeService.searchPlaces(query).await()

private val weatherService = ServiceCreator.create<WeatherService>()

suspend fun getDaliyWeather(lng: String, lat: String) = weatherService.getDailyWeather(lng,lat).await()

suspend fun getRealtimeWeather(lng: String, lat: String) = weatherService.getRealtimeWeather(lng,lat).await()

private suspend fun <T> Call<T>.await(): T{
return suspendCoroutine{ continuation ->
enqueue(object: Callback<T>{
override fun onResponse(call: Call<T>, response: Response<T>){
val body = response.body()
if(body != null) continuation.resume()
else continuation.resumeWithExceptin(
RuntimeException("response body is null")
)
}

override fun onFailure(call: Call<T>, t: Throwable){
continuation.resumeWithException(t)
}
})

}

}
}
}

完成了网络层的代码,接下来应该去仓库查进行相关的代码实现了。修改Repository中的代码,如下所示:

object Repository{

fun searchPlaces(query: String) = liveData(Dispatcher.IO){
val result = try{
val placeResponse = SunnyWeatherNetwork.searchPlace(query)
if(placeResponse.status == "ok"){
val places = placeResponse.places
Result.success(places)
}else{
Result.failure(RuntimeException("response status is${placeResponse.status}"))
}catch(e: Exception){
Result.failure<List<Place>(e)
}
}
emit(result)
}

fun refreshWeather(lng: String, lat: String) = liveData(Dispatchers.IO) {
val result = try {
coroutineScope {
val deferredRealtime = async {
SunnyWeatherNetwork.getRealtimeWeather(lng, lat)
}
val deferredDaily = async {
SunnyWeatherNetwork.getDailyWeather(lng, lat)
}
val realtimeResponse = deferredRealtime.await()
val dailyResponse = deferredDaily.await()
if (realtimeResponse.status == "ok" && dailyResponse.status == "ok") {
val weather = Weather(realtimeResponse.result.realtime,
dailyResponse.result.daily)
Result.success(weather)
} else {
Result.failure(
RuntimeException(
"realtime response status is ${realtimeResponse.status}" +
"daily response status is ${dailyResponse.status}"
)
)
}
}
} catch (e: Exception) {
Result.failure<Weather>(e)
}
emit(result)
}
}

注意,在仓库层我们并没有提供两个分别用于获取实时天气信息和未来天气信息的方法,而是 提供了一个refreshWeather()方法用来刷新天气信息。因为对于调用方而言,需要调用两次 请求才能获得其想要的所有天气数据明显是比较烦琐的行为,因此最好的做法就是在仓库层再 进行一次统一的封装。

一般代码写到这里就已经足够好了,但是其实我们还可以做到更好。可以在某个统一的入口函数中进行封 装,使得只要进行一次try catch处理就行了,下面我们就来学习一下具体应该怎样实现。

object Repository{

fun searchPlaces(query: String) = fire(Dispatchers.IO){
val placeResponse = SunnyWeatherNetwork.searchPlaces(query)
if(placeResponse.status == "ok"){
val places = placeResponse.places
Result.success(places)
}else{
Result.failure(RuntimeException("response status is ${placeResponse.status}"))
}
}

fun refreshWeather(lng: String, lat: String) = fire(Dispatchers.IO){
coroutineScope{
val deferredRealtime = async{
SunnyWeatherNetwork.getRealtimeWeather(lng, lat)
}
val deferredDaily = async{
SunnyWeatherNetwork.getDailyWeather(lng,lat)
}
val realtimeResponse = defferredRealtime.await()
val dailyResponse = defferredDaily.await()
if(realtimeResponse.statuse == "ok" && dailyResponse.status == "ok"){
val weather = Weather(realtiemResponse.result.realtime,dailyResponse.result.daily)
Result.success(weather)
}else{
Result.failure(
RuntimeException(
"realtime response status is ${realtimeResponse.status}" +
"daily response status is ${dailyResponse.status}"
)
)
}
}

private fun <T> fire(context: oroutineContext, block suspend() -> Result<T>) = liveData<Result<T>>(context) {
val result = try{
block()
}catch(e: Exception){
Result.failure<T>(e)
}
emit(result)
}
}
}

写到这里,逻辑层的实现就只剩最后一步了:定义ViewModel层。在ui/weather包下新建一个 WeatherViewModel,代码如下所示:

class WeatherViewModel: ViewModel(){

private val locationLiveData = MutableLiveData<Location>()

var locationLng = ""

var locationLat = ""

var placeName = ""

val weatherLiveData = locationLiveData.switchMap(location -> Repository.refreshWeather(loaction.lng,location.lat))

fun refreshWeather(lng: String, lat :String){
locationLiveData.value = Location(lng, lat)
}

}

我们还在WeatherViewModel中定义了locationLng、locationLat和placeName 这3个变量,它们都是和界面相关的数据,放到ViewModel中可以保证它们在手机屏幕发生旋 转的时候不会丢失,稍后在编写UI层代码的时候会用到这几个变量。

这样我们就将逻辑层的代码实现全部完成了,接下来又该去编写界面了。

实现UI层代码

首先创建一个用于显示天气信息的Activity。右击ui/weather包->New->Activity->Empty->Activity,创建一个WeatherActivity,并将布局名指定成activity_weather.xml

右击res/layout->New->Layout resoruce file,新建一个now.xml作为当前天气信息的布局,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/nowLayout"
android:layout_width="match_parent"
android:layout_height="530dp"
android:orientation="vertical">

<FrameLayout
android:id="@+id/titleLayout"
android:layout_width="match_parent"
android:layout_height="70dp">

<TextView
android:id="@+id/placeName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="60dp"
android:layout_marginEnd="60dp"
android:layout_gravity="center"
android:singleLine="true"
android:ellipsize="middle"
android:textColor="#fff"
android:textSize="22sp" />

</FrameLayout>

<LinearLayout
android:id="@+id/bodyLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:orientation="vertical">

<TextView
android:id="@+id/currentTemp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textColor="#fff"
android:textSize="70sp" />

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp">

<TextView
android:id="@+id/currentSky"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#fff"
android:textSize="18sp" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="13dp"
android:textColor="#fff"
android:textSize="18sp"
android:text="|" />

<TextView
android:id="@+id/currentAQI"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="13dp"
android:textColor="#fff"
android:textSize="18sp" />

</LinearLayout>

</LinearLayout>

</RelativeLayout>

这段代码主要分为上下两个布局:上半部分式头布局,里面只放置了一个TextView,用于显示城市名;下半部分式当前天气信息的布局,里面放置了几个TexView,分别用于显示当前气温、当前天气状况以及当前空气质量。

然后新建forecast.xml作为未来几天天气信息的布局,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="15dp"
android:layout_marginRight="15dp"
android:layout_marginTop="15dp"
app:cardCornerRadius="4dp">

<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="20dp"
android:layout_marginBottom="20dp"
android:text="预报"
android:textColor="?android:attr/textColorPrimary"
android:textSize="20sp"/>

<LinearLayout
android:id="@+id/forecastLayout"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</LinearLayout>

</LinearLayout>

</com.google.android.material.card.MaterialCardView>

最外层使用了MaterialCardView来实现卡片式布局的背景效果,然后使用TextView定义了一个标题,接着又使用了一个LinearLayout定义了一个用于显示未来天气信息的布局。不过这个布局中并没有放入任何内容,因为这是要根据服务器返回的数据在代码中动态添加的。

为此,我们需要再定义一个未来天气信息的子项布局,创建forecast_item.xml文件,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="15dp">

<TextView
android:id="@+id/dateInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="4" />

<ImageView
android:id="@+id/skyIcon"
android:layout_width="20dp"
android:layout_height="20dp" />

<TextView
android:id="@+id/skyInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="3"
android:gravity="center" />

<TextView
android:id="@+id/temperatureInfo"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="3"
android:gravity="end" />

</LinearLayout>

这个子项布局包含了3个TextView和1个ImageView,分别用于显示天气预报的日期、天气的 图标、天气的情况以及当天的最低温度和最高温度。

然后新建life_index.xml作为生活指数的布局,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="15dp"
app:cardCornerRadius="4dp">

<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="20dp"
android:text="生活指数"
android:textColor="?android:attr/textColorPrimary"
android:textSize="20sp"/>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp">

<RelativeLayout
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_weight="1">

<ImageView
android:id="@+id/coldRiskImg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="20dp"
android:src="@drawable/ic_coldrisk" />

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toEndOf="@id/coldRiskImg"
android:layout_marginStart="20dp"
android:orientation="vertical">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="感冒" />

<TextView
android:id="@+id/coldRiskText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

</RelativeLayout>

<RelativeLayout
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_weight="1">

<ImageView
android:id="@+id/dressingImg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="20dp"
android:src="@drawable/ic_dressing" />

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toEndOf="@id/dressingImg"
android:layout_marginStart="20dp"
android:orientation="vertical">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="穿衣" />

<TextView
android:id="@+id/dressingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

</RelativeLayout>

</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp">
<RelativeLayout
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_weight="1">

<ImageView
android:id="@+id/ultravioletImg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="20dp"
android:src="@drawable/ic_ultraviolet" />

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_toEndOf="@id/ultravioletImg"
android:layout_marginStart="20dp"
android:orientation="vertical">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="实时紫外线" />

<TextView
android:id="@+id/ultravioletText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

</RelativeLayout>

<RelativeLayout
android:layout_width="0dp"
android:layout_height="60dp"
android:layout_weight="1">

<ImageView
android:id="@+id/carWashingImg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="20dp"
android:src="@drawable/ic_carwashing" />

<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_toEndOf="@id/carWashingImg"
android:layout_marginStart="20dp"
android:orientation="vertical">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="洗车" />
<TextView
android:id="@+id/carWashingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary" />
</LinearLayout>

</RelativeLayout>

</LinearLayout>

</LinearLayout>

</com.google.android.material.card.MaterialCardView>

这个布局中的代码虽然看上去很长,但是并不复杂。其实它就是定义了一个四方格的布局,分 别用于显示感冒、穿衣、实时紫外线以及洗车的指数。所以只要看懂其中一个方格中的布局, 其他方格中的布局自然就明白了。每个方格中都有一个ImageView用来显示图标,一个 TextView用来显示标题,还有一个TextView用来显示指数。相信你只要仔细看一看,这个布局 还是很好理解的。

这样我们就把天气界面上每个部分的布局文件都编写好了,接下来的工作就是将它们引入 activity_weather.xml中,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/weatherLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="none"
android:overScrollMode="never"
android:visibility="invisible">

<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<include layout="@layout/now" />

<include layout="@layout/forecast" />

<include layout="@layout/life_index" />

</LinearLayout>

</ScrollView>

可以看到,最外层布局使用了一个ScrollView,这是因为天气界面中的内容比较多,使用 ScrollView就可以通过滚动的方式查看屏幕以外的内容。由于ScrollView的内部只允许存在一 个直接子布局,因此这里又嵌套了一个垂直方向的LinearLayout,然后在LinearLayout中将 刚才定义的所有布局逐个引入。

注意,一开始的时候我们是将ScrollView隐藏起来的,不然空数据的界面看上去会很奇怪。等 到天气数据请求成功之后,会通过代码的方式再将ScrollView显示出来。

这样我们就将天气界面布局编写完成了,接下来应该去实现WeatherActivity中的代码了。不 过在这之前,我们还要编写一个额外的转换函数。因为彩云天气返回的数据中,天气情况都是 一些诸如CLOUDY、WIND之类的天气代码,我们需要编写一个转换函数将这些天气代码转换成 一个Sky对象。在logic/model包下新建一个Sky.kt文件,代码如下所示:

class Sky (val info: String, val icon: Int, val bg: Int) 

private val sky = mapOf(
"CLEAR_DAY" to Sky("晴", R.drawable.ic_clear_day, R.drawable.bg_clear_day),
"CLEAR_NIGHT" to Sky("晴", R.drawable.ic_clear_night, R.drawable.bg_clear_night),
"PARTLY_CLOUDY_DAY" to Sky("多云", R.drawable.ic_partly_cloud_day,
R.drawable.bg_partly_cloudy_day),
"PARTLY_CLOUDY_NIGHT" to Sky("多云", R.drawable.ic_partly_cloud_night,
R.drawable.bg_partly_cloudy_night),
"CLOUDY" to Sky("阴", R.drawable.ic_cloudy, R.drawable.bg_cloudy),
"WIND" to Sky("大风", R.drawable.ic_cloudy, R.drawable.bg_wind),
"LIGHT_RAIN" to Sky("小雨", R.drawable.ic_light_rain, R.drawable.bg_rain),
"MODERATE_RAIN" to Sky("中雨", R.drawable.ic_moderate_rain, R.drawable.bg_rain),
"HEAVY_RAIN" to Sky("大雨", R.drawable.ic_heavy_rain, R.drawable.bg_rain),
"STORM_RAIN" to Sky("暴雨", R.drawable.ic_storm_rain, R.drawable.bg_rain),
"THUNDER_SHOWER" to Sky("雷阵雨", R.drawable.ic_thunder_shower, R.drawable.bg_rain),
"SLEET" to Sky("雨夹雪", R.drawable.ic_sleet, R.drawable.bg_rain),
"LIGHT_SNOW" to Sky("小雪", R.drawable.ic_light_snow, R.drawable.bg_snow),
"MODERATE_SNOW" to Sky("中雪", R.drawable.ic_moderate_snow, R.drawable.bg_snow),
"HEAVY_SNOW" to Sky("大雪", R.drawable.ic_heavy_snow, R.drawable.bg_snow),
"STORM_SNOW" to Sky("暴雪", R.drawable.ic_heavy_snow, R.drawable.bg_snow),
"HAIL" to Sky("冰雹", R.drawable.ic_hail, R.drawable.bg_snow),
"LIGHT_HAZE" to Sky("轻度雾霾", R.drawable.ic_light_haze, R.drawable.bg_fog),
"MODERATE_HAZE" to Sky("中度雾霾", R.drawable.ic_moderate_haze, R.drawable.bg_fog),
"HEAVY_HAZE" to Sky("重度雾霾", R.drawable.ic_heavy_haze, R.drawable.bg_fog),
"FOG" to Sky("雾", R.drawable.ic_fog, R.drawable.bg_fog),
"DUST" to Sky("浮尘", R.drawable.ic_fog, R.drawable.bg_fog)
)

fun getSky(skycon: String): Sky {
return sky[skycon] ?: sky["CLEAR_DAY"]!!
}

接下来我们就可以在WeatherActivity中去请求天气数据,并将数据展示到界面上。修改 WeatherActivity中的代码,如下所示:

package com.example.sunnyweather.ui.weather

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.example.sunnyweather.R
import com.example.sunnyweather.databinding.ActivityMainBinding
import com.example.sunnyweather.databinding.ActivityWeatherBinding
import com.example.sunnyweather.databinding.ForecastBinding
import com.example.sunnyweather.databinding.LifeIndexBinding
import com.example.sunnyweather.databinding.NowBinding
import com.example.sunnyweather.logic.model.Weather
import com.example.sunnyweather.logic.model.getSky
import com.example.sunnyweather.ui.place.WeatherViewModel
import java.text.SimpleDateFormat
import java.util.Locale

class WeatherActivity : AppCompatActivity() {

private lateinit var binding: ActivityWeatherBinding
val viewModel by lazy { ViewModelProvider(this).get(WeatherViewModel::class.java) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// 初始化 ViewBinding
binding = ActivityWeatherBinding.inflate(layoutInflater)
setContentView(binding.root)
if (viewModel.locationLng.isEmpty()) {
viewModel.locationLng = intent.getStringExtra("location_lng") ?: ""
}
if (viewModel.locationLat.isEmpty()) {
viewModel.locationLat = intent.getStringExtra("location_lat") ?: ""
}
if (viewModel.placeName.isEmpty()) {
viewModel.placeName = intent.getStringExtra("place_name") ?: ""
}



// 观察天气数据
viewModel.weatherLiveData.observe(this, Observer { result ->
val weather = result.getOrNull()
if (weather != null) {
showWeatherInfo(weather)
} else {
Toast.makeText(this, "无法成功获取天气信息", Toast.LENGTH_SHORT).show()
}
})

// 刷新天气信息
viewModel.refreshWeather(viewModel.locationLng, viewModel.locationLat)
}

private fun showWeatherInfo(weather: Weather) {
// 使用 binding 访问 ScrollView 中的视图
binding.weatherLayout.visibility = View.VISIBLE

// 通过 ViewBinding 访问嵌套布局中的视图
val nowBinding = NowBinding.bind(binding.nowLayout.root) // 假设 nowLayout 是 include 引入的布局
val forecastBinding = ForecastBinding.bind(binding.forecastLayout.root) // 类似地,处理 forecast 布局
val lifeIndexBinding = LifeIndexBinding.bind(binding.lifeIndexLayout.root) // 处理 life_index 布局

// 填充数据
nowBinding.placeName.text = viewModel.placeName
val realtime = weather.realtime
val daily = weather.daily

// 填充 now.xml 布局中的数据
val currentTempText = "${realtime.temperature.toInt()} ℃"
nowBinding.currentTemp.text = currentTempText
nowBinding.currentSky.text = getSky(realtime.skycon).info
val currentPM25Text = "空气指数 ${realtime.airQuality.aqi.chn.toInt()}"
nowBinding.currentAQI.text = currentPM25Text
nowBinding.nowLayout.setBackgroundResource(getSky(realtime.skycon).bg)

// 填充 forecast.xml 布局中的数据
forecastBinding.forecastLayout.removeAllViews()
val days = daily.skycon.size
for (i in 0 until days) {
val skycon = daily.skycon[i]
val temperature = daily.temperature[i]
val view = LayoutInflater.from(this).inflate(R.layout.forecast_item, forecastBinding.forecastLayout, false)
val dateInfo = view.findViewById<TextView>(R.id.dateInfo)
val skyIcon = view.findViewById<ImageView>(R.id.skyIcon)
val skyInfo = view.findViewById<TextView>(R.id.skyInfo)
val temperatureInfo = view.findViewById<TextView>(R.id.temperatureInfo)

val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
dateInfo.text = simpleDateFormat.format(skycon.date)

val sky = getSky(skycon.value)
skyIcon.setImageResource(sky.icon)
skyInfo.text = sky.info
val tempText = "${temperature.min.toInt()} ~ ${temperature.max.toInt()} ℃"
temperatureInfo.text = tempText

forecastBinding.forecastLayout.addView(view)
}

// 填充 life_index.xml 布局中的数据
val lifeIndex = daily.lifeIndex
lifeIndexBinding.coldRiskText.text = lifeIndex.coldRisk[0].desc
lifeIndexBinding.dressingText.text = lifeIndex.dressing[0].desc
lifeIndexBinding.ultravioletText.text = lifeIndex.ultraviolet[0].desc
lifeIndexBinding.carWashingText.text = lifeIndex.carWashing[0].desc

binding.weatherLayout.visibility = View.VISIBLE
}
}

不过如果你仔细观察上图,就会发现背景图并没有和状态栏融合到一起,这样的视觉体验还没 有达到最佳的效果。虽说我们在12.7.2小节已经学习过如何将背景图和状态栏融合到一起,但 当时是借助Material库完成的,实现过程也比较麻烦。这里我准备教你另外一种更简单的实现 方式。修改WeatherActivity中的代码,如下所示:

class WeatherActivity: AppCompatActivity(){

private lateinit var binding: ActivityWeatherBinding
val viewModel by lazy{ViewModelProvider(this).get(WeatherViewModel::class.java)}

override fun onCreate(savedInstanceState: Bundle?){
//初始化ViewBinding
binding = ActivityWeatherBinding.inflate(layoutInflater)
val decorView = window.decorView
decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
window.statusBarColor = Color.TRANSPARENT
setContentView(binding.root)

if(viewModel.locationLng.isEmpty()){
viewModel.locationLng = intent.getStringExtra("location_lng")?:""
}
if(viewModel.locationLat.isEmpty()){
viewModel.locationLat = intent.getStringExtra("location_lat")?:""
}
if(viewModel.placeName.isEmpty()){
viewModel.placeName = intent.getStringExtra("place_name")?:""
}

//观察天气数据
viewModel.weatherLiveData.observe(this, Observer{ result ->
val weather = result.getOrNull()
if(weather != null){
showWeatherInfo(weather)
}else{
Toast.makeText(this,"无法成功获取天气信息",Toast.LENGTH_SHORT).show()
}

})

//刷新天气信息
viewModel.refreshWeather(viewModel.locationLng, viewModel.locationLat)
}

private fun showWeatherInfo(weather: Weather){
//使用binding 访问 ScrollView 中的视图
binding.weatherLayout.visibility = View.VISBIE

//通过ViewBinding 访问嵌套布局中的视图
val nowBinding = NowBinding.bind(binding.nowLayout.root)//记得引入布局时添加id
val forecastBinding = ForecastBinding.bind(binding.forecastLayout.root)
val lifeIndexBinding = LifeIndexBinding.bind(binding.lifeIndexLayout.root)

//填充数据
nowBinding.placeName.text = viewModel.placeName
val realtime = weather.realtime
val daily = weather.daily

//填充now.xml布局中的数据
val currentTempText = "${realtime.temperature.toInt()}℃"
nowBinding.currentTemp.text = currentTempText
nowBinding.currentSky.text = getSky(realtiem.skycon).info
val currentPM25Text = "天气指数${realtime.airQuality.aqi.chn.toInt()}"
nowBinding.currentAQI.text = currentPM25Text
nowBinding.nowLayout.setBackgroundResource(getSky(realtime.skycon).bg)

//填充forecast.xml布局中的数据
forecastBinding.forecastLayout.removeAllViews()
val days = daily.skycon.size
for(i in 0 until days){
val skycon = daily.temperature[i]
val temperature = daily.temperature[i]
val view = LayoutInfalter.from(this).inflate(R.layout.forecast_item, forecastBinding.forecastLayout, false)
val dateInfo = view.findViewById<TextView>(R.id.dateInfo)
val skyIcon = view.findViewByid<ImageView>(R.id.skyIcon)
val temperatureInfo = view.findViewById<TextView>(R.id.temperatureInfo)

val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd",Locale.getDefault())
dateInfo.text = simpleDateFormant.formant(skycon.date)

val sky = getSky(skycon.value)
skyIcon.setImageResoucee(sky.icon)
skyInfo.text = sky.info
val tempText = "${temperature.min.toInt()}~${temperature.max.toInt()}℃"
temperatureInfo.text = tempText

forecastBinding.forecastLayout.addView(view)

// 填充 life_index.xml 布局中的数据
val lifeIndex = daily.lifeIndex
lifeIndexBinding.coldRiskText.text = lifeIndex.coldRisk[0].desc
lifeIndexBinding.dressingText.text = lifeIndex.dressing[0].desc
lifeIndexBinding.ultravioletText.text = lifeIndex.ultraviolet[0].desc
lifeIndexBinding.carWashingText.text = lifeIndex.carWashing[0].desc

binding.weatherLayout.visibility = View.VISIBLE


}
}
}

我们调用了getWindow().getDecorView()方法拿到当前Activity的DecorView,再调用它 的setSystemUiVisibility()方法来改变系统UI的显示,这里传入 View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN和 View.SYSTEM_UI_FLAG_LAYOUT_STABLE就表示Activity的布局会显示在状态栏上面,最后 调用一下setStatusBarColor()方法将状态栏设置成透明色。

仅仅这些代码就可以实现让背景图和状态栏融合到一起的效果了。不过,由于系统状态栏已经 成为我们布局的一部分,因此会导致天气界面的布局整体向上偏移了一些,这样头部布局就显 得有些太靠上了。当然,这个问题也是非常好解决的,借助android:fitsSystemWindows 属性就可以了。修改now.xml中的代码,如下所示:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" 
android:id="@+id/nowLayout"
android:layout_width="match_parent"
android:layout_height="530dp"
android:orientation="vertical">
<FrameLayout
android:id="@+id/titleLayout"
android:layout_width="match_parent"
android:layout_height="70dp"
android:fitsSystemWindows="true">
...
</FrameLayout>
...
</RelativeLayout>

记录选中的城市

很明显这个功能需要用到持久化技术,不过由于要存储的数据并不属于关系型数据,因此也用 不着使用数据库存储技术,直接使用SharedPreferences存储就可以了。

然而,即使是使用SharedPreferences存储这种简单的操作,我们这里也要尽量按照MVVM的 分层架构设计来实现,不要为了图省事就把所有逻辑都写到UI控制层里面。

那么,首先在logic/dao包下新建一个PlaceDao单例类,并编写如下代码:

object PlaceDao{

fun savePlace(place: Place){
sharedPreferences().edit{
putString("place", Gson().toJson(place))
}
}

fun getSavedPlace(): Place{
val placeJson = sharedPreferences().getString("place","")
return Gson().fromJson(placeJson, Place::class.java)
}

fun isPlaceSaved() = sharedPreferences().contains("place")

private fun sharedPreferences() = SunnyWeatherApplication.context.
getSharedPreferences("sunny_weather", Context.MODE_PRIVATE)
}

在PlaceDao类中,我们封装了几个必要的存储和读取数据的接口。savePlace()方法用于将 Place对象存储到SharedPreferences文件中,这里使用了一个技巧,我们先通过GSON将 Place对象转成一个JSON字符串,然后就可以用字符串存储的方式来保存数据了。

另外,这里还提供了一个isPlaceSaved()方法,用于判断是否数据已被存储。

将PlaceDao封装好了之后,接下来我们就可以在仓库查进行实现了。修改Repository中的代码,如下所示:

object Repository{
、、、
fun savePlace(place: Place) = PlaceDao.savePlace(place)

fun getSavedPlace() = placeDao.getSavedPlace()

fun isPlaceSaved() = PlaceDao.isPlaceSaved()
}

很简单,仓库层只是做了一层接口封装而已。其实这里的实现方式并不标准,因为即使是对 SharedPreferences文件进行读写的操作,也是不太建议在主线程中进行,虽然它的执行速度 通常会很快。

这几个接口的业务逻辑是和PlaceViewModel相关的,因此我们还得在PlaceViewModel中再 进行一层封装才行,代码如下所示:

class PlaceViewModel: ViewModel(){
、、、
fun savePace(place: Place) = Repository.savePlace(place)

fun getSavedPlace() = Repository.getSavedPlace()

fun isPlaceSaved() = Repository.isPlaceSaved()
}

由于仓库层中这几个接口的内部没有开启线程,因此也不必借助LiveData对象来观察数据变 化,直接调用仓库层中相应的接口并返回即可。

将存储与读取Place对象的能力都提供好了之后,接下来就可以进行具体的功能实现了。首先 修改PlaceAdapter中的代码,如下所示:

class PlaceAdapter(private val fragment: PlaceFragment, private val placeList: List<Place>):
RecyclerView.Adapter<PlaceAdapter.ViewHolder>(){
inner class ViewHolder(val binding: PlaceItemBinding):RecyclerView.ViewHolder(binding.root)

override fun getItemCount(): Int {
return placeList.size
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = PlaceItemBinding.inflate(LayoutInflater.from(parent.context),parent,false)
return ViewHolder(binding)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val place = placeList[position]
holder.binding.placeName.text = place.name
holder.binding.placeAddress.text = place.address
holder.itemView.setOnClickListener{
val intent = Intent(fragment.requireContext(),WeatherActivity::class.java).apply {
putExtra("location_lng", place.location.lng)
putExtra("location_lat",place.location.lat)
putExtra("place_name",place.name)
}
fragment.viewModel.savePlace(place)
fragment.startActivity(intent)
fragment.activity?.finish()

}
}

}

这里需要进行两处修改:先把PlaceAdapter主构造函数中传入的Fragment对象改成 PlaceFragment对象,这样我们就可以调用PlaceFragment所对应的PlaceViewModel了; 接着在onCreateViewHolder()方法中,当点击了任何子项布局时,在跳转到 WeatherActivity之前,先调用PlaceViewModel的savePlace()方法来存储选中的城市。

完成了存储功能之后,我们还要对存储的状态进行判断和读取才行,修改PlaceFragment中的 代码,如下所示:

class PlaceFragment: Fragment(){
、、、
override fun onActivityCreated(savedInstanceState: Bundle?){
super.onActivityCreated(saveInstanceState)
if(viewModel.isPlaceSaved()){
val place = viewModel.getSavedPlace()
val intent = Intent(fragment.requireContext(),WeatherActivity::class.java).apply{
putExtra("location_lng",place.location.lng)
putExtra("location_lat",place.location.lat)
putExtra("place_name",place.name)
}
startActivity(intent)
activity?.finish()
return
}
}
}