Android结构设计系列[1]--初识工程

声明:

本文是对Mark Allison系列博客的翻译和学习笔记,感谢作者提供的demo和这么好的博客。

前言

从标题上想必大家已经猜出来了,从本篇开始将是一个系列的Android工程结构设计博文,我们将从一个简单的获取天气并展示的APP开始,一步步的重构并研究如何更好的设计整个工程的结构。本系列中一部分知识点在以前的博文中可以找到相关知识,另一部则是新的框架和特性,如果遇到会详细说明。

说明:在本系列中我会认为大家能熟练使用Kotlin开发Android项目,并且会使用Retrofit、RxJava等主流框架,这些技术会从一开始就会出现在源码中。

显示天气

本案例中的天气数据来自 OpenWeatherApp API,在该页面注册后会发送一份邮件包含一个API Key。

开始类

src目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
src
│ Converter.kt
│ WeatherStationApplication.kt

├─model
│ Common.kt
│ Current.kt

├─net
│ OpenWeatherMap.kt

└─ui
CurrentWeatherFragment.kt
MainActivity.kt
NoPermissionFragment.kt
PreferencesFragment.kt

首先我们看看MainActivity的实现

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
class MainActivity : AppCompatActivity() {

//所需的权限集合
private val permissions: Array<out String> = arrayOf(
ACCESS_FINE_LOCATION,
ACCESS_COARSE_LOCATION
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

//权限检查
if (permissions.any { checkSelfPermission(it) == PERMISSION_DENIED }) {
requestPermissions(permissions, 0)
} else {
supportFragmentManager.transaction(allowStateLoss = true) {
replace(R.id.activity_main, CurrentWeatherFragment())
addToBackStack(CurrentWeatherFragment::class.java.simpleName)
}
}

//设置actionbar(布局中的toolbar)
//注意这里的toolbar使用了kotlin的扩展库,可以方便的使用id来获取对象
setSupportActionBar(toolbar)
}

override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
//处理权限请求结果
if (requestCode == 0) {
supportFragmentManager.transaction(allowStateLoss = true) {
if (this@MainActivity.permissions.any {
checkSelfPermission(it) == PERMISSION_DENIED }) {
//加载无权限Fragment
replace(R.id.activity_main, NoPermissionFragment())
} else {
//加载天气显示Fragment
replace(R.id.activity_main, CurrentWeatherFragment())
addToBackStack(CurrentWeatherFragment::class.java.simpleName)
}
}
}
}
}

ConstraintLayout

MainActivity里面的实现很简单,布局是一个fragment,申请了定位权限来获取当前位置用于获取本地天气情况。根据定位权限来分别跳转到NoPermissionFragmentCurrentWeatherFragment两个Fragment,其中NoPermissionFragment的内容很简单,就是一个提示而已。

any操作符

在kotlin中集合有很多操作符,any就是其中的一个,用来判断集合中是否有满足条件的元素。常见的简单操作符如下:

集合操作符说明
any如果至少有一个元素符合判断条件,则返回true,否则false
all如果集合中所有的元素都符合判断条件,则返回true否则false
count返回集合中符合判断条件的元素总数
max返回集合中最大的一项,如果没有则返回null
min返回集合中最小的一项,如果没有则返回null
forEach遍历所有元素,并执行给定的操作(类似于Java 中的for循环)

权限申请

这里使用了LOCATION权限组的权限,Android M 之后的权限管理机制来申请权限。PackageManager.PERMISSION_DENIED表示应用不具有此权限,PackageManager.PERMISSION_GRANTED表示应用具有此权限。

1
2
3
4
5
6
7
8
//用来检测应用是否已经具有权限
int checkSelfPermission(String permission)

//进行请求单个或多个权限
void requestPermissions(String[] permissions, int requestCode)

//请求权限结果回调
void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults)

有关更多权限问题请参考我的另一篇博文《Android 6.0/7.0权限问题》

Kotlin的扩展库

在使用Kotlin开发Android中我们可以使用安卓扩展库来方便Android开发,可以很方便的访问在xml中定义的控件,不再需要使用findViewById(),请参考我的另一篇博文《Android开发中Kotlin的扩展库及实现Parcelable》

ConstraintLayout

ConstraintLayout使用可视化的方式来编写界面,可以有效地解决布局嵌套过多的问题。使用约束的方式来指定各个控件的位置和关系的,它有点类似于RelativeLayout,但远比RelativeLayout要更强大。

有关ConstraintLayout的使用请参考 guolin 的 《Android新特性介绍,ConstraintLayout完全解析》

核心类

通过上面分析,我们知道天气显示的真正逻辑和界面在CurrentWeatherFragment,首先定义Retrofit的ApiService。

1
2
3
4
5
6
7
8
9
10
interface OpenWeatherMap {

@GET("/data/2.5/weather")
@Headers("Cache-Control: private, max-age=600, max-stale=600")
fun currentWeather(
@Query("lat") latitude: Double, //经度
@Query("lon") longitude: Double, //纬度
@Query("appid") appId: String //api key
): Call<Current>
}

上面代码使用了Okhttp和Retrofit的网络缓存,需要配合Okhttp的setCache()方法使用。

1
2
3
4
5
6
7
8
9
10
11
12
private val cacheSize: Long = 10 * 1024 * 1024

