Android 架构 - MVI

文章介绍了MVVM架构中的MVI模式,强调了数据的单向流动和唯一可信数据源的概念,以及在Compose环境下如何处理ViewModel和Activity的生命周期。此外,还讨论了事件驱动的Intent机制和状态管理,提供了搭建项目的基本步骤,包括定义State、Event、处理事件和状态更新的ViewModel方法,以及UI层如何响应和发送事件。

一、概念

概念基于单向数据流,数据永远在一个环形结构中单向流动,便于追踪测试。用户事件→业务罗技→更新状态→界面重组。
通信

View→ViewModel:将用户操作以Intent形式通知给ViewModel,监听ViewModel中State的变化会自动更新到UI。

ViewModel→Model:对Intent类型分类判断做对应的逻辑处理,调用Model获取数据。

ViewModel→View:会获取的数据更新到State,State的变化会自动更新View。

  • 模型 Model:处理数据的逻辑和存取。
  • 视图 View:展示界面和数据的状态。
  • 意图 Intent:代表用户的操作(点击、输入、获取列表数据等)。
  • 状态 State:反应数据当前值。

1.1 唯一可信数据源

为了解决 MVVM 中 UI 订阅多个分散的状态(ViewModel中的LiveData/Flow)导致各种数据并行更新或数据相互依赖时,无法清晰掌握整个页面的状态。MVI使用 UiState 将所有状态整合在一处(通过 data class 实现),UI刷新只依赖这一个数据源。

1.2 数据单向流动

DataBinding  数据模型和视图一方发生变化就会同步到另一方,数据的流动是双向的,这样不便于追踪测试。MVI强调数据的源头只有一个,目的地也只有一个。数据从 Data Layer 流向 UI Layer 的 ViewModel 中,ViewModel 将数据转换成 State 给 UI Element 更新。

1.3 事件驱动

MVVM 没有约束 View 和 ViewModel 的交互方式,MVI将用户操作统一封装到 Intent 实现了屏蔽(通过密封接口实现),View只能发送已被定义好的事件让 ViewModel 统一处理,通过对事件类型的识别做出相应的业务处理并更新状态,采用 Channel 保证并发安全。

二、单 Activity 架构的问题

Compose 通过 AndroidComposeView 来与 Activity 交互,使用单 Activity 页面跳转都能在 Compose 内部完成。Navigation不仅支持 View 的 单Activity+多Fragment 架构,也支持 Compose 的 单Activity+多Composable 架构。

2.1 ViewModel的销毁

       Compose 中的 viewModel() 函数可以从任何组合项中获取 ViewModel,考虑到函数的生命周期和作用域,应在屏幕级组合函数中获取 ViewModel 实例,也就是被 Activity、Navigation目的地调用的根级组合项。不要直接将 ViewModel 实例传递给子组合项用,而是传递子组合项所需要的数据或函数(即状态提升)。

  • 如果根组合项托管在 Activity 中,不同组合项中调用 viewModel() 获取相同类型的 ViewModel 将会是同一个实例。因为是 单Activity 架构,绑定的作用域是同一个ViewModelStoreOwner,也因此 ViewModel 的生命周期不会随组合项的销毁而回收。
  • 如果根组合项托管在 Navigstion 目的地中,不同组合项中调用 viewModel() 获取相同类型的 ViewModel 是不同的实例。因为作用域被限定在了目的地,ViewModel的生命周期会跟随目的地从返回站中弹出而清除。

2.2 处理生命周期

2.2.1 可组合项的生命周期处理

详见:附带效应(副作用)

对于组合函数的生命周期:onActive 首次挂载到组件树、onCommit 重组刷新、onDispose 从组件树上移除,可以通过附带效应来监听。

LaunchedEffect第一次调用Compose函数时执行(首次进入页面)。
DisposableEffect需要重写 onDispose() 函数当页面退出时调用(退出页面时释放资源)。
SideEffectCompose函数每次执行都会调用该方法(每次重组时)。

