火山天气

天气API:彩云科技 | 开放平台 (caiyunapp.com)

  1. 查询全球城市数据信息:https://api.caiyunapp.com/v2/place?query=北京&token={token}&lang=zh_CN

    返回JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {"**status**":"ok","query":"北京",
    "**places**":[
    {"**name**":"北京市","**location**":{"lat":39.9041999,"lng":116.4073963},
    "**formatted_address**":"中国北京市"},
    {"name":"北京西站","location":{"lat":39.89491,"lng":116.322056},
    "formatted_address":"中国 北京市 丰台区 莲花池东路118号"},
    {"name":"北京南站","location":{"lat":39.865195,"lng":116.378545},
    "formatted_address":"中国 北京市 丰台区 永外大街车站路12号"},
    {"name":"北京站(地铁站)","location":{"lat":39.904983,"lng":116.427287},
    "formatted_address":"中国 北京市 东城区 2号线"}
    ]}
  2. 查询某地区实时天气:

    https://api.caiyunapp.com/v2.5/{token}/116.4073963,39.9041999/realtime.json

    返回JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "status": "ok",
    "result": {
    "**realtime**": {
    "**temperature**": 23.16,
    "**skycon**": "WIND",
    "**air_quality**": {
    "aqi": { "chn": 17.0 }
    }
    }
    }
    }
  3. 查询未来几天天气信息接口:

    https://api.caiyunapp.com/v2.5/{token}/116.4073963,39.9041999/daily.json

    返回的JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    "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": "舒适"}, ... ]
    }
    }
    }
    }

1. MVVM项目架构

MVVM:Model-View-ViewModel (还有MVP/MVC)

Model:数据模型

View:界面展示

ViewModel:连接数据和界面的桥梁

实现让业务逻辑和界面展示分离的程序结构设计

image-20221103164101961

UI控制层:Activity,Fragment,布局文件等和界面有关的东西;

ViewModel层:持有和UI元素相关的数据,并保证旋转不会丢失;同时提供接口给UI控制层调用和仓库层通信;

仓库层:判断调用请求的数据去哪里获取

本地数据源:SQLite,SharedPreferences

网络数据库:Retrofit访问服务器提供的Webservice接口来获得

注意:箭头是单向的,表示上一层只能持有下一层的引用。不能反过来也不能跨层

项目结构:

image-20221103165442048

依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
dependencies {

implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'

implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation 'com.squareup.retrofit2:converter-gson:2.6.1'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1"

testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'


}

2. 城市数据

  1. 查询全球城市数据信息:https://api.caiyunapp.com/v2/place?query=北京&token={token}&lang=zh_CN

    返回JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {"**status**":"ok","query":"北京",
    "**places**":[
    {"**name**":"北京市","**location**":{"lat":39.9041999,"lng":116.4073963},
    "**formatted_address**":"中国北京市"},
    {"name":"北京西站","location":{"lat":39.89491,"lng":116.322056},
    "formatted_address":"中国 北京市 丰台区 莲花池东路118号"},
    {"name":"北京南站","location":{"lat":39.865195,"lng":116.378545},
    "formatted_address":"中国 北京市 丰台区 永外大街车站路12号"},
    {"name":"北京站(地铁站)","location":{"lat":39.904983,"lng":116.427287},
    "formatted_address":"中国 北京市 东城区 2号线"}
    ]}

2.1 逻辑层

2.1.1 准备工作

全局获取context

为什么?

