[架构]Android MVI 实践指南

972 阅读4分钟

总览


  • UI Layer

    Activity或者Fragment(推荐)

    1. view、viewmodel初始化工作
    2. view、viewmodel监听绑定
    • View Layer

      根据页面数据(UIState)显示界面

    • ViewModel Layer

      将原始数据转换为页面数据(UIState)能力

  • Domain Layer
    1. ViewModel可复用逻辑
    2. 需要Repository的逻辑
  • Data Layer

    基本数据源

    1. 网络数据
    2. 系统数据

ViewModel的定位比在MVVM中更偏向于View层,所以将View层和ViewModel层统一作为UI layer,ViewModel中原本更偏向于数据层的逻辑处理被放在新增的Domain Layer。

单一数据源

view只根据uiState显示内容,不提供对外可直接修改view的方法

单向数据流

view自身不能直接更改自己显示内容,只能通过Action将修改的意图通知ViewModel,ViewModel修改uiState后view根据uiState刷新

例如:checkBox是否选中修改流程
点击后,CheckBox(View)不应该直接将自己改为checked,而是通过ViewModel修改uiState,将uiState.isChecked修改为true,然后CheckBox(View)会自动根据isChecked属性将自己设置为checked

转存失败,建议直接上传图片文件

更多细节

一次性事件

一般是show Toast/AlertDialog

UILayer快速处理Action/Event

UILayer.reduce

  例如需要Fragment/Activity才能处理的事件 & 完全不需要ViewModel & 不需要修改UIState,比如Activity.finish()

UILayer.handleEvent

  比如Activity.finish()

Demo

点击AA点击B点击AB
Data1 增加两次+ToastData2增加1次 + Toast1. Data1 增加两次+Toast 2. Data2增加1次 + Toast

目录结构

├── data
│   ├── Data1Repository.kt
│   └── Data2Repository.kt
├── domain
│   ├── ChangeData1AndData2UseCase.kt
│   ├── ChangeData1UseCase.kt
│   └── ChangeData2UseCase.kt
└── ui
    ├── MainActivity.kt
    ├── contract
    │   ├── UIAction.kt
    │   ├── UIEvent.kt
    │   └── UIState.kt
    ├── view
    │   └── SwithunView.kt
                AView
    └── viewmodel
        └── SwithunViewModel.kt
                - AViewModel

UI Layer

shell

  • 一般是Activity/Fragment

  • 主要做的事情

    • viewmodel初始化
    • view初始化
    • view viewmodel 监听绑定
    • 部分action/event处理
class MainActivity : AppCompatActivity() {

    private var view: SwithunView? = null
    private val viewModel: SwithunViewModel by viewModels<SwithunViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        initViewModel()
        initView()
        initObserve()
    }

    private fun initViewModel() {
        viewModel.init(SwithunViewModel.InitData("I am init data"))
    }

    private fun initView() {
        ActivityMainBinding.inflate(LayoutInflater.from(this)).let { binding ->
            setContentView(binding.root)
            this.view = SwithunView(binding, ::reduce)
        }
    }

    private fun initObserve() {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.RESUMED) {
                viewModel.state.collect {
                    view?.bindState(it)
                }
            }
        }
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.RESUMED) {
                viewModel.event.collect {
                    view?.handleEvent(it)
                }
            }
        }
    }

    private fun reduce(action: UIAction) {
        Log.d(TAG, "[reduce] ${action.toLogStr()}")
        when (action) {
            is UIAction.ClosePageAction -> { }
            else -> viewModel.reduce(action)
        }
    }

    companion object {
        private const val TAG = "MainActivity"
    }

}

view

  1. 只有两个public方法

    1. bindState:根据uiState操作view
    2. handleEvent:响应事件,一般是Toast / AlterDialog
  2. 所有UI变更都是由UIState的变更造成

  3. 只关心一个页面中的内容的展示,与页面生命周期相关的事情通过Action传递给shell(Activity / Fragment)处理

