对于架构设计,Google一直想要规范开发者的开发习惯,但是在上层应用开发中又太过于灵活,所以一直没有形成统一的规范,即便如此,Google几乎在1-2年的时间范围内,都会推出一种新的架构设计模式,以此来优化此前的架构模式,从MVP,到MVVM,再到现在的MVI。那么在这篇文章中,我将会根据Google的开发者文档中给出的建议,通过实际的代码实现来深入说明架构的准则。
1 分层架构
其实分层架构核心的观念就是分离关注点,Google应用架构简介入口 在Android最早期的MVC架构中,往往在一个Activity或者Fragment中完成了所有业务逻辑的编写,这种设计现在看来就是一种错误,代码臃肿无法扩展,更不要说随着App大小不断增大,从而实现业务的可插拔(扩缩)。
因此从MVP架构出现以后,分离关注点开始逐渐被外界认可,数据层专注于数据生产,界面层专注于数据的展示,而Presenter层或者之后的ViewModel层,则是作为两者之间通信的桥梁。那么对于分层架构,Google对于开发者的建议是如何的呢?
1.1 【强烈建议】使用明确的数据层
数据层,主要用于提供数据,无论是从网络获取,还是从本地数据库获取,都是一个独立的模块,用于公开全部的应用数据,而且会处理绝大部分的业务逻辑。
在实际的项目开发中,建议将数据层存放在data软件包或者data provider模块中,如下
如果将数据层单独拆一个lib_data模块,那么这个模块就是一个公共模块,应用的其余模块均可引用(如果用到数据层的数据),在Google的建议中,即便是只有一个数据源,也要放在单独的软件包或者模块中。
在数据层中,你可以根据业务类型或者数据类型,创建不同的${type}Repository
存储类,例如与登录相关的,可以叫做LoginRepository
;与支付相关的,叫做PaymentRepository
。
记住一点,存储类是唯一可以直接跟数据源打交道的类,其他层不能直接访问数据源,也就意味着外层访问数据层的唯一入口就是存储类。 所以,存储类的主要作用就是:
(1)对外提供应用需要的数据;
(2)集中处理数据的变化;
(3)处理部分业务逻辑;
所以对于存储类来说,需要与数据源绑定在一起,每个数据源类只能负责处理一种数据来源,这个来源可以是网络、本地数据库、本地文件等。 我们拿LoginRepository
举例来说:
登录数据源类LoginRemoteDataSource
:
/**
* 与登录相关的远程数据源,一般指与网络相关的
*/
class LoginRemoteDataSource {
suspend fun login(username: String, password: String): Boolean {
//模拟根据用户名和密码登录
return withContext(Dispatchers.IO) {
//延迟2s
delay(2_000)
NetUtils.login(username, password)
}
}
}
LoginRepository
存储类需要持有远程数据源的引用,如果有本地数据源,那么也需要持有本地数据源的引用。
/**
* 登录数据存储类
*/
class LoginRepository(
private val loginRemoteDataSource: LoginRemoteDataSource
) {
/**
* 登录
* @param username 用户名
* @param password 密码
*/
suspend fun login(username: String, password: String): Boolean {
return loginRemoteDataSource.login(username, password)
}
}
这里讲一下命令规范,对于存储库类以其负责的数据命名。具体命名惯例如下:
数据类型 + Repository。
例如:NewsRepository
、MoviesRepository
或 PaymentsRepository
。
数据源类以其负责的数据以及使用的来源命名。具体命名惯例如下:
数据类型 + 来源类型 + DataSource。数据来源可以使用Remote或者Local来代表,更易懂。
当我们完成存储库类和数据来源类的设计之后,那么其他层要访问数据层,那么就访问LoginRepository
即可。
1.2 【强烈建议】使用明确的界面层
界面层一般指的就是用于渲染界面的Activity或者Fragment,在实际的项目开发中,建议将界面层相关的类放在ui软件包下。
那么界面层的组件,例如Activity、Fragment、ViewModel想要访问数据层的数据时,就会使用到之前的LoginRepository
类,通常对于数据访问存储逻辑,是放在ViewModel中实现,也为了保存界面层的数据。
2 界面层
前面讲到了,在界面层中需要获取数据,就要与数据层通信,而为了避免数据层和界面层之间的强耦合,会采用ViewModel作为两者的中间件来处理,那么对于界面层,Google有哪些规范呢?
2.1 【强烈建议】遵循单向数据流原则
什么是单向数据流呢?就是通过ViewModel来公开界面层的状态,并通过方法调用来接收界面的操作,而且界面层无法更改ViewModel中界面层的状态,以此形成一个单向的数据流,这也是MVI架构的一个核心概念。
/**
* 与登录相关的状态
*/
sealed class LoginUiState {
object IdleUiState : LoginUiState()
class LoginSuccessUiState(val username: String) : LoginUiState()
class LoginErrorUiState(val errorCode: Int, val errorMsg: String) : LoginUiState()
}
LoginUiState
是与登录相关的状态,通过ViewModel向界面层公开,界面层在获取这些状态之后,做出相应的操作即可。
class LoginViewModel(
private val loginRepository: LoginRepository
) : ViewModel() {
/**
* 登录逻辑
*/
fun login(username: String, password: String) {
viewModelScope.launch {
loginRepository.login(username, password)
}
}
}
LoginViewModel
是登录页面持有的ViewModel,其中定义的login
方法就是界面层调用,而ViewModel层接收到界面层的操作之后,开始执行登录的操作。
2.2 【强烈建议】使用生命周期感知型界面状态收集方式
具备生命周期感知的收集方式,主要就是LiveData和Stateflow,而在2.1中,Google建议我们使用ViewModel公开界面层状态,LiveData就不适合这个场景,因此剩下的就是Stateflow,本身Stateflow是不具备生命周期感知的,但是在上篇文章中,我们在介绍Kotlin中的热流时,讲过使用repeatOnLifecycle
API就能够使得flow具备生命周期感知能力。
class LoginViewModel(
private val loginRepository: LoginRepository
) : ViewModel() {
private val _loginState: MutableStateFlow<LoginUiState> =
MutableStateFlow(LoginUiState.IdleUiState)
/**对外暴露登录的状态*/
val loginState: StateFlow<LoginUiState> = _loginState
/**
* 登录逻辑
*/
fun login(username: String, password: String) {
viewModelScope.launch {
val isLoginSuccess = loginRepository.login(username, password)
if (isLoginSuccess) {
_loginState.value = LoginUiState.LoginSuccessUiState(username)
} else {
_loginState.value = LoginUiState.LoginErrorUiState(-1, "登录失败")
}
}
}
}
我们在LoginViewModel
中向界面层暴露了登录的状态loginState
,那么在用户点击登录时,便可以拿到这些状态。
val loginViewModel = LoginViewModel(LoginRepository(LoginRemoteDataSource()))
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
loginViewModel.loginState.collect { state ->
when (state) {
is LoginUiState.IdleUiState -> {
}
is LoginUiState.LoginSuccessUiState -> {
Toast.makeText(
this@MainActivity,
"${state.username}登录成功",
Toast.LENGTH_SHORT
).show()
}
is LoginUiState.LoginErrorUiState -> {
Toast.makeText(
this@MainActivity,
"登录失败",
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
findViewById<Button>(R.id.btn_login).setOnClickListener {
loginViewModel.login("layz4android", "123456")
}
所以这里还会再提一句,为什么LiveData虽然也具备感知生命周期的能力,但是不适用的原因,在状态频繁变化的场景,LiveData会丢失一些状态,但是LiveData一定能够把最新的状态返回界面层。
2.3 【强烈建议】请勿将来自 ViewModel 的事件发送到界面
其实这个建议,我并没有看懂,如果按照单向数据流的原则来看,ViewModel是处理事件的那一方,而界面层只是感知状态的变化,而不需要接收ViewModel的事件。
这里需要注意一点:ViewModel事件应始终会引发界面状态更新。
3 ViewModel
对于ViewModel层的界限,我认为它属于界面层的一部分,但又不完全与界面层强绑定在一起,通过前面的介绍,ViewModel就是用来提供界面状态以及对数据层的访问。
3.1 【强烈建议】ViewModel不应该与Android生命周期有关
ViewModel是有自己的生命周期的,其存在的时间是首次请求ViewModel创建,到宿主完全消失,我们拿Activity
举例,当Activity调用onCreate之后,ViewModel就会被创建。
当屏幕发生旋转时,虽然Activity执行onDestroy但是会立刻重建,此时ViewModel还是存在内存当中,所以为什么会采用ViewModel存储页面状态,这就是原因之一,等到Activity调用finish完全消失之后,ViewModel才会销毁。
那么即便如此,在使用ViewModel时,不能将Activity
、Fragment
、Context
、Resources
等作为依赖传递,如果一定要使用这些参数,那么就不能放到ViewModel中,需要考虑放在其他层中。
3.2 【强烈建议】使用协程和数据流
其实如果我们在使用MVI架构之后,数据流将会变成一个非常常用的工具,而在ViewModel中是会提供viewModelScope
协程作用域,用于开启协程。
所以ViewModel在访问数据层时,因为大多数情况下都是进行网络请求,属于耗时操作,因此数据层中大部分的函数都是挂起函数,所以可以在ViewModel中开启协程,通过异步回调的方式获取服务端的结果,并解析数据分发数据流。
3.3 【强烈建议】在屏幕级别使用ViewModel
什么是屏幕级别,我理解就是我们常见的Activity
和Fragment
;Google官方禁止在一些可重复使用的界面使用ViewModel,如果想要处理可重复使用的界面,需要使用普通的状态容器类(JetPack Compose)。
3.4 【建议】ViewModel公开界面状态
这是Google推出MVI架构之后,出现的一种概念叫UiState,在以往的架构设计中,界面层会拿到服务端请求的数据,根据拿到的数据判断要展示哪个页面。
像这样业务逻辑,如果少还好,但如果这个界面需要依赖多个接口的数据,那么势必会使整个界面层变得臃肿起来,我个人理解界面层应该只负责展示数据,而不是需要根据数据判断自己该展示什么页面。 所以界面层观察ViewModel层公开的界面状态,根据UiState选择展示某个页面,例如loading、error、success等页面。
但是对于一些开发者来说,这种迁移可能需要一段时间,因此Google仅仅是建议开发者使用这种思想,而不是强烈建议,看个人所好了。
像在2.2小节中,我们请求服务端拿到的是一个Boolean类型的数据状态,因此我们可以自己创建一个数据流MutableStateFlow
,并对外提供不可变的StateFlow
;但是如果从服务端拿到的是一个数据流,而且是一个冷流,官方建议使用stateIn将其转换为一个StateFlow。但是我理解在ViewModel层收集数据,将状态往界面层抛反而更合适一些
fun login2(username: String, password: String) {
viewModelScope.launch {
loginRepository.login2(username, password).collect{ userInfo->
if (userInfo == null){
_loginState.value = LoginUiState.LoginErrorUiState(-1,"登录失败")
}else{
_loginState.value = LoginUiState.LoginSuccessUiState(userInfo.username)
}
}
}
}
通过这种方式反而能够统一规范。
这篇文档其实主要介绍了Google对于架构规范给出的一些建议,我们在实际的项目开发中,希望能够按照现代Android开发规范设计我们的架构,有一些建议显得有些啰嗦,但是真正实施下来之后,我们项目整体的可扩展性就会变得很高了。