因为MVVM这种分层架构设计,我们在ViewModel就不再持有Activity的引用了,所以会经常出现缺context的情况。

  1. 在com.lmc.volcanoweather包下新建一个VolcanoWeatherApplication类:

    image-20221106162714195

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    package com.lmc.volcanoweather

    import android.annotation.SuppressLint
    import android.app.Application
    import android.content.Context
    //全局获取context
    class VolcanoWeatherApplication:Application() {
    companion object {
    @SuppressLint("StaticFieldLeak")
    lateinit var context: Context
    //天气API的令牌值
    const val TOKEN = "NL0AtaDgGQO3Ytfg"
    }

    override fun onCreate() {
    super.onCreate()
    context = applicationContext
    }
    }

    顺手把天气API的令牌值放进去

  2. 在AndroidManifest下指定用我们这个VolcanoWeatherApplication

    就下面name那一句指定下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <application
    android:name=".VolcanoWeatherApplication"
    android:allowBackup="true"
    android:dataExtractionRules="@xml/data_extraction_rules"
    android:fullBackupContent="@xml/backup_rules"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.VolcanoWeather"
    tools:targetApi="31">
    ……
    </application>

2.1.2 数据模型

  1. logic-》model 下建立一个个PlaceResponse.kt

    image-20221106164054048

    代码:

    1
    2
    3
    4
    5
    6
    7
    8
    package com.lmc.volcanoweather.logic.model

    import com.google.gson.annotations.SerializedName

    data class PlaceResponse(val status: String, val places: List<Place>)
    data class Place(val name: String, val location: Location,
    @SerializedName("formatted_address") val address: String)//JSON中字段命名和Kotlin中命名规范不一致就用一下这个
    data class Location(val lng: String, val lat: String)

2.1.3 网络层

  1. 定义一个Retrofit接口(访问彩云天气城市搜索API:logic/network下新建PlaceService)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package com.lmc.volcanoweather.logic.network

    import com.lmc.volcanoweather.VolcanoWeatherApplication
    import com.lmc.volcanoweather.logic.model.PlaceResponse
    import retrofit2.Call
    import retrofit2.http.GET
    import retrofit2.http.Query
    //位置服务
    interface PlaceService {
    //发送的网络请求只有query(地址),其他的都不会变
    @GET("v2/place?token=${VolcanoWeatherApplication.TOKEN}&lang=zh_CN")
    fun searchPlaces(@Query("query") query: String):Call<PlaceResponse>//数据返回结果解析成PlaceResponse
    }
  2. 创建一个Retrofit构造器(logic/network-》ServiceCreator单例类)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package com.lmc.volcanoweather.logic.network

    import retrofit2.Retrofit
    import retrofit2.converter.gson.GsonConverterFactory

    //Retrofit的构造器
    object ServiceCreator {
    private const val BASE_URL = "https://api.caiyunapp.com/"
    private val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addConverterFactory(GsonConverterFactory.create())
    .build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
    inline fun <reified T> create(): T = create(T::class.java)
    }
  3. 一个统一的网络数据源访问入口(对所有网络请求API进行封装)logic/network/VolcanoWeatherNetwork单例类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    package com.lmc.volcanoweather.logic.network

    import retrofit2.Call
    import retrofit2.Callback
    import retrofit2.Response
    import kotlin.coroutines.resume
    import kotlin.coroutines.resumeWithException
    import kotlin.coroutines.suspendCoroutine

    //一个统一的网络数据源访问入口
    object VolcanoWeatherNetwork {
    private val placeService = ServiceCreator.create<PlaceService>()//创建了一个PlaceService接口的动态代理对象
    suspend fun searchPlaces(query: String) = placeService.searchPlaces(query).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(body)
    else continuation.resumeWithException(
    RuntimeException("response body is null"))
    }
    override fun onFailure(call: Call<T>, t: Throwable) {
    continuation.resumeWithException(t)
    }
    })
    }
    }
    }

2.1.4 仓库层

仓库层主要工作:判断调用方请求的数据要从哪里拿(本地还是网络?)并拿到给调用方。

相当于他是调用和缓存的中间件,每次有调用请求先看下本地有没有缓存,没有就直接去网络或者内存中拿。

这里没有做缓存必要,直接全去网络。

logic下做一个Repository单例类,作为仓库层的统一封装入口。

image-20221106171104592

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.lmc.volcanoweather.logic

import androidx.lifecycle.liveData
import com.lmc.volcanoweather.logic.model.Place
import com.lmc.volcanoweather.logic.network.VolcanoWeatherNetwork
import kotlinx.coroutines.Dispatchers

