Android MVVM 开发一个天气App

本篇文章介绍的是使用 MVVM 架构 以及结合 Kotlin + Jetpack 组件 + 协程 + Retrofit 等新技术构建一个查询天气类App!

源码下载地址 : 天气 App 源码的csdn下载地址

首先看下该天气App项目简单架构图
Android MVVM 开发一个天气App_第1张图片

接下来是开发此App 的流程顺序

  1. 依赖库的引入
  2. 数据源层
  3. 仓库层处理转换数据
  4. viewModel 层
  5. ui (Activity + Fragment 层)

一. 依赖库的引入

	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 中的数据来源,一般分为本地数据源和网络数据源

  • 本地数据源 ~~~~ 就是采用持久化技术进行本地化缓存的方式(关系型数据 采用sqlite, 非关系型采用sharedpreference ,文件存储等)
  • 网络数据源 ~~~~ 指的就是采用网络获取Api接口方式,取服务器的数据

网络数据源:这里采用的是第三方彩云天气的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
    }
}

说明:

  1. 构建viewModel 对象实例
  2. swipeRefresh 支持上拉刷新功能
  3. drawLayout 支持抽屉侧滑
  4. viewModel.weatherLiveData.observe(this, Observer { result -> } – LiveData 双向绑定响应式处理

好啦,写到这里就写完了,主要是梳理了一些Android MVVM 架构编码的基础流程,后续还有待继续优化!

你可能感兴趣的:(Android,android)