Android进阶宝典 -- MVI架构设计改造

952 阅读4分钟

相关推荐
# Android进阶宝典 -- Google官方架构MVI

在之前的一篇文章中,简单引入了MVI架构,其实现在架构模式这么多,选择性也多,甚至有些项目中还在使用MVP。最近接手的一个项目,因为是多方合作开发,但因为是组件化开发,虽然其他模块还在使用MVP,但是新增的模块我已经统一使用了MVI架构,之后整个项目重构,我也会将MVP完全迁移至MVI架构。

1 为什么要使用MVI

其实这个问题在每个新架构出来之后都会问,为什么不使用MVVM,数据双向绑定它不香吗?其实我们在使用MVVM架构的时候,使用到的并不一定是原汁原味的MVVM,大家都在使用databinding吗?我觉得这个肯定不是,说实话databinding并不好用,而且它有一个最大的问题:UI层数据变化,导致VM层数据变化

1.1 实际的MVVM

这里我们不考虑databinding的问题,而是谈一下实际项目中的MVVM。因为架构是整体而不是某个点,不能因为没有使用databinding就认为架构模式不是MVVM。

相信大部分的伙伴们,如果使用了MVVM架构,大部分的伙伴们应该都是这么用的:

private val _userInfo:MutableLiveData<User>  = MutableLiveData()
val userInfo:LiveData<User> =_userInfo

fun getUserInfo(username:String,password:String){
    viewModelScope.launch {
        //模拟网络请求耗时操作
        Thread.sleep(2000)
        _userInfo.postValue(User(username,password,4,false,"男"))
    }
}

在ViewModel中定义私有变量_userInfo用于改变数据结果,提供公开userInfo变量由页面(Activity或者Fragment)监听,其实这种关系也是单向的,UI层是无法改变ViewModel中存储的数据的,这种方式其实解决了MVVM中数据双向绑定带来的问题。

viewModel.userInfo.observe(viewLifecycleOwner) {
    Toast.makeText(requireContext(), "$it", Toast.LENGTH_SHORT).show()
    findNavController().navigate(R.id.action_firstFragment_to_secondFragment)
}

binding.btnLogin.setOnClickListener {
    viewModel.getUserInfo(binding.etUsername.toString(), binding.etPassword.toString())
}

在View层,用户触发操作之后,请求获取数据;同时会监听userInfo的数据变化,如果数据变化之后,就跳转进入到下一页。

上面的写法只是单纯地处理数据,其实除了数据,页面中需要处理的还有各种状态,例如:加载loading、网络错误、无网络、弱网等等

private val _eventState: MutableLiveData<EventState> = MutableLiveData()
val eventState: LiveData<EventState> = _eventState


fun getUserInfo(username: String, password: String) {
    viewModelScope.launch {
        _eventState.postValue(EventState(isLoading = true))
        //模拟网络请求耗时操作
        Thread.sleep(2000)
        _eventState.postValue(EventState(isLoading = false,data = User(username,password,4,false,"男")))
    }
}
viewModel.eventState.observe(viewLifecycleOwner) { state ->
    if (state.isLoading) {

    }
    if (state.isNetError) {

    }
    if (state.isNoNet) {

    }
    if (state.isWeakNet) {

    }
}

在业务层模块,需要知道这些状态并做出相应的页面展示,但是这样处理违背了关注分离的原理,UI层应该只是展示UI,而不需要关心是否是网络失败了,是否请求成功了这样的操作。 而是只负责是不是要展示数据,是不是要展示loading动画,是不是要

1.2 MVI能解决这些问题?

image.png

首先我们看下官网的这张图,在ViewModel层封装了UI state,UI层主要负责向ViewModel分发事件,所以整个数据流向就是单向的,其实在1.1小节中的示例数据流同样也是单向的。

# Android进阶宝典 -- Google官方架构MVI这篇文章中,简单介绍了MVI的使用方式,但是那种使用方式并不是真正的MVI,而且在官方文档中大部分在介绍Ui state,具体的操作流程并没有详细介绍,那么接下来就看下,真正的MVI能够解决哪些问题。

2 真正的MVI架构实战

2.1 构建Intent试图

这里的Intent并不是Activity之间传递数据的Intent,而是用户操作的试图,例如点击按钮请求获取数据,从官方的图中可以看到是封装在ViewModel中的,所以我们在ViewModel中封装一层试图。

sealed class LoginIntent {
    class pressLogin(username: String, password: String) : LoginIntent() //点击登录
    class getNews() : LoginIntent() //获取新闻
}