//仓库层的统一封装入口
object Repository {
fun searchPlaces(query: String) = liveData(Dispatchers.IO) {//这里设置全是在子线程中干活
val result = try {
val placeResponse = VolcanoWeatherNetwork.searchPlaces(query)
if (placeResponse.status == "ok") {
val places = placeResponse.places
Result.success(places)//这个是Kotlin自带的包装方法
} else {
Result.failure(RuntimeException("response status is ${placeResponse.status}"))
}
} catch (e: Exception) {
Result.failure<List<Place>>(e)
}
emit(result)
}
}

2.1.5 ViewModel层

这是逻辑层和UI层的一个桥梁,但是更加偏向逻辑。但又由于ViewModel和Activity还有Fragment是一一对应的。所有把他们放在一起好了。

ui/place下新建一个PlaceViewModel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.lmc.volcanoweather.ui.place

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.lmc.volcanoweather.logic.Repository
import com.lmc.volcanoweather.logic.model.Place
//ViewModel层
class PlaceViewModel:ViewModel() {
private val searchLiveData = MutableLiveData<String>()
val placeList = ArrayList<Place>()
val placeLiveData = Transformations.switchMap(searchLiveData) { query ->
Repository.searchPlaces(query)
}
fun searchPlaces(query: String) {
searchLiveData.value = query
}
}

2.2 UI层

2.2.1 界面总布局

在res/layout目录中新建fragment_place.xml布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:windowBackground">

<ImageView
android:id="@+id/bgImageView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:src="@drawable/bg_place"/>

<FrameLayout
android:id="@+id/actionBarLayout"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@color/design_default_color_primary">
<EditText
android:id="@+id/searchPlaceEdit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:hint="输入地址"
android:background="@drawable/search_bg"/>
</FrameLayout>

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/actionBarLayout"
android:visibility="gone"/>

</RelativeLayout>

image-20221107142453630

2.2.2 RecycleView的善后工作

  1. 子项布局

    image-20221107142710677

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <?xml version="1.0" encoding="utf-8"?>
    <com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"

    android:layout_width="match_parent"
    android:layout_height="130dp"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_margin="12dp"
    app:cardCornerRadius="4dp">
    <LinearLayout
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="18dp"
    android:layout_gravity="center_vertical">
    <TextView
    android:id="@+id/placeName"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textColor="?android:attr/textColorPrimary"
    android:textSize="20sp"/>
    <TextView
    android:id="@+id/placeAddress"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="10dp"
    android:textColor="?android:attr/textColorSecondary"
    android:textSize="14sp"/>
    </LinearLayout>
    </com.google.android.material.card.MaterialCardView>

    image-20221107143253929

  2. RecycleView适配器

    image-20221107143356782

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    package com.lmc.volcanoweather.ui.place

    import android.view.LayoutInflater
    import android.view.View
    import android.view.ViewGroup
    import android.widget.TextView
    import androidx.fragment.app.Fragment
    import androidx.recyclerview.widget.RecyclerView
    import com.lmc.volcanoweather.R
    import com.lmc.volcanoweather.logic.model.Place
    //RecyclerView适配器
    class PlaceAdapter(private val fragment: Fragment, private val placeList: List<Place>) :
    RecyclerView.Adapter<PlaceAdapter.ViewHolder>() {
    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    val placeName: TextView = view.findViewById(R.id.placeName)
    val placeAddress: TextView = view.findViewById(R.id.placeAddress)
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val view = LayoutInflater.from(parent.context).inflate(R.layout.place_item,
    parent, false)
    return ViewHolder(view)
    }
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val place = placeList[position]
    holder.placeName.text = place.name
    holder.placeAddress.text = place.address
    }
    override fun getItemCount() = placeList.size
    }

2.2.3 Fragment

  1. Fragment实现

image-20221107144434547

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.lmc.volcanoweather.ui.place

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.lmc.volcanoweather.R
import kotlinx.android.synthetic.main.fragment_place.*