private val okHttpClient: OkHttpClient by lazy {
context?.let {
OkHttpClient.Builder()
.cache(Cache(it.cacheDir, cacheSize)) //设置缓存目录和大小
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
} ?: throw IllegalStateException("Context is not valid")
}

Cache-Control用于指定所有缓存机制在整个请求/响应链中必须服从的指令,常用的Cache-Control值。

Cache-Control说明
public所有内容都将被缓存(客户端和代理服务器都可缓存)
private内容只缓存到私有缓存中(仅客户端可以缓存,代理服务器不可缓存)
no-cache必须先与服务器确认返回的响应是否被更改,然后才能使用该响应来满足后续对同一个网址的请求。因此,如果存在合适的验证令牌 (ETag),no-cache 会发起往返通信来验证缓存的响应,如果资源未被更改,可以避免下载。
no-store所有内容都不会被缓存到缓存或 Internet 临时文件中
must-revalidation/proxy-revalidation如果缓存的内容失效,请求必须发送到服务器/代理以进行重新验证
max-age=xxx (xxx is numeric)缓存的内容将在 xxx 秒后失效, 这个选项只在HTTP 1.1可用, 并如果和Last-Modified一起使用时, 优先级较高

max-age=600 表示当访问此地址后的600秒内再次访问不会去服务器。
max-stale=600 指示客户机可以接收超出超时期间的响应消息。这里客户机可以接收超出600秒的响应消息。

接下来就是在CurrentWeatherFragmentonResume()中请求位置信息,然后根据经纬度来请求天气数据(这意味着每次我们开启屏幕或者返回界面都会执行一次)。

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
override fun onResume() {
super.onResume()
locationProvider.requestUpdates(::retrieveForecast) //请求位置信息
}

//根据经纬度来请求位置信息
private fun retrieveForecast(latitude: Double, longitude: Double) {
Retrofit.Builder()
.client(okHttpClient)
.baseUrl("https://api.openweathermap.org/")
.addConverterFactory(moshiConverterFactory)
.build().apply {
call = create(OpenWeatherMap::class.java)
.currentWeather(latitude, longitude, BuildConfig.API_KEY)
}
call?.enqueue(object : Callback<Current> {
override fun onFailure(call: Call<Current>?, t: Throwable?) {
println("Request Error: $t")
}

override fun onResponse(call: Call<Current>?, response: Response<Current>?) {
println("Got current: ${response?.body()}")
response?.body()?.also { current ->
currentWeather = current
bind(current) //数据和界面绑定
}
}
})
}

设置类

值得注意的是在CurrentWeatherFragment中不仅仅有请求和展示当前天气的功能,还有一个设置入口来更改当前fragment为PreferencesFragment

设置界面

1
2
3
4
5
6
7
8
9
10
11
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
R.id.action_settings -> { //点击设置按钮
fragmentManager?.transaction(allowStateLoss = true) {
replace(R.id.activity_main, PreferencesFragment())
addToBackStack(PreferencesFragment::class.java.simpleName)
}
true
}
else -> super.onOptionsItemSelected(item)
}

PreferencesFragment是一个继承自PreferenceFragmentCompat的Fragment,可以很方便的通过配置xml中的ListPreference来进行菜单的设置,代码如下:

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
class PreferencesFragment : PreferenceFragmentCompat() {

override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
//绑定设置界面的UI
addPreferencesFromResource(R.xml.preferences)
}

override fun onAttach(context: Context?) {
super.onAttach(context)

if (context is AppCompatActivity) {
//更改ActionBar显示
context.supportActionBar?.apply {
title = getString(R.string.units)
setDisplayHomeAsUpEnabled(true)
setHasOptionsMenu(true)
}
}
}

override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
android.R.id.home -> { //返回按钮事件
fragmentManager?.popBackStack()
true
}
else -> super.onOptionsItemSelected(item)
}
}
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"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
android:title="@string/units">
<ListPreference
android:defaultValue="mph"
android:entries="@array/speed_entries" <!-- 定义显示的entries集合 -->
android:entryValues="@array/speed_values" <!-- 定义values集合 -->
android:key="@string/speed_units"
android:summary="%s"
android:title="@string/speed_units" />
<ListPreference
android:defaultValue="celsius"
android:entries="@array/temperature_entries"
android:entryValues="@array/temperature_values"
android:key="@string/temperature_units"
android:summary="%s"
android:title="@string/temperature_units" />
</PreferenceScreen>

然后在显示的时候,在我们的Converter.kt中读取SharePreference中的单位设置,对温度单位进行了转换。

1
2
3
4
5
6
7
8
9
10
11
12
private val sharedPreferences: SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context),

fun speed(value: Float): String =
sharedPreferences.getString("Speed", "mph").let { units ->
when (units) {
"mph" -> msToMph(value)
else -> value
}
}.let { newValue ->
context.getString(R.string.wind_speed, newValue)
}

上面的代码实现了一个现实当前天气的APP,但是代码结构是很糟糕的,如果我们的最终目的仅仅是为了实现这么小的APP,那么这么做确实无可厚非,但是如果我们要实现比较大的项目,特别是需要长期迭代的项目这个工程显然不合格。

创建一个可维护,灵活的代码结构其实并不是很容易,但是这些是促成软件工程的基础,接下来的几篇博文中我们将结合这个小案例来分析一下如何去实现一个更加合理的结构。

源码请参考:https://github.com/StylingAndroid/WeatherStation/tree/introduction