不知道有没有不知道sealed封闭类是干啥用的,其实sealed class你可以把他看做一个抽象类,不能被实例化,但是内部存储的子类是可以实例化的。

这里是定义了两个试图,第一个就是在点击登录按钮是需要UI层发送的试图,只做这个事情。

val channel:Channel<LoginIntent> = Channel(Channel.UNLIMITED) //用于封存试图

init {
    initHandleIntent()
}

//分发试图
private fun initHandleIntent() {
    viewModelScope.launch {
        channel.consumeAsFlow().collect { 
            when(it){
                is LoginIntent.pressLogin -> getUserInfo(it.username,it.password)
                is LoginIntent.getNews -> getNews()
            }
        }
    }
}

在ViewModel中,创建了一个管道容器Channel,为啥用Channel,因为我们采用Flow的形式必定需要使用协程间的通信。然后当ViewModel接收到试图之后,完成相应的分发。

binding.btnLogin.setOnClickListener {
    lifecycleScope.launchWhenCreated {
        viewModel.channel.send(LoginIntent.pressLogin(binding.etUsername.toString(), binding.etPassword.toString()))
    }
}

我们看在UI层只做一件事,就是发送试图然后监听,这样做有什么好处呢?

image.png

想想我们之前是怎么做的,是不是直接调用了ViewModel中的方法,这种其实就是强耦合,如果这个方法有改变,或者参数有改变,哪个就需要改动UI层的代码;在使用了MVI这种方式之后,其实只需要修改ViewModel中的逻辑

image.png

这么看是不是UI层已经跟ViewModel完成了一次解耦,并且与ViewModel完成闭环,单向数据流已经初步呈现。

2.2 创建UI state

sealed class LoginUiState {
    class success(user: User) : LoginUiState()
    class error(exception: Exception) : LoginUiState()
}

首先我们只看成功或者失败,定义了两种状态。

private val _loginUiState: MutableStateFlow<LoginUiState> =
    MutableStateFlow(LoginUiState.success(null))
val loginUiState: StateFlow<LoginUiState> = _loginUiState.asStateFlow()
private fun getUserInfo(username: String, password: String) {
    viewModelScope.launch {
        try {
            //模拟网络请求耗时操作
            Thread.sleep(2000)
            _loginUiState.value = LoginUiState.success(User(username, password, 3, false, "男"))
        } catch (e: Exception) {
            _loginUiState.value = LoginUiState.error(e)
        }
    }
}

当UI state发送过来之后,监听状态做对应的跳转处理即可