2.2.2 Activity 的生命周期获取

详见:使用Lifecycle

三、搭建项目

3.1 定义界面状态 UiState

  • 命名采用:页面名称 + UiState。
  • 一般同 ViewModel 写在同一个 .kt 文件中,也可以单独写在一个 .kt 文件中。
  • 采用 data class 因为自带 copy() 功能,非常方便更新部分属性。界面刷新用到的状态全都定义成属性,集中在一起实现唯一可信数据源。属性使用 val 确保不可变。根据多个状态派生出来的状态,定义在 data class 的类体中。
  • UI 所需要的某些状态若是相互独立,不要定义在同一个数据类中,刷新频率高的那个会造成低的频繁更新。(属性内容无变化会跳过重组也还好,分开更方便阅读)
data class DemoUiState(
    val isLoading: Boolean = false,
    val success: List<DemoBean> = emptyList(),
    val isLogin: Boolean = false,    //是否登录
    val isPremium: Boolean= false    //是不是会员
) {
    val canDownload: Boolean = isLogin && isPremium    //派生状态
}

3.2 定义用户事件 UiEffect

  • 命名采用:页面名称 + UiEffect。
  • 一般同 ViewModel 写在同一个 .kt 文件中,也可以单独写在一个 .kt 文件中。
  • 采用 sealed interface 除了保证类型受控优化 when 判断,子类不带参数就定义成 data object 类型方便复用(用object是因为创建的实例无状态区别,data object 是它的优化版,不会打印必要信息)。将可能的用户行为全部定义成子类。
sealed interface DemoUiEffect {
    //按钮点击
    data class OnClick(val url: String) : DemoUiEffect
    //初始化数据
    object InitData: DemoUiEffect
}

3.3 定义界面事件 UiEvent

  • 命名采用:页面名称 + UiEvent。
  • 一般同 ViewModel 写在同一个 .kt 文件中,也可以单独写在一个 .kt 文件中。
sealed interface DemoUiEvent {
    data class ShowToast(val str: String) : DemoUiEvent
    data class ShowDialog(val str: String) : DemoUiEvent
}

3.4 处理用户事件+更新状态+发送界面事件(ViewModel)

Compose 向外部(ViewModel)发送用户事件属于副作用,涉及并发安全问题(多核心优化重组,可能执行在非UI线程),因此考虑协程间通信使用 Channel 来实现。

  • 事件只能被消费一次,不能回放造成粘性事件。(排除 StateFlow)
  • 事件必须执行(消费代码若是执行在了生产后,也不能丢弃值)。(排除SharedFlow)

ViewModel 向 UI 发送界面事件,像弹 Toast/Dialog 没有订阅者就应该丢弃事件,因此使用事件流 SharedFlow 来实现。

class DemoViewModel(
    private val repository: DemoRepository
) : ViewModel() {
    //暴露给UI订阅的界面状态
    var uiState by mutableStateOf(DemoUiState())
        private set

    //暴露给UI订阅的界面事件
    private val _uiEvent = MutableSharedFlow<DemoUiEvent>()
    val uiEvent = _uiEvent.asSharedFlow()

    //定义用来通信的Channel
    private val uiEffect = Channel<DemoUiEffect>()

    //初始化时就启动用户事件处理
    init { handleEvent() }

    //处理用户事件
    private fun handleEvent() {
        viewModelScope.launch {
            uiEffect.consumeAsFlow().collect { event ->
                when(event) {
                    is DemoUiEffect.OnClick -> _uiEvent.emit(DemoUiEvent.ShowToast("点击了按钮"))
                    DemoUiEffect.InitData -> initData()
                }
            }
        }
    }

    //暴露给UI发送用户事件(比直接在UI中获取Channel发送方便)
    fun dispatchEvent(event: DemoUiEffect) {
        viewModelScope.launch {
            uiEffect.send(event)
        }
    }

    //(在业务代码里)更新状态和发送界面事件
    private suspend fun initData() {
        //状态设为加载中
        uiState = uiState.copy(isLoading = true)
        runCatching {
            repository.getData()
        }.onSuccess { data ->
            //赋值成功结果,取消加载中
            uiState = uiState.copy(success = it, isLoading = false)
        }.onFailure {
            //取消加载中,并弹Toast
            uiState = uiState.copy(isLoading = false)
            _uiEvent.emit(DemoUiEvent.ShowToast(it.message.toString()))
        }
    }

    override fun onCleared() {
        super.onCleared()
        uiEffect.close()  //释放资源
    }
}

