本篇文章介绍的是使用 MVVM 架构 以及结合 Kotlin + Jetpack 组件 + 协程 + Retrofit 等新技术构建一个查询天气类App!
源码下载地址 : 天气 App 源码的csdn下载地址
接下来是开发此App 的流程顺序
一. 依赖库的引入
apply plugin: 'kotlin-kapt'
//常用依赖库
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7'
implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha03'
implementation 'com.google.android.material:material:1.0.0'
implementation 'com.squareup.retrofit2:retrofit:2.6.1'
implementation 'com.squareup.retrofit2:converter-gson:2.6.1'
implementation 'com.squareup.retrofit2:converter-scalars:2.5.0'
implementation 'com.github.bumptech.glide:glide:4.9.0'
kapt 'com.github.bumptech.glide:compiler:4.9.0'
implementation 'com.github.bumptech.glide:okhttp3-integration:4.9.0'
implementation 'jp.wasabeef:glide-transformations:4.1.0'
implementation 'org.greenrobot:eventbus:3.1.1'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
implementation 'com.permissionx.guolindev:permissionx:1.2.2'
//Android Jetpack 组件
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.work:work-runtime:2.3.4'
二. 数据源层
数据源层指的是 App 中的数据来源,一般分为本地数据源和网络数据源
网络数据源:这里采用的是第三方彩云天气的API 接口 ,只需去注册一个账号拿到令牌后,即可调用它的Api接口去取数据! 彩云天气官网
网络层采用的是 Retrofit + 协程 + LiveData 的模式
第一步 : 定义数据模型model
package com.cx.sunnyweather.logic.model
import com.google.gson.annotations.SerializedName
/**
* Created by cx on 2020/8/18
* Describe:
*/
data class PlaceResponse(val status: String, val places: List) {
data class Place(
val name: String,
val location: Location,
@SerializedName("formatted_address") val address: String
)
data class Location(val lng: String, val lat: String)
}
第二步 :Retrofit 定义接口
interface PlaceService {
@GET("v2/place?token=${SunnyWeatherApplication.TOKEN}&lang=zh_CN")
fun searchPlaces(@Query("query") query: String): Call
}
interface WeatherService {
@GET("v2.5/${SunnyWeatherApplication.TOKEN}/{lng},{lat}/realtime.json")
fun getRealtimeWeather(@Path("lng") lng: String, @Path("lat") lat: String): Call
@GET("v2.5/${SunnyWeatherApplication.TOKEN}/{lng},{lat}/daily.json")
fun getDailyWeather(@Path("lng") lng: String, @Path("lat") lat: String): Call
}
第三步:封装Retrofit build()
package com.cx.sunnyweather.logic.network
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
/**
* Created by cx on 2020/8/18
* Describe:
*/
object ServiceCreator {
private const val BASE_URL = "https://api.caiyunapp.com/"
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
fun create(serviceClass : Class) : T = retrofit.create(serviceClass)
inline fun create() : T = create(T::class.java)
}
第四步 :提供向上一层获取数据的方法
package com.cx.sunnyweather.logic.network
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.lang.RuntimeException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/**
* Created by cx on 2020/8/18
* Describe:
*/
object SunnyWeatherNetwork {
private val placeService = ServiceCreator.create(PlaceService::class.java)
private val weatherService = ServiceCreator.create(WeatherService::class.java)
suspend fun searchPlaces(query: String) = placeService.searchPlaces(query).await()
suspend fun getDailyWeather(lng: String, lat:String) = weatherService.getDailyWeather(lng,lat).await()
suspend fun getRealtimeWeather(lng: String, lat: String) = weatherService.getRealtimeWeather(lng,lat).await()
private suspend fun Call.await(): T {
return suspendCoroutine { continuation ->
enqueue(object : Callback {
override fun onFailure(call: Call, t: Throwable) {
continuation.resumeWithException(t)
}
override fun onResponse(call: Call, response: Response) {
val body = response.body()
if(body != null) continuation.resume(body)
else continuation.resumeWithException(RuntimeException("response body is null"))
}
})
}
}
}
说明:suspend 是 kotlin中的关键字 ,指向的是只允许在协程中调用该方法,首先构建services, 然后采用协程异步调用enqueue(),await() 方法success会返回response.body()值
本地数据源:由于此时只用到了保存查询天气的地点,所以采用sharedPreference 轻量级存储即可
package com.cx.sunnyweather.logic.dao
import android.content.Context
import androidx.core.content.edit
import com.cx.sunnyweather.SunnyWeatherApplication
import com.cx.sunnyweather.logic.model.PlaceResponse
import com.google.gson.Gson
/**
* Created by cx on 2020/8/19
* Describe:
*/
object PlaceDao {
fun savePlace(place: PlaceResponse.Place) {
sharedPreferences().edit {
putString("place",Gson().toJson(place))
}
}
fun getSavedPlace(): PlaceResponse.Place {
val placeJson = sharedPreferences().getString("place","")
return Gson().fromJson(placeJson, PlaceResponse.Place::class.java)
}
fun isPlaceSaved() = sharedPreferences().contains("place")
private fun sharedPreferences() =
SunnyWeatherApplication.context.getSharedPreferences("sunny_weather", Context.MODE_PRIVATE)
}
三. 数据仓库层
一般在数据仓库层中定义的方法,是为了能够异步获取的数据以响应式编程的方式通知给上一层,通常会返回一个LiveData对象
package com.cx.sunnyweather.logic
import androidx.lifecycle.liveData
import com.cx.sunnyweather.logic.dao.PlaceDao
import com.cx.sunnyweather.logic.model.PlaceResponse
import com.cx.sunnyweather.logic.model.Weather
import com.cx.sunnyweather.logic.network.SunnyWeatherNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.lang.RuntimeException
import kotlin.coroutines.CoroutineContext
/**
* Created by cx on 2020/8/18
* Describe:
*/
object WeatherRepository {
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 = 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}"))
}
}
}
fun savePlace(place: PlaceResponse.Place) = PlaceDao.savePlace(place)
fun getSavePlace() = PlaceDao.getSavedPlace()
fun isPlaceSaved() = PlaceDao.isPlaceSaved()
private fun fire(context : CoroutineContext, block : suspend() -> Result) =
liveData(context){
val result = try {
block()
} catch (e: Exception) {
Result.failure(e)
}
emit(result)
}
}
说明:
重点查看fire() 方法,它是封装了liveData 的代码块,传参数是协程的context(可以指定在哪个线程调用),以及block : suspend() -> Result 的意思是传一个block()方法进来,而且因为是在协程中调用,所以需加上 suspend 关键字,然后返回值是Result。
再看下refreshWeather() 中的 async {},指的是异步并发执行,然后await() 阻碍协程方法向下执行,就是两个网络请求并发执行,然后等都执行完后,再接着走下面的逻辑,这样的话比两个请求同步执行等结果要快的多。
四. viewModel 层
viewModel 层主要是用来接收下一层response仓库层传递过来的 liveData 对象,然后封装方法,供上层ui层调用
package com.cx.sunnyweather.ui.weather
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.cx.sunnyweather.logic.WeatherRepository
import com.cx.sunnyweather.logic.model.PlaceResponse
/**
* Created by cx on 2020/8/19
* Describe:
*/
class WeatherViewModel : ViewModel(){
private val locationLiveData = MutableLiveData()
var locationLng = ""
var locationLat = ""
var placeName = ""
// switchMap 方法会观察locationLiveData 这个对象,并在switchMap() 方法的转换函数中调用仓库层中定义的refreshWeather() 方法
// 这样,仓库层返回的liveData对象,就可以转换成一个可供Activity观察的LiveData 对象
val weatherLiveData = Transformations.switchMap(locationLiveData) {
location -> WeatherRepository.refreshWeather(location.lng,location.lat)
}
fun refreshWeather(lng: String, lat: String) {
locationLiveData.value = PlaceResponse.Location(lng, lat)
}
}
五. UI 层
UI 层指的就是 Activity 和 Fragment 专门用来显示ui 界面的一层
第一步: 构建xml 布局
第二步: 响应viewmodel 的liveData 数据,进行页面的刷新
package com.cx.sunnyweather.ui.weather
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.hardware.input.InputManager
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import com.cx.sunnyweather.R
import com.cx.sunnyweather.logic.model.Weather
import com.cx.sunnyweather.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.NewInstanceFactory().create(WeatherViewModel::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= 21) {
val decorView = window.decorView
decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
window.statusBarColor = Color.TRANSPARENT
}
setContentView(R.layout.activity_weather)
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()
result.exceptionOrNull()?.printStackTrace()
}
swipeRefresh.isRefreshing = false
})
swipeRefresh.setColorSchemeResources(R.color.colorPrimary)
refreshWeather()
swipeRefresh.setOnRefreshListener {
refreshWeather()
}
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)
}
})
}
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
}
fun refreshWeather() {
viewModel.refreshWeather(viewModel.locationLng,viewModel.locationLat)
swipeRefresh.isRefreshing = true
}
}
说明:
好啦,写到这里就写完了,主要是梳理了一些Android MVVM 架构编码的基础流程,后续还有待继续优化!