class PlaceFragment:Fragment() {
val viewModel by lazy { ViewModelProvider(this).get(PlaceViewModel::class.java) }
private lateinit var adapter: PlaceAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_place, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
val layoutManager = LinearLayoutManager(activity)
recyclerView.layoutManager = layoutManager
adapter = PlaceAdapter(this, viewModel.placeList)
recyclerView.adapter = adapter
searchPlaceEdit.addTextChangedListener { editable ->
val content = editable.toString()
if (content.isNotEmpty()) {
viewModel.searchPlaces(content)
} else {
recyclerView.visibility = View.GONE
bgImageView.visibility = View.VISIBLE
viewModel.placeList.clear()
adapter.notifyDataSetChanged()
}
}
viewModel.placeLiveData.observe(viewLifecycleOwner, Observer{ result ->
val places = result.getOrNull()
if (places != null) {
recyclerView.visibility = View.VISIBLE
bgImageView.visibility = View.GONE
viewModel.placeList.clear()
viewModel.placeList.addAll(places)
adapter.notifyDataSetChanged()
} else {
Toast.makeText(activity, "未能查询到任何地点", Toast.LENGTH_SHORT).show()
result.exceptionOrNull()?.printStackTrace()
}
})
}
}
  1. 将Fragment添加到Activity中

    activity_main.xml中的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <?xml version="1.0" encoding="utf-8"?>
    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
    android:id="@+id/placeFragment"
    android:name="com.lmc.volcanoweather.ui.place.PlaceFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
    </FrameLayout>
  2. 隐藏原生的ActionBar

    image-20221107145752864

    1
    2
    3
    4
    5
    6
    <resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
    ...
    </style>
    </resources>
  3. 申请权限

    1
    2
    3
    4
    5
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.sunnyweather.android">
    <uses-permission android:name="android.permission.INTERNET" />
    ...
    </manifest>

结果:image-20221107150029243

os:其实地方名,地址,经纬度都拿到了

3. 天气信息

  1. 查询某地区实时天气:

    https://api.caiyunapp.com/v2.5/{token}/116.4073963,39.9041999/realtime.json

    返回JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "status": "ok",
    "result": {
    "**realtime**": {
    "**temperature**": 23.16,
    "**skycon**": "WIND",
    "**air_quality**": {
    "aqi": { "chn": 17.0 }
    }
    }
    }
    }
  2. 查询未来几天天气信息接口:

    https://api.caiyunapp.com/v2.5/{token}/116.4073963,39.9041999/daily.json

    返回的JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    {
    "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": "舒适"}, ... ]
    }
    }
    }
    }

3.1 逻辑

3.1.1 数据模型

  1. RealtimeResponse

image-20221107150555760

1
2
3
4
5
6
7
8
9
10
11
package com.lmc.volcanoweather.logic.model

import com.google.gson.annotations.SerializedName

data class RealtimeResponse(val status: String, val result: 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)
}

所有数据模型类都定义在RealtimeResonse内部可以有效防止和其他接口的数据类型重名冲突的情况

  1. DailyResponse
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.lmc.volcanoweather.logic.model

import com.google.gson.annotations.SerializedName
import java.util.*

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)
}
  1. 整和上面两个的 Weather

    image-20221107152815165

    1
    2
    3
    package com.lmc.volcanoweather.logic.model

    data class Weather(val realtime: RealtimeResponse.Realtime, val daily: DailyResponse.Daily)
  2. Sky(这个是在UI后面补充的)

    image-20221107165606951

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    package com.lmc.volcanoweather.logic.model

    import com.lmc.volcanoweather.R

    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"]!!
    }

3.1.2 网络层

1. 访问天气信息API的Retrofit接口

image-20221107153005106

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.lmc.volcanoweather.logic.network

import com.lmc.volcanoweather.VolcanoWeatherApplication
import com.lmc.volcanoweather.logic.model.DailyResponse
import com.lmc.volcanoweather.logic.model.RealtimeResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path

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