class SwithunView(
    private val binding: ActivityMainBinding,
    private val reduce: (UIAction) -> Unit
) {

    private val context = binding.root.context

    init {
        ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
        binding.AA.setOnClickListener { reduce(UIAction.ClickAAAction("AA Info")) }
        binding.AB.setOnClickListener { reduce(UIAction.ClickABAction("AB Info")) }
        binding.B.setOnClickListener { reduce(UIAction.ClickBAction("B Info")) }
    }

    fun bindState(state: UIState) {
        Log.d(TAG, "[bindState] ${state.toLogStr()}")
        when (state) {
            UIState.Loading -> showLoading()
            is UIState.Normal -> showNormal(state)
        }
    }

    fun handleEvent(event: UIEvent) {
        Log.d(TAG, "[handleEvent] ${event.toLogStr()}")
        when (event) {
            is UIEvent.ToastEvent -> {
                Toast.makeText(context, "Event: ${event.msg}", Toast.LENGTH_SHORT).show()
            }
            UIEvent.ClosePageEvent -> { }
        }
    }

    private fun showLoading() {

    }

    private fun showNormal(state: UIState.Normal) {
        bindA(state.data1, state.data2)
        bindB(state.data2)
    }

    private fun bindA(data1: String, data2: Int?) {
        binding.AA.text = "A_A: [data1($data1)]"
        binding.AB.text = "A_B: [data1($data1), data2($data2)]"
    }

    private fun bindB(data2: Int?) {
        binding.B.text = "B: [data2($data2)]"
    }


    companion object {
        private const val TAG = "SwithunView"
    }

}

ViewModel

  1. 只有两个public方法

    1. init:提供必需的初始化参数
    2. reduce:处理UI事件
  2. 大致结构

    1. uiState

    2. uiEvent

    3. init()

    4. reduce()

      1. handleXXXAction (use XXXUseCase)
  3. 其他ViewModel flag、缓存数据可以全部统一放在VMData中,方便处理比如需要“重启”的情况——可以快速找到哪些需要被重置的flag、缓存等等

class SwithunViewModel : ViewModel() {

    private val _state: MutableStateFlow<UIState> = MutableStateFlow(UIState.Loading)
    val state: StateFlow<UIState> = _state.asStateFlow()

    private val _event: MutableSharedFlow<UIEvent> = MutableSharedFlow()
    val event: SharedFlow<UIEvent> = _event.asSharedFlow()

    private val data1Repository = Data1Repository()
    private val data2Repository = Data2Repository()

    fun init(initData: InitData) {
        Log.d(TAG, "[init] ${initData.data}")
        initObserve()
        _state.value =
            UIState.Normal("我是Data1[${data1Repository.getData1()}]", data2Repository.data2.value)
    }

    fun reduce(action: UIAction) {
        Log.d(TAG, "[reduce] ${action.toLogStr()}")
        when (action) {
            is UIAction.ClickAAAction -> handleClickAAAction()
            is UIAction.ClickABAction -> handleClickABAction()
            is UIAction.ClickBAction -> handleClickBAction()
            is UIAction.ClosePageAction -> { }
        }
    }

    private fun handleClickAAAction() {
        viewModelScope.launch {
            ChangeData1UseCase(data1Repository).execute().collect { newData1 ->
                setNormaState { copy(data1 = "我是Data1[${newData1}]") }
                _event.emit(UIEvent.ToastEvent("我是Data1[${newData1}]"))
            }
        }
    }

    private fun handleClickABAction() {
        viewModelScope.launch {
            ChangeData1AndData2UseCase(data1Repository, data2Repository).execute()
                .collect { (type, value) ->
                    when (type) {
                        "Data1" -> {
                            setNormaState { copy(data1 = "我是Data1[${value}]") }
                            _event.emit(UIEvent.ToastEvent("我是Data1[${value}]"))
                        }

                        else -> {}
                    }
                }
        }
    }

    private fun handleClickBAction() {
        viewModelScope.launch {
            ChangeData2UseCase(data2Repository).execute()
            _event.emit(UIEvent.ToastEvent("我是Data2[${data2Repository.data2.value}]"))
        }
    }