lifecycleScope.launch {
    viewModel.loginUiState.collect { state ->
        when (state) {
            is LoginUiState.success -> {
                dealLoginUi(state)
            }
            is LoginUiState.error -> {
                Toast.makeText(requireContext(), "出错了", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

还是回到我们前面的话题,这种处理方式没问题,条理很清楚,但是一个重要的架构设计原则-分离关注点原则

要遵循的最重要的原则是分离关注点。 一种常见的错误是在一个 Activity 或 Fragment 中编写所有代码。这些基于界面的类应仅包含处理界面和操作系统交互的逻辑。您应使这些类尽可能保持精简,这样可以避免许多与组件生命周期相关的问题,并提高这些类的可测试性

MVC架构为什么已经被废弃,就是因为在Activity中做了全部的逻辑处理,维护起来非常困难,因此发展到今天的MVI架构,目的就是为了UI层做的更加精简。

2.3 分离关注点架构原则

因为要考虑关注点分离,那么我们可以关注跟UI相关的两个点:UI数据更新、状态展示。

UI数据更新,指的是从网络层获取的数据,需要在UI更新展示;状态展示则是指用发起试图到数据返回中间这个过程中一系列的状态,例如loading、网络错误、空页面等。

sealed class LoginViewEvent {
    class showLoading : LoginViewEvent()
    class hideLoading : LoginViewEvent()
    class showNetworkError : LoginViewEvent()
    class showEmpty : LoginViewEvent()

    class showResult(weather: Weather) : LoginViewEvent()
}

所以我们需要关注的无非就是这几个数据指标,而且对于要显示哪种状态全部都在ViewModel中完成,而不是在UI层判断我是要展示哪种状态。

private fun getWeather() {

    viewModelScope.launch {
        flow {
            val result = WeatherDataSource.getWeather("北京")
            emit(result)
        }.catch {
            Log.e("TAG", "网络异常")
            _loginViewEvent.value = LoginViewEvent.showNetworkError()
        }.onStart {
            //开始请求网络
            Log.e("TAG", "开始请求数据")
            _loginViewEvent.value = LoginViewEvent.showLoading()
        }.onEach {
            Log.e("TAG", "获取到数据 ${it.result}")
        }.map {
            Log.e("TAG", "处理数据")
            if (it.result == null) {
                _loginViewEvent.value = LoginViewEvent.showEmpty()
            } else {
                _loginViewEvent.value = LoginViewEvent.showResult(it)
            }
        }.collect()
    }
}

这样在UI层就完全由数据驱动UI层的展示,监听LoginViewEvent状态的变化

lifecycleScope.launch {
    viewModel.loginViewEvent.collect { state ->
        when (state) {
            is LoginViewEvent.showLoading -> Toast.makeText(
                requireContext(),
                "showLoading",
                Toast.LENGTH_SHORT
            ).show()
            is LoginViewEvent.hideLoading -> {}
            is LoginViewEvent.showResult -> dealLoginUi(state)
            is LoginViewEvent.showEmpty -> {}
            is LoginViewEvent.showNetworkError -> {}
        }
    }
}

因为数据源单一且可靠,如果某个地方出现问题,例如空页面或者错误页面,可以直接反推查找问题根源,易于排查问题。

2.4 StateFlow监听重复状态

在前面,我们是在UI层监听了事件状态做了页面的跳转

private fun dealLoginUi(state: LoginViewEvent.showResult) {
    binding.tvResult.text = state.weather.toString()
    findNavController().navigate(R.id.action_firstFragment_to_secondFragment)
}

如果我们采用Navigation进行页面跳转的时候,因为跳转Fragment会导致页面重建,而且每个Fragment共享Activity的ViewModel,所以当返回跳转页面时,因为ViewModel中存储的页面状态还是showResult,会导致页面无法返回。

2022-10-22 23:25:36.411 21887-21887/com.lay.mvi E/TAG: state==>com.lay.mvi.webview.LoginViewEvent$showResult@f5ab807
2022-10-22 23:25:40.153 21887-21887/com.lay.mvi E/TAG: onViewCreated
2022-10-22 23:25:40.153 21887-21887/com.lay.mvi E/TAG: state==>com.lay.mvi.webview.LoginViewEvent$showResult@f5ab807

其实我们可以看到,两个state是完全一致的;在官网中有一个处理方法就是,在完成瞬时消息后,可以调用ViewModel的方法去处理事件状态


fun resetLoginViewEvent() {
    _loginViewEvent.value = LoginViewEvent.hideLoading() //hideLoading是一个干净的操作
}
private fun dealLoginUi(state: LoginViewEvent.showResult) {
    binding.tvResult.text = state.weather.toString()
    findNavController().navigate(R.id.action_firstFragment_to_secondFragment)
    // Once the message is displayed and dismissed, notify the ViewModel.
    viewModel.resetLoginViewEvent()
}

这种处理方式其实不是很优雅,而且容易忘记需要UI层通知ViewModel层处理,会增加一些模板代码。

2.5 StateFlow局部刷新

在状态决定一切的架构中,状态频繁地刷新会导致UI频繁刷新,那么对于同一状态的某个属性值发生变化,不应该引起其他属性值对应的UI发生刷新,因此可以使用Flow中的distinctUntilChanged函数来处理,当发送过来的数据跟上次是一样的,就不会引起数据的刷新。

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.loginUiState
            .map { it.result }.distinctUntilChanged().collectLatest {
                Log.e("TAG", "weather ${it.hashCode()}")
                if (it != null) {
                    binding.tvResult.text = it.toString()
                }
            }
    }
}

那么如果一个UiState中属性特别多,那么对于每个属性写一套上面的模版代码没有必要,因此可以写一个扩展方法。

fun <T, A> StateFlow<T>.observeState(
    lifecycleOwner: LifecycleOwner,
    property: KProperty1<T, A>,
    action: (A) -> Unit
) {
    lifecycleOwner.lifecycleScope.launch {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            this@observeState.map {
                StateParams(property.get(it))
            }.distinctUntilChanged().collect { (a) ->
                action.invoke(a)
            }
        }
    }
}

internal data class StateParams<A>(val a: A)

那么在使用的时候就比较简单了

viewModel.loginUiState.observeState(this, LoginUiState::result) {
    Log.e("TAG", "刷新---------")
    if (it != null) {
        binding.tvResult.text = it.toString()
    }
}

其实架构没有好坏之分,有些同事他们在用MVP架构也用的很好,用RxJava也很好,新出的架构很大意义上是解决以往架构上存在的问题,而不是一味地炫技,有所取舍,根据自己的业务场景选择适合自己的开发模式。