@GET("v2.5/${VolcanoWeatherApplication.TOKEN}/{lng},{lat}/daily.json")
fun getDailyWeather(@Path("lng") lng: String, @Path("lat") lat: String):
Call<DailyResponse>
}
2.VolcanoWeatherNetwork网络数据源访问入口对新加的这个接口进行封装

image-20221107153503788

1
2
3
4
5
6
7
8
object VolcanoWeatherNetwork {
private val weatherService = ServiceCreator.create(WeatherService::class.java)
suspend fun getDailyWeather(lng: String, lat: String) =
weatherService.getDailyWeather(lng, lat).await()
suspend fun getRealtimeWeather(lng: String, lat: String) =
weatherService.getRealtimeWeather(lng, lat).await()
...
}

3.1.3 仓库层

  1. 修改Repository中的代码:

image-20221107153731630

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.lmc.volcanoweather.logic

import androidx.lifecycle.liveData
import com.lmc.volcanoweather.logic.model.Place
import com.lmc.volcanoweather.logic.model.Weather
import com.lmc.volcanoweather.logic.network.VolcanoWeatherNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlin.coroutines.CoroutineContext

//仓库层的统一封装入口
object Repository {
fun searchPlaces(query: String) = fire(Dispatchers.IO) {
val placeResponse = VolcanoWeatherNetwork.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 {
VolcanoWeatherNetwork.getRealtimeWeather(lng, lat)
}
val deferredDaily = async {
VolcanoWeatherNetwork.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}"
)
)
}
}
}
//这个fire是升级过后的东西拉
private fun <T> fire(context: CoroutineContext, block: suspend () -> Result<T>) =
liveData<Result<T>>(context) {
val result = try {
block()
} catch (e: Exception) {
Result.failure<T>(e)
}
emit(result)
}
}

3.1.4 ViewModel

image-20221107160307510

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.lmc.volcanoweather.ui.wealther

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.lmc.volcanoweather.logic.Repository
import com.lmc.volcanoweather.logic.model.Location

class WeatherViewModel : ViewModel() {
private val locationLiveData = MutableLiveData<Location>()
//下面三个都是和界面相关的数据,所以放在 ViewModel
var locationLng = ""
var locationLat = ""
var placeName = ""
//这个是转换LiveData观察用的
val weatherLiveData = Transformations.switchMap(locationLiveData) { location ->
Repository.refreshWeather(location.lng, location.lat)
}

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

3.2 UI

3.2.1 显示天气的Activity

image-20221107161254732

activity_weather.xml(这个在创建Activity的时候会自动创建) 会是很长的一个布局,我们用引用布局的形式。

3.2.2 now

当前天气信息布局

image-20221107161927746

image-20221107161905024

3.2.3 forecast

未来几天天气信息布局

image-20221107162109721

image-20221107162402138

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?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>

3.2.4 fprecast_item

未来天气信息的子项布局

image-20221107162540732

image-20221107162940272

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?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.2.5 life_index

生活指数的布局

image-20221107164104493

image-20221107164800332

image-20221107164937530

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
<?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>

3.2.6 引入

将上面4个模块加载进显示天气的Activity

image-20221107165138692

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?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>

3.2.7 Sky转换函数准备

见3.1.1 最后一个

3.2.8 WeatherActivity下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package com.lmc.volcanoweather.ui.wealther

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.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.lmc.volcanoweather.R
import com.lmc.volcanoweather.logic.model.Weather
import com.lmc.volcanoweather.logic.model.getSky
import kotlinx.android.synthetic.main.activity_weather.*
import kotlinx.android.synthetic.main.forecast.*
import kotlinx.android.synthetic.main.life_index.*
import kotlinx.android.synthetic.main.now.*
import java.text.SimpleDateFormat
import java.util.*

class WeatherActivity : AppCompatActivity() {
val viewModel by lazy { ViewModelProvider(this).get(WeatherViewModel::class.java) }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_weather)
//下面三个是从Intent中去除经纬坐标和地区名称
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") ?: ""
}
//观察天气LiveData,当获取到服务器返回数据就展示出来
viewModel.weatherLiveData.observe(this, Observer { result ->
val weather = result.getOrNull()
if (weather != null) {
showWeatherInfo(weather)
} else {
Toast.makeText(this, "无法成功获取天气信息", Toast.LENGTH_SHORT).show()
result.exceptionOrNull()?.printStackTrace()
}
})
viewModel.refreshWeather(viewModel.locationLng, viewModel.locationLat)//执行了一次刷新天气的请求
}

