一、背景
互联网技术飞速发展的时代,在Android领域从早期的MVC架构发展到现在的MVP、MVVM架构,开发语言也从Java语言,到新崛起的Kotlin语言,甚至是Flutter工具包所使用的跨平台Dart语言,一门新技术、新语言的出现总是需要不断的迭代,使之变得更为成熟才能更好的运用到实际项目中。
时至今日,MVVM+Kotlin+AAC组合架构已经变得越来越广泛使用,相较于MVP+Java组合架构有着很大的区别和优势:
- 使用Kotlin语言使得代码较Java语言更为简洁;
- 同时Kotlin特有的协程机制,使得异步并发到统一的过程更为高效;
- MVVM基于观察者模式采用数据驱动,去掉了mvp中的view层;
- AAC组件的引入方便生命周期的管理及数据到xml的交互和UI展示更为便捷。
所以我们应该掌握它,在合适的项目的中使用它。
本文是整理和学习了网上的各类资料后,最终自己实践而成的一个MVVM+Kotlin+AAC案例。
下面就随我一道来看看我是怎样搭建MvvmFrame这个项目的。
来一张图认识下MVVM+AAC结合的样子:
二、创建项目
本项目基于Android Studio 4.0.1编译器进行开发。
创建项目时,Language需要选择kotlin,然后Finish完成。
三、配置依赖
项目创建完成之后我们需要配置一些必要的依赖包,
1、首先,我们需要在Module:app 中的build.gradle文件中添加Kotlin的一些插件和协程相关的依赖包
Kotlin插件
apply plugin: 'kotlin-kapt'
协程相关的依赖包
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
2、其次,引入AAC组件
添加生命周期管理依赖包
implementation "androidx.lifecycle:lifecycle-extensions:2.0.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha03"
在android节点下,打开dataBinding数据绑定开关
buildFeatures {
dataBinding = true
}
3、再次,引入网络请求框架retrofit2依赖
implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.5.0'
4、最后,我们得到了一份完整的build.gradle文件清单
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 29
defaultConfig {
applicationId "com.hb.mvvmframe"
minSdkVersion 16
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
dataBinding = true
}
}
dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
implementation "androidx.lifecycle:lifecycle-extensions:2.0.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha03"
implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.5.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
四、创建包
配置依赖完成之后我们就可以开始正式的创建项目的包结构,此操作可以按照个人习惯进行创建。
包data:负责提供数据,数据可包含如本地数据、网络请求返回的数据;
包data.model:放置实体对象;
包data.network:放置服务接口api及Request请求类;
包data.repository:放置请求具体的服务接口类,返回数据;
包ui:负责视图层的显示,包含Actvity,viewModel等类;
包ui.base:放置一些视图层相关类的基类;
包ui.login:放置具体的业务类;
包util:工具包,放置各种工具类。
五、创建关键类或对象
创建包之后我们需要添加一些关键类或对象,这些类主要是项目中需要用到的类,在全局中使用或是基础封装。
目前项目结构演变为如下图所示的样子:
新增了三个文件,两个class和一个object。
ProjectApplication.kt:是项目的Application的子类,作用和我们之前的用法类同,声明了一个全局的上下文对象实例;
SPUtil.kt:是自定义的共享首选项(SharedPreferences )工具类,用来存储键值数据对到本地;
ServiceCreator.kt:是伴生对象,它的成员可以通过容器类的类名来访问,即相当与Java中的静态成员形式,是对网络请求方法的封装,方便其他类调用。
三个文件的清单如下:
ServiceCreator.kt
package com.hb.mvvmframe.data.network
import com.hb.mvvmframe.ProjectApplication
import com.hb.mvvmframe.util.SPUtil
import okhttp3.CacheControl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.net.HttpURLConnection
import java.util.concurrent.TimeUnit
object ServiceCreator {
private const val BASE_URL = "http://127.0.0.1:8080/"
private var REWRITE_CACHE_CONTROL_INTERCEPTOR =
Interceptor { chain: Interceptor.Chain ->
val cacheBuilder = CacheControl.Builder()
cacheBuilder.maxAge(0, TimeUnit.SECONDS)
cacheBuilder.maxStale(365, TimeUnit.DAYS)
val cacheControl = cacheBuilder.build()
var token by SPUtil(
ProjectApplication.context,
"token",
""
)
var request = chain.request()
request = request.newBuilder()
.cacheControl(cacheControl)
.addHeader("token", token)
.build()
val originalResponse = chain.proceed(request)
when (val responseCode = originalResponse.code()) {
HttpURLConnection.HTTP_UNAUTHORIZED -> {
println("未登录")
}
else -> {
println(responseCode)
}
}
val maxAge = 0
originalResponse.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "public ,max-age=$maxAge")
.build()
}
private val httpClient =
OkHttpClient.Builder().addInterceptor(REWRITE_CACHE_CONTROL_INTERCEPTOR)
private val builder = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(httpClient.build())
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
private val retrofit = builder.build()
fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
}
SPUtil.kt
package com.hb.mvvmframe.util
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import kotlin.reflect.KProperty
class SPUtil<T>(val context: Context, val name: String, private val default: T) {
private val prefs: SharedPreferences by lazy {
context.getSharedPreferences(
context.packageName + "_preferences",
Context.MODE_PRIVATE
)
}
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
Log.i("info", "调用$this 的getValue()")
return getSharePreferences(name, default)
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
Log.i("info", "调用$this 的setValue() value参数值为:$value")
putSharePreferences(name, value)
}
@SuppressLint("CommitPrefEdits")
private fun putSharePreferences(name: String, value: T) = with(prefs.edit()) {
when (value) {
is Long -> putLong(name, value)
is String -> putString(name, value)
is Int -> putInt(name, value)
is Boolean -> putBoolean(name, value)
is Float -> putFloat(name, value)
else -> throw IllegalArgumentException("This type of data cannot be saved!")
}.apply()
}
@Suppress("UNCHECKED_CAST")
private fun getSharePreferences(name: String, default: T): T = with(prefs) {
val res: Any =
when (default) {
is Long -> getLong(name, default)
is Int -> getInt(name, default)
is Boolean -> getBoolean(name, default)
is Float -> getFloat(name, default)
is String -> getString(name,default)
else -> throw IllegalArgumentException("This type of data cannot be saved!")
}!!
return res as T
}
}
ProjectApplication.kt
package com.hb.mvvmframe
import android.app.Application
import android.content.Context
class ProjectApplication: Application() {
override fun onCreate() {
super.onCreate()
context=this
}
companion object{
lateinit var context:Context
}
}
六、配置AndroidManifest.xml清单文件
在编写业务代码之前,还得先在AndroidManifest.xml清单文件中添加如下配置:
1、添加网络权限。
<uses-permission android:name="android.permission.INTERNET" />
2、在标签中添加ProjectApplication
android:name=".ProjectApplication"
当项目的 targetSdk>=28,即项目将运行在大于等于Android 9.0的系统时,如果后端网络接口(API)不支持https,也没有https证书的话,则还需添加屏蔽https的配置,否则http请求会失败。
3、在标签中添加屏蔽https的配置语句
android:networkSecurityConfig="@xml/network_security_config"
同时在项目的/res目录下添加xml文件夹,并在/res/xml/文件夹下创建network_security_config.xml文件。
network_security_config.xml文件内容如下:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
七、编写业务代码
目前基础工作准备完毕,我们可以开始编写业务代码了。
如以实现登录模块为例,最终业务代码编写完成后的项目结构图如下:
我们先来看看这三个文件:
LoginActivity.kt
package com.hb.mvvmframe.ui.login
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.ActionBar
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.hb.mvvmframe.ProjectApplication
import com.hb.mvvmframe.R
import com.hb.mvvmframe.databinding.ActivityLoginBinding
import com.hb.mvvmframe.ui.base.BaseActivity
import com.hb.mvvmframe.util.InjectorUtil
import com.hb.mvvmframe.util.SPUtil
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.android.synthetic.main.include_toolbar.*
class LoginActivity : BaseActivity() {
private val viewModel by lazy {
ViewModelProviders.of(this, InjectorUtil.getLoginModelFactory())
.get(LoginViewModel::class.java)
}
private val binding by lazy {
DataBindingUtil.setContentView<ActivityLoginBinding>(
this, R.layout.activity_login
)
}
private var spToken by SPUtil(
ProjectApplication.context,
"token",
""
)
private var spUserName by SPUtil(
ProjectApplication.context,
"username",
""
)
private var spPassword by SPUtil(
ProjectApplication.context,
"password",
""
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun setViewModel() {
binding.viewModel = viewModel
binding.lifecycleOwner = this
}
override fun setAppTitle() {
setSupportActionBar(toolbar)
val actionBar: ActionBar? = supportActionBar
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true)
actionBar.title = null
toolTitle.text = "登录"
}
}
override fun initView() {
viewModel.username = spUserName
viewModel.password = spPassword
la_btn_login.setOnClickListener {
viewModel.login()
}
viewModel.resLogin.observe(this, Observer { res ->
if (res.code == 0) {
//首选项操作
spToken = res.token
spUserName = la_et_name.text.toString().trim()
spPassword = la_et_psd.text.toString().trim()
Toast.makeText(ProjectApplication.context, res.msg, Toast.LENGTH_SHORT).show()
//跳转到其他界面
//var intent = Intent(this, MainActivity::class.java)
//startActivity(intent)
//finish()
} else {
Toast.makeText(ProjectApplication.context, res.msg, Toast.LENGTH_SHORT).show()
}
})
}
}
在上述代码文件中,我们看到这几个override方法setViewModel(),setAppTitle(), initView(),它们都是来自父类BaseActivity,我们进行了分封装,添加了对应的抽象函数。
BaseActivity.kt 文件代码会在后面段落中给出。
读上面这段代码,我们需要关注的几个重点:
- private val viewModel和private val binding是声明的两个懒加载对象;
- viewModel
变量是为了引用LoginViewModel实例而存在,有它我们可以获取或更新网络API返回的数据,并通过binding.viewModel=viewModel
语句将实例传递到xml布局; - binding
变量是为了与R.layout.activity_login布局文件建立数据绑定的链接即LiveData而存在,有它我们可以根据id载入布局中的控件,并直接根据数据的变化绑定和刷新UI界面的数据。
LoginModelFactory.kt
package com.hb.mvvmframe.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.hb.mvvmframe.data.repository.LoginRepository
class LoginModelFactory (private val repository: LoginRepository) : ViewModelProvider.NewInstanceFactory(){
@Override
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return LoginViewModel(repository) as T
}
}
通过工厂的方式创建LoginViewModel对象,方便传入不同的构造函数。
在LoginActivity中我们通过InjectorUtil.getLoginModelFactory()这一句来载入实例对象。
LoginViewModel.kt
package com.hb.mvvmframe.ui.login
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hb.mvvmframe.ProjectApplication
import com.hb.mvvmframe.data.model.ResLogin
import com.hb.mvvmframe.data.repository.LoginRepository
import kotlinx.coroutines.launch
class LoginViewModel(private val repository: LoginRepository) : ViewModel() {
var resLogin = MutableLiveData<ResLogin>()
var username = ""
var password = ""
fun login() {
launch({
resLogin.value = repository.login(username, password)
}, {
Toast.makeText(ProjectApplication.context, it.message, Toast.LENGTH_SHORT).show()
})
}
private fun launch(block: suspend () -> Unit, error: suspend (Throwable) -> Unit) =
viewModelScope.launch {
try {
block()
} catch (e: Throwable) {
error(e)
}
}
}
阅读本类主要有几个注意点:
-
LoginRepository是用于为ViewModel层提供数据的,后面会单独分析它,而我们现在用的是网络请求,所以这个地方我们用到了suspend关键字,也就是挂起的意思,挂起操作将会关联到后续的协程处理。
-
上面LoginActivity.kt、LoginModelFactory.kt、 LoginViewModel.kt三个类涉及到的辅助类InjectorUtil、BaseActivity及布局文件activity_login.xml,这个地方一并给出。
InjectorUtil.kt
package com.hb.mvvmframe.util
import com.hb.mvvmframe.data.network.CommonNetwork
import com.hb.mvvmframe.data.repository.LoginRepository
import com.hb.mvvmframe.ui.login.LoginModelFactory
object InjectorUtil {
fun getLoginModelFactory() = LoginModelFactory(getLoginRepository())
private fun getLoginRepository() = LoginRepository.getInstance(
CommonNetwork.getInstance()
)
}
BaseActivity.kt
package com.hb.mvvmframe.ui.base
import android.R
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setViewModel()
initIntent()
setAppTitle()
initView()
}
private fun initIntent() {}
abstract fun setViewModel()
abstract fun setAppTitle()
abstract fun initView()
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.home -> finish()
else -> {
}
}
return true
}
}
activity_login.xml
(这就是 data binding layout布局的写法,不清楚的童鞋可以参考本系列历史文章(二)Android Jetpack 组件之数据绑定库 (DataBinding))
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.hb.mvvmframe.ui.login.LoginViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include layout="@layout/include_toolbar" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:layout_marginLeft="16dp"
android:layout_marginTop="84dp"
android:text="@string/str_welcome"
android:textColor="@color/color_title"
android:textSize="22sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="84dp"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
tools:context=".ui.login.LoginActivity">
<EditText
android:id="@+id/la_et_name"
android:layout_width="match_parent"
android:layout_height="45dp"
android:imeOptions="actionNext"
android:hint="@string/str_login_name_hint"
android:text="@={viewModel.username}"
android:textColor="@color/color_title"
android:textSize="16sp" />
<EditText
android:id="@+id/la_et_psd"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_marginTop="10dp"
android:hint="@string/str_login_psd_hint"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:text="@={viewModel.password}"
android:textColor="@color/color_title"
android:textSize="16sp" />
<Button
android:id="@+id/la_btn_login"
android:layout_width="match_parent"
android:layout_height="45dp"
android:layout_marginTop="20dp"
android:background="@color/colorPrimary"
android:text="@string/str_login"
android:textColor="@color/white"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
</layout>
接下来我们关注数据提供层(repository)模块,涉及到的几个类如下图所示:
ResLogin.kt 是实体类;
CommonService.kt 这是一个接口类,用于存放我们的API访问接口;
CommonNetwork.kt 负责将ServiceCreator和CommonService组织到一起,进行请求访问,并返回数据,在这个类中我们用到了协程;
LoginRepository.kt 作为我们唯一的数据来源接口,负责将CommonNetwork返回的数据或其他本地查询的数据进行封装,提供统一的API,供上层透明化的调用。
接下来我们看看这些类的代码清单。
ResLogin.kt
package com.hb.mvvmframe.data.model
import com.google.gson.annotations.SerializedName
class ResLogin {
@SerializedName("retCode")
var code = -1
@SerializedName("retMsg")
var msg = ""
var token = ""
lateinit var retObj: RetObjBean
inner class RetObjBean {
private val id = 0
private val name: String? = null
private val phone: Any? = null
private val token: String? = null
private val menuList: List<MenuListBean>? = null
inner class MenuListBean{
private val id = 0
private val name: String? = null
private val updateDate: Long = 0
}
}
}
CommonService.kt
package com.hb.mvvmframe.data.network.api
import com.hb.mvvmframe.data.model.ResLogin
import retrofit2.Call
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Headers
import retrofit2.http.POST
interface CommonService {
@FormUrlEncoded
@Headers("Content-Type:application/x-www-form-urlencoded; charset=utf-8") //传递汉字到后台不乱码
@POST("app_user/login")
fun login(
@Field("loginName") name: String,
@Field("password") psd: String
): Call<ResLogin>
}
CommonNetwork.kt
package com.hb.mvvmframe.data.network
import com.hb.mvvmframe.data.network.api.CommonService
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class CommonNetwork {
private val commonService = ServiceCreator.create(CommonService::class.java)
suspend fun fetchLogin(name: String, password: String) =
commonService.login(name, password).await()
private suspend fun <T> Call<T>.await(): T {
return kotlin.coroutines.suspendCoroutine { continuation ->
enqueue(object : Callback<T> {
override fun onFailure(call: Call<T>, t: Throwable) {
continuation.resumeWithException(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"))
}
})
}
}
companion object {
private var network: CommonNetwork? = null
fun getInstance(): CommonNetwork {
if (network == null) {
synchronized(CommonNetwork::class.java) {
if (network == null) {
network = CommonNetwork()
}
}
}
return network!!
}
}
}
CommonNetwork中我们使用了kotlin.coroutines.suspendCoroutine 协程实例,通过.wait就可以方法进行调用,调用的方法我们需要加上suspend 挂起关键字。
LoginRepository.kt
package com.hb.mvvmframe.data.repository
import com.hb.mvvmframe.data.network.CommonNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class LoginRepository private constructor(
private val network: CommonNetwork
) {
suspend fun login(name: String, password: String) = withContext(Dispatchers.IO) {
val result = network.fetchLogin(name, password)
result
}
companion object {
private var instance: LoginRepository? = null
fun getInstance(network: CommonNetwork): LoginRepository {
if (instance == null) {
synchronized(LoginRepository::class.java) {
if (instance == null) {
instance = LoginRepository(network)
}
}
}
return instance!!
}
}
}
LoginRepository类里面我们可以获取多种数据源,如本地数据或网络请求返回的数据,但对其他层只需要提供统一的API即可。
八、总结
最后来个静态的效果图😀:
整个MVVM架构的案例已经完结 ,本例程需要熟悉kotlin语言对于java的同学可能看起来点陌生,不过大家可以看看本博的其他文章或许会有更多的了解。
(一)Android Jetpack 组件介绍
(二)Android Jetpack 组件之数据绑定库 (DataBinding)
(三)Android Jetpack 组件之ViewModel
(四)Android Jetpack 组件之LiveData
(五)Kotlin的基础用法
原创不易,求个关注。
微信公众号:一粒尘埃的漫旅
里面有很多想对大家说的话,就像和朋友聊聊天。
写代码,做设计,聊生活,聊工作,聊职场。
我见到的世界是什么样子的?
搜索关注我吧。
公众号与博客的内容不同。