火山天气
天气API:彩云科技 | 开放平台 (caiyunapp.com)
查询全球城市数据信息: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号线" } ] }
查询某地区实时天气:
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 } } } } }
查询未来几天天气信息接口:
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:连接数据和界面的桥梁
实现让业务逻辑和界面展示分离的程序结构设计
UI控制层:Activity,Fragment,布局文件等和界面有关的东西;
ViewModel层:持有和UI元素相关的数据,并保证旋转不会丢失;同时提供接口给UI控制层调用和仓库层通信;
仓库层:判断调用请求的数据去哪里获取
本地数据源:SQLite,SharedPreferences
网络数据库:Retrofit访问服务器提供的Webservice接口来获得
注意:箭头是单向的,表示上一层只能持有下一层的引用。不能反过来也不能跨层
项目结构:
依赖:
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. 城市数据
查询全球城市数据信息: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
的情况。
在com.lmc.volcanoweather包下新建一个VolcanoWeatherApplication类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package com.lmc.volcanoweatherimport android.annotation .SuppressLintimport android.app.Applicationimport android.content.Contextclass VolcanoWeatherApplication :Application () { companion object { @SuppressLint("StaticFieldLeak" ) lateinit var context: Context const val TOKEN = "NL0AtaDgGQO3Ytfg" } override fun onCreate () { super .onCreate() context = applicationContext } }
顺手把天气API的令牌值放进去
在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 数据模型
logic-》model 下建立一个个PlaceResponse.kt
代码:
1 2 3 4 5 6 7 8 package com.lmc.volcanoweather.logic.modelimport com.google.gson.annotations.SerializedNamedata class PlaceResponse (val status: String, val places: List<Place>)data class Place (val name: String, val location: Location, @SerializedName("formatted_address" ) val address: String) data class Location (val lng: String, val lat: String)
2.1.3 网络层
定义一个Retrofit接口(访问彩云天气城市搜索API:logic/network下新建PlaceService)
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.lmc.volcanoweather.logic.networkimport com.lmc.volcanoweather.VolcanoWeatherApplicationimport com.lmc.volcanoweather.logic.model.PlaceResponseimport retrofit2.Callimport retrofit2.http.GETimport retrofit2.http.Queryinterface PlaceService { @GET("v2/place?token=${VolcanoWeatherApplication.TOKEN} &lang=zh_CN" ) fun searchPlaces (@Query("query" ) query: String ) :Call<PlaceResponse> }
创建一个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.networkimport retrofit2.Retrofitimport retrofit2.converter.gson.GsonConverterFactoryobject 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) }
一个统一的网络数据源访问入口(对所有网络请求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.networkimport retrofit2.Callimport retrofit2.Callbackimport retrofit2.Responseimport kotlin.coroutines.resumeimport kotlin.coroutines.resumeWithExceptionimport kotlin.coroutines.suspendCoroutineobject VolcanoWeatherNetwork { private val placeService = ServiceCreator.create<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单例类,作为仓库层的统一封装入口。
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.logicimport androidx.lifecycle.liveDataimport com.lmc.volcanoweather.logic.model.Placeimport com.lmc.volcanoweather.logic.network.VolcanoWeatherNetworkimport kotlinx.coroutines.Dispatchersobject 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) } 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.placeimport androidx.lifecycle.MutableLiveDataimport androidx.lifecycle.Transformationsimport androidx.lifecycle.ViewModelimport com.lmc.volcanoweather.logic.Repositoryimport com.lmc.volcanoweather.logic.model.Placeclass 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 >
2.2.2 RecycleView的善后工作
子项布局
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 >
RecycleView适配器
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.placeimport android.view.LayoutInflaterimport android.view.Viewimport android.view.ViewGroupimport android.widget.TextViewimport androidx.fragment.app.Fragmentimport androidx.recyclerview.widget.RecyclerViewimport com.lmc.volcanoweather.Rimport com.lmc.volcanoweather.logic.model.Placeclass 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
Fragment实现
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.placeimport android.os.Bundleimport android.view.LayoutInflaterimport android.view.Viewimport android.view.ViewGroupimport android.widget.Toastimport androidx.core.widget.addTextChangedListenerimport androidx.fragment.app.Fragmentimport androidx.lifecycle.Observerimport androidx.lifecycle.ViewModelProviderimport androidx.recyclerview.widget.LinearLayoutManagerimport com.lmc.volcanoweather.Rimport 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() } }) } }
将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 >
隐藏原生的ActionBar
1 2 3 4 5 6 <resources > <style name ="AppTheme" parent ="Theme.MaterialComponents.Light.NoActionBar" > ... </style > </resources >
申请权限
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 >
结果:
os:其实地方名,地址,经纬度都拿到了
3. 天气信息
查询某地区实时天气:
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 } } } } }
查询未来几天天气信息接口:
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 数据模型
RealtimeResponse
1 2 3 4 5 6 7 8 9 10 11 package com.lmc.volcanoweather.logic.modelimport com.google.gson.annotations.SerializedNamedata 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内部可以有效防止和其他接口的数据类型重名冲突的情况
DailyResponse
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.lmc.volcanoweather.logic.modelimport com.google.gson.annotations.SerializedNameimport 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) }
整和上面两个的 Weather
1 2 3 package com.lmc.volcanoweather.logic.modeldata class Weather (val realtime: RealtimeResponse.Realtime, val daily: DailyResponse.Daily)
Sky(这个是在UI后面补充的)
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.modelimport com.lmc.volcanoweather.Rclass 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接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 package com.lmc.volcanoweather.logic.networkimport com.lmc.volcanoweather.VolcanoWeatherApplicationimport com.lmc.volcanoweather.logic.model.DailyResponseimport com.lmc.volcanoweather.logic.model.RealtimeResponseimport retrofit2.Callimport retrofit2.http.GETimport retrofit2.http.Pathinterface 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网络数据源访问入口对新加的这个接口进行封装
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 仓库层
修改Repository中的代码:
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.logicimport androidx.lifecycle.liveDataimport com.lmc.volcanoweather.logic.model.Placeimport com.lmc.volcanoweather.logic.model.Weatherimport com.lmc.volcanoweather.logic.network.VolcanoWeatherNetworkimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.asyncimport kotlinx.coroutines.coroutineScopeimport kotlin.coroutines.CoroutineContextobject 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} " ) ) } } } 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
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.wealtherimport androidx.lifecycle.MutableLiveDataimport androidx.lifecycle.Transformationsimport androidx.lifecycle.ViewModelimport com.lmc.volcanoweather.logic.Repositoryimport com.lmc.volcanoweather.logic.model.Locationclass WeatherViewModel : ViewModel () { private val locationLiveData = MutableLiveData<Location>() var locationLng = "" var locationLat = "" var placeName = "" 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
activity_weather.xml(这个在创建Activity的时候会自动创建) 会是很长的一个布局,我们用引用布局的形式。
3.2.2 now
当前天气信息布局
3.2.3 forecast
未来几天天气信息布局
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
未来天气信息的子项布局
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
生活指数的布局
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
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.wealtherimport android.os.Bundleimport android.view.LayoutInflaterimport android.view.Viewimport android.widget.ImageViewimport android.widget.TextViewimport android.widget.Toastimport androidx.appcompat.app.AppCompatActivityimport androidx.lifecycle.Observerimport androidx.lifecycle.ViewModelProviderimport com.lmc.volcanoweather.Rimport com.lmc.volcanoweather.logic.model.Weatherimport com.lmc.volcanoweather.logic.model.getSkyimport 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.SimpleDateFormatimport 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) 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() } }) viewModel.refreshWeather(viewModel.locationLng, viewModel.locationLat) } private fun showWeatherInfo (weather: Weather ) { placeName.text = viewModel.placeName val realtime = weather.realtime val daily = weather.daily 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) 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) } 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 界面跳转
从搜索城市界面跳转到天气界面
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这里改一下:看图
我ui下面weather包名之前写错了,之前是wealther 现在改过来了weather
美化:
之前美化是用的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 window.statusBarColor = Color.TRANSPARENT } ... }
这时候,天气界面的布局整体向上偏移了一些——丑:
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 >
这一行是让系统状态栏留出空间。
好像确实更优雅了
3.3 记录选中城市
3.3.1 存储准备
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.daoimport android.content.Contextimport android.provider.Settings.Global.putStringimport androidx.core.content.editimport com.google.gson.Gsonimport com.lmc.volcanoweather.VolcanoWeatherApplicationimport com.lmc.volcanoweather.logic.model.Placeobject 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 () = VolcanoWeatherApplication.context. getSharedPreferences("sunny_weather" , Context.MODE_PRIVATE) }
仓库中封装一下
1 2 3 4 5 6 object Repository { ... fun savePlace (place: Place ) = PlaceDao.savePlace(place) fun getSavedPlace () = PlaceDao.getSavedPlace() fun isPlaceSaved () = PlaceDao.isPlaceSaved() }
PlaceViewModel中再进行一次封装
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
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 } ... }
进行两处修改:
先把 PlaceAdapter 主构造函数中传入的 Fragment 对象改成 PlaceFragment 对象
当点击了任何子项布局时,在跳转到 WeatherActivity 之前,先调用 PlaceViewModel 的 savePlace() 方法来存储选中的城市。
PlaceFragment
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 手动刷新天气
直接用下拉控件就可以了
在ScrollView的外面嵌套了一层SwipeRefreshLayout
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 >
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 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 头布局加个切换按钮
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 实现滑动菜单功能
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 滑动菜单逻辑
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进行如下修改:
???
加了一层逻辑判断条件
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中的,因此并不需要跳转,只要去请求新选择城市的天气信息就可以了。
出现一个空指针BUG
解决办法:
这两个方法一定要放在最上面
5. 制作APP图标
从 Android8 开始app图标应该是两层:前景层和背景层。
前景层:APP的logo
背景层:衬托APP的Logo(只能定义颜色和纹理,不可以有形状)
图标形状由手机厂商来决定(mask)
6. 生成正式签名的APK文件文件
之前测试的安卓APP是AS有个默认的keystore文件帮我们自动进行了签名(gradle负责)
6.1 AS生成
这个也不好演示,看视频好耶
6.2 Gradle生成
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.总结
弱项:
网络部分,尤其是Retrofit
RecycleView的一些列操作
Fragment