1.前言
在上一篇文章中已经介绍了常规的没有结合Compose UI来使用的MVI模式了,本篇文章就是把之前的内容结合起来,在之前的基础上修改为完整的Compose UI + MVI的案例,如果对于文章中有不理解的可以回过头去看之前的内容.
2.ViewModel的完整代码
class LoginViewModel : ViewModel() {
val loginChannel = Channel<LoginIntent>(Channel.UNLIMITED)
private val loginState = MutableStateFlow(LoginState())
val uiLoginState: StateFlow<LoginState> = loginState
private val toNewPage = MutableSharedFlow<Boolean>()
val uiToNewPage: SharedFlow<Boolean> = toNewPage
init {
viewModelScope.launch {
loginChannel.consumeAsFlow().collect {
when (it) {
is LoginIntent.RunLoginIntent -> {
login()
}
}
}
}
}
private fun login() {
loginState.value = loginState.value.copy(isLogin = true)
viewModelScope.launch {
val name = loginState.value.nameCache.value
val password = loginState.value.passwordCache.value
val enPassword = getEncryptPassword(password)
val loginToken = APIManager.requestLoginToken(name, getEncryptPassword(password), getVerify(name, enPassword))
//以上delay是模拟了一个请求耗时
loginState.value = loginState.value.copy(isLogin = false)
//如果token不是空的话,就代表登录成功了,emi登录成功的消息出去,让UI执行操作
if (loginToken.isNotEmpty()) {
toNewPage.emit(true)
}
}
}
private fun getEncryptPassword(password: String): String {
//这里随便做一下密码加密,实际应该是做MD5处理或者其他算法处理,密码不以明文形式提交
return password.plus("abc")
}
private fun getVerify(userName: String, password: String): String {
//这里生成校验秘钥,用于接口请求校验,也可以在请求头里面做,一般是用于进一步防止别人模拟请求
return "This is where the data is encrypted"
}
open class LoginIntent {
object RunLoginIntent : LoginIntent()
}
}
ViewModel
中定义了Channel
和对应的登录状态数据还有需要View
层执行动作的Flow
,一样对外暴露的还是抽象接口,然后在init
函数中订阅channel
,当接收到RunLoginIntent
意图的时候就执行login
函数.在函数中分别执行了以下步骤
- 设置是否正在登录中为
true
,可以看到这里我们通过copy
函数,很方便的就修改了其中某一个值,在页面中接收到这个值的改变后,就会显示登录动画.- 对密码明文进行加密,这里只是简单演示一下,实际会复杂一些.
- 生成校验内容,这个一般是用来服务器防止模拟请求,客户端配合处理就行了
- 调用
APIManager
请求登录数据,这里模拟的是返回一个token
,后续使用这个token
去做其他事情,实际可能是返回一个用户完整数据+令牌或者是其他之类的.- 登录接口返回之后,就把登录中的状态取消掉,因为耗时操作已经完成了.
- 最后判定如果是登录成功,就通过
toNewPage
字段emit
跳转页面的信息出去,实际的业务这里应该还会有登录失败了的话,需要提示用户登录失败之类的提示.
3.View的完整代码
LoginActivity
class LoginActivity : ComponentActivity() {
companion object {
const val TAG = "LoginActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LoginView().initView()
CollectState()
}
}
@Composable
private fun CollectState() {
hiltViewModel<LoginViewModel>().apply {
uiToNewPage.collectWithEffect {
if (it) {
//执行登录跳转页面
iLog("$TAG 跳转到主页页面")
}
}
uiLoginState.collectWithEffect { value ->
if (value.isLogin) {
//显示登录加载框
iLog("$TAG 显示了登录进度条")
return@collectWithEffect
}
iLog("$TAG 隐藏登录进度条")
}
}
}
}
const val UNIT_EXT = 0xff0011
需要注意一下,这里的
hiltViewModel
需要引入一个compose
的库androidx.hilt:hilt-navigation-compose:1.0.0
onCreate
中还是调用的setContent
函数去设置LoginView
完了去订阅相关的业务数据- 订阅数据中,通过接收到状态,去决定跳转以及显示网络请求动画(这里只是标识了一下,实际是操作对应的弹窗以及调用页面跳转代码)
- 一般情况下是一个组数据就可以了,但是这里因为有跳转页面这种一次性的逻辑操作,所以加了
uiToNewPage
的变量- 可以看到所有的数据都是通过
uiLoginState
来管理的,不管是记录的数据状态,还是通知到UI
的状态,维护数据的话也是通过这一个State
就行了
LoginView
class LoginView() {
@OptIn(ExperimentalUnitApi::class)
@Preview
@Composable
fun initView() {
Column(
modifier = Modifier
.background(color = colorResource(id = R.color.bg_white))
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LogIcon()
VSpacer(30)
Text(text = "Welcome to example", fontWeight = FontWeight.Bold, fontSize = TextUnit(23f, TextUnitType.Sp))
VSpacer(30)
InputEdit(hint = "please input your userName")
VSpacer(5)
InputEdit(hint = "please input your password", true)
VSpacer(30)
val loginViewModel: LoginViewModel = hiltViewModel()
Button(modifier = Modifier.semantics { testTag = "test" }, onClick = {
loginViewModel.loginChannel.trySend(LoginViewModel.LoginIntent.RunLoginIntent).getOrThrow()
}) {
Text(text = "Login")
}
}
}
@Composable
fun LogIcon() {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = "图标",
modifier = Modifier
.width(50.dp)
.height(50.dp)
.clip(CircleShape)
.border(2.dp, colorResource(id = android.R.color.black), CircleShape)
)
}
@Composable
fun VSpacer(height: Int) {
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(height.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InputEdit(hint: String, isPassword: Boolean = false) {
val loginViewModel: LoginViewModel = hiltViewModel()
val name = remember { loginViewModel.uiLoginState.value.nameCache }
val password = remember { loginViewModel.uiLoginState.value.passwordCache }
val state = if (isPassword) password else name
val transformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None
val hintStr = if (isPassword) "Password" else "UserName"
TextField(visualTransformation = transformation, value = state.value, onValueChange = {
state.value = it
}, modifier = Modifier
.width(260.dp)
.height(56.dp), label = { Text(text = hintStr) }, keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done, keyboardType = KeyboardType.Password
), placeholder = { Text(text = hintStr, Modifier.alpha(0.5f)) })
}
}
前面的还是和之前文章一样,只是在数据设置这一块做出了修改,
state
变量直接放在了数据集中,在实际使用的时候通过remeber
直接引用,可以看到和Compose UI
结合之后,有许多Compose
现成的函数调用,可以直接和View
结合起来,这里就不用再费劲的订阅了,直接通过函数引用就可以了.
4.扩展函数相关
@Composable
fun <V> Flow<V>.collectWithEffect(collector1: FlowCollector<V>): Int {
LaunchedEffect(key1 = UUID.randomUUID()) {
collect(collector1)
}
return UNIT_EXT
}
这里订阅的时候通过
LaunchedEffect
来订阅数据变化,进行了一下基础的封装.
5.总结
到此Compose UI + MVI
整体结构内容基本完成,可以看到MVI
模式在android
中的使用google
是想要结合Compose UI
来使用的,所以对此在Compose
中一些现成的函数支撑,但是MVI
本身是一种模式,不一定要绑定Compose UI
使用,甚至都不一定要使用Kotlin
,像Channel
,Flow
这些,哪怕是换了语言或者换了平台,只要是面向对象的语音,应该都是可以定制出来,所以不用整个项目转换为Compose UI + MVI
也可以使用这种模式 ,但是最好的毕竟是有google
支持,使用这种模式还是绑定使用Compose UI
来.后续还会横向对比优缺点.