3.5 响应状态+处理界面事件+发送用户事件(UI)

  • 在屏幕级组合项获取ViewModel。
  • 当 Activity 处于可交互期间(resume)才需要处理一次性事件。
@Composable
fun MainScreen(
    viewModel: DemoViewModel = viewModel()    //普通获取VM
//  viewModel: DemoViewModel = viewModel(factory = DemoViewModelFactory(DemoRepository(DemoDataSource())))    //带参获取VM
) {
    //发送用户事件(这里的数据初始化只需要执行一次,避免每次重组都执行)
    LaunchedEffect(Unit) {
        viewModel.dispatchEvent(DemoUiEffect.InitData)
    }
    //只在Activity处于可交互时处理事件
    LifecycleResumeEffect(Unit) {
        lifecycleScope.launch {
            viewModel.uiEvent.collect { event ->
                when (event) {
                    is DemoUiEvent.ShowDialog -> {}
                    is DemoUiEvent.ShowToast -> Toast.makeText(APP.context, event.str, Toast.LENGTH_SHORT).show()
                }
            }
        }
        //必须调用,可清理资源
        onPauseOrDispose {}
    }
    //读取状态并处理
    Content(
        data = viewModel.uiState.success,
        onClick = { viewModel.dispatchEvent(DemoEvent.OnClick("url")) }
    )
}

@Composable
private fun Content(
    data: List<DemoBean>,
    onClick: (String) -> Unit
) {
    //将数据设置给子组件
}

四、封装 BaseViewModel

解决 ViewModel 中模板代码过多问题。

abstract class BaseVM<UiState, UiEffect, UiEvent> : ViewModel() {

    var uiState by mutableStateOf(initUiState())
        protected set
    protected val uiEffect = Channel<UiEffect>()
    protected val _uiEvent = MutableSharedFlow<UiEvent>()
    val uiEvent = _uiEvent.asSharedFlow()

    init {
        handleUiEffect()
    }

    private fun handleUiEffect() {
        viewModelScope.launch {
            uiEffect.consumeAsFlow().collect { effect ->
                onUiEffect(effect)
            }
        }
    }

    fun dispatchUiEffect(effect: UiEffect) {
        viewModelScope.launch { 
            uiEffect.send(effect)
        }
    }

    protected abstract fun initUiState(): UiState
    protected abstract suspend fun onUiEffect(effect: UiEffect)

    override fun onCleared() {
        super.onCleared()
        uiEffect.close()
    }
    
}

调用起来简洁很多:

class DemoVM : BaseVM<DemoUiState, DemoUiEffect, DemoUiEvent>() {

    override fun initUiState() = DemoUiState()

    override suspend fun onUiEffect(effect: DemoUiEffect) = when (effect) {
        DemoUiEffect.InitData -> initData()
    }

    suspend fun initData() {
        uiState = uiState.copy(name = "")
        _uiEvent.emit(DemoUiEvent.ShowToast(""))
    }

}
data class DemoUiState(
    val name: String = ""
)

sealed interface DemoUiEffect {
    object InitData: DemoUiEffect
}

sealed interface DemoUiEvent {
    data class ShowToast(val str: String) : DemoUiEvent
}

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值