private fun showWeatherInfo(weather: Weather) {
placeName.text = viewModel.placeName
val realtime = weather.realtime
val daily = weather.daily

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

// 填充forecast.xml布局中的数据
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,
forecastLayout, false
)
val dateInfo = view.findViewById(R.id.dateInfo) as TextView
val skyIcon = view.findViewById(R.id.skyIcon) as ImageView
val skyInfo = view.findViewById(R.id.skyInfo) as TextView
val temperatureInfo = view.findViewById(R.id.temperatureInfo) as TextView
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
forecastLayout.addView(view)
}

// 填充life_index.xml布局中的数据
val lifeIndex = daily.lifeIndex
//生活指数有很多天的,我们只拿出当天的
coldRiskText.text = lifeIndex.coldRisk[0].desc
dressingText.text = lifeIndex.dressing[0].desc
ultravioletText.text = lifeIndex.ultraviolet[0].desc
carWashingText.text = lifeIndex.carWashing[0].desc

//获取了所有数据要让他们显示出来
weatherLayout.visibility = View.VISIBLE
}
}

3.2.9 界面跳转

从搜索城市界面跳转到天气界面

image-20221107171916611

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PlaceAdapter(private val fragment: Fragment, private val placeList: List<Place>) :
RecyclerView.Adapter<PlaceAdapter.ViewHolder>() {
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.place_item,
parent, false)
val holder = ViewHolder(view)
holder.itemView.setOnClickListener {
val position = holder.adapterPosition
val place = placeList[position]
val intent = Intent(parent.context, WeatherActivity::class.java).apply {
putExtra("location_lng", place.location.lng)
putExtra("location_lat", place.location.lat)
putExtra("place_name", place.name)
}
fragment.startActivity(intent)
fragment.activity?.finish()
}
return holder
}
...
}

如果出现这个Bug:

android.content.ActivityNotFoundException: Unable to find explicit activity class {com.lmc.volcanoweather/com.lmc.volcanoweather.ui.wealther.WeatherActivity}; have you declared this activity in your AndroidManifest.xml?

则AndroidManifest.xml这里改一下:看图

image-20221107173037558

我ui下面weather包名之前写错了,之前是wealther 现在改过来了weather

image-20221107173054749

image-20221107173351243

美化:

之前美化是用的Material库来做的,虽然优秀但是复杂。今天来个更简单的实现方式。

WeatherActivity中:

1
2
3
4
5
6
7
8
9
10
11
12
class WeatherActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/下面这里是设置那个状态栏的
val decorView = window.decorView
decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE//这行表示Activity的布局会显示在状态栏上
window.statusBarColor = Color.TRANSPARENT//将状态栏设置为透明色
}
...
}

这时候,天气界面的布局整体向上偏移了一些——丑:

image-20221107182716540

os:其实也还好,怕以后工作产品狗挑毛病也写一下好了

修改:now.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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>

这一行是让系统状态栏留出空间。

image-20221107183046983

好像确实更优雅了

3.3 记录选中城市