    private fun initObserve() {
        viewModelScope.launch {
            data2Repository.data2.collect { newData2 ->
                setNormaState { copy(data2 = newData2) }
            }
        }
    }

    private fun setNormaState(reducer: UIState.Normal.() -> UIState.Normal) {
        this._state.value.asNormal { normal ->
            this._state.value = reducer(normal)
        }
    }

    data class InitData(val data: String)

    data class VMData(val flag1: Int, val otherData: String)

    companion object {
        private const val TAG = "SwithunViewModel"
    }
}

contract

UIState

  1. 不同状态通过不同实现类区分,避免放在一个类中导致本来在某个属性下一定不可空的属性不得不写为可空。

  2. UIState属性应该是跟业务逻辑不相关的数据类型,尽量是一些基本数据类型: String ,Int, Bool,Enum 尽量是view可以不需要二次处理直接可以用的,比如可以给XXXTextView.text = 某个属性值

    1. 容易mock UIState —— 容易测试
    2. 新idl尚不可用的时候也不影响写View层
  3. UIState中的所有属性都是不可变的,ViewModel想要更新uiState只能新建一个UIState(可通过data class copy方法简化新建流程)

    1. 在某个时刻一次性拿到所有在这个时刻的所有跟UI相关的属性值
sealed interface UIState {

    data object Loading : UIState

    data class Normal(val data1: String, val data2: Int?) : UIState

}

UIAction

sealed interface UIAction {

    class ClickAAAction(val someInfo: String): UIAction
    class ClickABAction(val someInfo: String): UIAction
    class ClickBAction(val someInfo: String): UIAction
    data object ClosePageAction: UIAction
}

UIEvent

sealed interface UIEvent {
    data class ToastEvent(val msg: String) : UIEvent
    data object ClosePageEvent : UIEvent
}

Domain Layer

  1. 仅有一个public方法: execute
  2. 对于可能有多次返回值的情况可以使用Flow
  3. UseCase尽量设计为可组合方式,避免一个庞大的、难以复用的Use Case,多个UseCase可以组合为更复杂的UseCase
class ChangeData1UseCase(private val repository: Data1Repository) {

    fun execute(): Flow<Int?> = flow {
        // change1
        repository.changeData1()
        emit(repository.getData1())

        // change1 again
        repository.changeData1()
        emit(repository.getData1())
    }

}
class ChangeData2UseCase(private val data2Repository: Data2Repository) {

    suspend fun execute(): Int? {
        data2Repository.changeData()
        return data2Repository.data2.value
    }

}
class ChangeData1AndData2UseCase(private val data1Repository: Data1Repository, private val data2Repository: Data2Repository) {
    fun execute() = flow<Pair<String, Int?>> {
        ChangeData1UseCase(data1Repository).execute().collect {
            emit("Data1" to it)
        }
        emit("Data2" to ChangeData2UseCase(data2Repository).execute())
    }
}

Data Layer

两种方式

  1. data1为普通变量,ViewModel通过change和get方法修改和获取data1
  2. (建议)data2为StateFlow,ViewModel通过change方法修改data2,并且监听data2响应变更& 获取最新数据
class Data1Repository {

    private var data1: Int? = null

    fun getData1(): Int? {
        return data1
    }

    suspend fun changeData1(): Boolean {
        if (data1 == null) {
            data1 = 1
        }
        delay(1000)
        return data1?.let  {
data1 = it + 1
            true
        } ?: false
    }

    fun clearCache(): Boolean {
        data1 = null
        return true
    }

}
class Data2Repository {

    private var innerData2: MutableStateFlow<Int?> = MutableStateFlow(null)
    val data2: StateFlow<Int?> = innerData2.asStateFlow()

    suspend fun changeData(): Boolean {
        if (data2.value == null) {
            innerData2.value = 1
            return true
        }
        delay(1000)
        return innerData2.value?.let {
            innerData2.value = it + 1
            true
        } ?: false
    }

    fun clearCache(): Boolean {
        innerData2.value = null
        return true
    }

}