3.3.1 存储准备

  1. image-20221107191559967

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    package com.lmc.volcanoweather.logic.dao

    import android.content.Context
    import android.provider.Settings.Global.putString
    import androidx.core.content.edit
    import com.google.gson.Gson
    import com.lmc.volcanoweather.VolcanoWeatherApplication
    import com.lmc.volcanoweather.logic.model.Place

    object PlaceDao {
    fun savePlace(place: Place) {
    sharedPreferences().edit {
    putString("place", Gson().toJson(place))//利用这个GSON将一个place对象转成一个JSON字符串再去存就很划算
    }
    }
    fun getSavedPlace(): Place {
    val placeJson = sharedPreferences().getString("place", "")
    return Gson().fromJson(placeJson, Place::class.java)
    }
    //判断数据是否又被存储
    fun isPlaceSaved() = sharedPreferences().contains("place")
    private fun sharedPreferences() = VolcanoWeatherApplication.context.
    getSharedPreferences("sunny_weather", Context.MODE_PRIVATE)
    }
  2. 仓库中封装一下

    image-20221107191808930

    1
    2
    3
    4
    5
    6
    object Repository {
    ...
    fun savePlace(place: Place) = PlaceDao.savePlace(place)
    fun getSavedPlace() = PlaceDao.getSavedPlace()
    fun isPlaceSaved() = PlaceDao.isPlaceSaved()
    }
  3. PlaceViewModel中再进行一次封装

    image-20221107191955822

    1
    2
    3
    4
    5
    6
    class PlaceViewModel : ViewModel() {
    ...
    fun savePlace(place: Place) = Repository.savePlace(place)
    fun getSavedPlace() = Repository.getSavedPlace()
    fun isPlaceSaved() = Repository.isPlaceSaved()
    }

3.3.2 实现

PlaceAdapter

image-20221107192230429

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class PlaceAdapter(private val fragment: PlaceFragment, private val placeList:
List<Place>) : RecyclerView.Adapter<PlaceAdapter.ViewHolder>() {
...
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.place_item,
parent, false)
val holder = ViewHolder(view)
holder.itemView.setOnClickListener {
val position = holder.adapterPosition
val place = placeList[position]
val intent = Intent(parent.context, 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()
}
return holder
}
...
}

进行两处修改:

  1. 先把 PlaceAdapter 主构造函数中传入的 Fragment 对象改成 PlaceFragment 对象
  2. 当点击了任何子项布局时,在跳转到 WeatherActivity 之前,先调用 PlaceViewModel 的 savePlace() 方法来存储选中的城市。
PlaceFragment

image-20221107192833343

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class PlaceFragment : Fragment() {
...
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (viewModel.isPlaceSaved()) {
val place = viewModel.getSavedPlace()
val intent = Intent(context, 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
}
...
}
}

验证了一下几乎成了

4.手动刷新天气&切换城市

有个Bug:选中某个地方就没办法查看其他城市的天气了。

4.1 手动刷新天气

直接用下拉控件就可以了

  1. 在ScrollView的外面嵌套了一层SwipeRefreshLayout

    image-20221108150656151

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipeRefresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ScrollView
    android:id="@+id/weatherLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:overScrollMode="never"
    android:scrollbars="none"
    android:visibility="invisible">
    ...
    </ScrollView>
    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
  2. WeatherActivity 中加入刷新天气的处理逻辑:

    image-20221108150818662

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    class WeatherActivity : AppCompatActivity() {
    val viewModel by lazy { ViewModelProvider(this).get(WeatherViewModel::class.java) }
    override fun onCreate(savedInstanceState: Bundle?) {
    ...
    viewModel.weatherLiveData.observe(this, Observer { result ->
    val weather = result.getOrNull()
    if (weather != null) {
    showWeatherInfo(weather)
    } else {
    Toast.makeText(this, "无法成功获取天气信息", Toast.LENGTH_SHORT).show()
    result.exceptionOrNull()?.printStackTrace()
    }
    swipeRefresh.isRefreshing = false
    })
    swipeRefresh.setColorSchemeResources(R.color.design_default_color_primary)
    refreshWeather()
    swipeRefresh.setOnRefreshListener {
    refreshWeather()
    }
    }
    fun refreshWeather() {
    viewModel.refreshWeather(viewModel.locationLng, viewModel.locationLat)
    swipeRefresh.isRefreshing = true
    }
    ...
    }

其实吧,没啥大区别,你进去就是最新的天气信息。再刷新下子有啥作用尼?

4.2 切换城市

想着是通过滑动菜单来做

4.2.1 头布局加个切换按钮

image-20221108151942198

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<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">
<Button
android:id="@+id/navBtn"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginStart="15dp"
android:layout_gravity="center_vertical"
android:background="@drawable/ic_home" />
...
</FrameLayout>
...
</RelativeLayout>

4.2.2 实现滑动菜单功能

image-20221108152223568

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<androidx.drawerlayout.widget.DrawerLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
...
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="start"
android:clickable="true"
android:focusable="true"
android:background="@color/design_default_color_primary">
<fragment
android:id="@+id/placeFragment"
android:name="com.lmc.volcanoweather.ui.place.PlaceFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="25dp"/>
</FrameLayout>
</androidx.drawerlayout.widget.DrawerLayout>

在SwipeRefreshLayout的外面又嵌套了一层DrawerLayout,第一个是主屏幕中的内容,第二个是滑动菜单中的内容

4.2.3 滑动菜单逻辑

image-20221108153659654

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class WeatherActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
navBtn.setOnClickListener {
drawerLayout.openDrawer(GravityCompat.START)
}
drawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener {
override fun onDrawerStateChanged(newState: Int) {}
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {}
override fun onDrawerOpened(drawerView: View) {}
override fun onDrawerClosed(drawerView: View) {
//滑动菜单隐藏的时候也隐藏输入法
val manager = getSystemService(Context.INPUT_METHOD_SERVICE)
as InputMethodManager
manager.hideSoftInputFromWindow(drawerView.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS)
}
})
}
...
}

有个Bug:

之前在PlaceFragment中做过一个数据存储状态的判断,假如已经有选中的城市保
存在SharedPreferences文件中了,那么就直接跳转到WeatherActivity。但是现在将
PlaceFragment嵌入WeatherActivity中之后,如果还执行这段逻辑肯定是不行的,因为这会
造成无限循环跳转的情况。为此需要对PlaceFragment进行如下修改:

???

image-20221108154643471

加了一层逻辑判断条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class PlaceFragment : Fragment() {
...
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if (activity is MainActivity && viewModel.isPlaceSaved()) {
val place = viewModel.getSavedPlace()
val intent = Intent(context, 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
}
...
}
}

切换城市后的逻辑:

之前选中了某个城市后是跳转到WeatherActivity的,而现在由于我们本来就是
在WeatherActivity中的,因此并不需要跳转,只要去请求新选择城市的天气信息就可以了。

image-20221108155127067

出现一个空指针BUG

解决办法:image-20221108160359323

这两个方法一定要放在最上面

5. 制作APP图标

从 Android8 开始app图标应该是两层:前景层和背景层。

前景层:APP的logo

背景层:衬托APP的Logo(只能定义颜色和纹理,不可以有形状)

图标形状由手机厂商来决定(mask)

image-20221109161923535

image-20221109162022438

6. 生成正式签名的APK文件文件

之前测试的安卓APP是AS有个默认的keystore文件帮我们自动进行了签名(gradle负责)

6.1 AS生成

image-20221109162800822

这个也不好演示,看视频好耶

6.2 Gradle生成

image-20221109165813855

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-android-extensions'
}

android {
compileSdk 32

defaultConfig {
applicationId "com.lmc.volcanoweather"
minSdk 21
targetSdk 32
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
config {
storeFile file('/Users/Mecenlee/Mecenlee.jks')
storePassword '594946'
keyAlias = 'Mecenleedev'
keyPassword '594946'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.config
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}

dependencies {

implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'

implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation 'com.squareup.retrofit2:converter-gson:2.6.1'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1"

testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'


}

7.优化点

8.总结

弱项:

  1. 网络部分,尤其是Retrofit
  2. RecycleView的一些列操作
  3. Fragment