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:渲染UI(展示界面和数据的状态)。
  • 意图 Intent:承载操作(点击、输入、获取列表数据等)。
  • 状态 State:反应数据当前值。

1.1 唯一可信数据源

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

1.2 状态不可变

状态不应该被直接修改,而是重新赋值为一个包含新数据的副本。对象变更才能触发观察,属性变更才会引发重组。

1.3 数据单向流动

DataBinding  数据模型和视图一方发生变化就会同步到另一方,数据的流动是双向的,这样不便于追踪测试。MVI强调数据的源头只有一个,目的地也只有一个,永远在一个环形结构中单向流动,便于追踪测试。

1.4 事件驱动

MVVM 没有约束 UI 和 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 确保不可变,提供默认值方便初始化和减少 null 判断。根据多个状态派生出来的状态,定义在 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 定义用户事件 UiAction

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

3.3 定义界面事件 UiEvent

如果只有 Toast 和 Dialog 这种简单的需求可省去,在 ViewModel 中直接用 ApplicationContext 弹 Toast,把 isShowDialog 整合进 UiState 中,ViewModel 直接改状态弹窗并通过 UiAction 响应 UI 中的操作。

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

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

  • Compose 向外部(ViewModel)发送用户事件属于副作用,涉及并发安全问题(多核心优化重组,可能执行在非UI线程),因此考虑协程间通信使用 Channel 来实现。
    • 事件只能被消费一次,不能回放造成粘性事件。(排除 StateFlow)
    • 事件必须执行(消费代码若是执行在了生产后,也不能丢弃值)。(排除SharedFlow)
  • ViewModel 向 UI 发送界面事件,像弹 Toast/Dialog 没有订阅者就应该丢弃事件,因此使用事件流 SharedFlow 来实现。
  • 页面数据的初始化在 ViewModel 的 init{} 代码块中调用,因为在 Compose 中用 LaunchedEffects() 会受 Activity 重建影响而再次触发。(视情况而定,如果界面是 Pager 中的子页,Pager会缓存页面所以 LaunchedEffects() 不会跟随Activity重建而再次触发。这样反而不能通过判断 index 在子页显示时才初始化数据)
  • 状态可用 MutableState 或 StateFlow 实现。MutableState 在普通使用下更简洁但仅限Compose,StateFlow 在并发修改时 update() 方法原子更新更简洁。
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 uiAction = Channel<DemoUiAction>()

    //ViewModel初始化时:初始化数据,启动用户事件处理
    init {
        viewModelScope.launch {
            initData()
            handleAction()
        }
    }

    //处理用户事件
    private suspend fun handleAction() {
        uiAction.consumeAsFlow().collect { action ->
            when(action) {
                is DemoUiAction.OnClick -> _uiEvent.emit(DemoUiEvent.ShowToast("点击了按钮"))
            }
        }
    }

    //暴露给UI发送用户事件(比直接在UI中获取Channel发送方便,包装成普通函数更方便外部调用)
    fun dispatchEvent(action: DemoUiAction) {
        viewModelScope.launch {
            uiAction.send(action)
        }
    }

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

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

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

  • 在屏幕级组合项获取ViewModel。
  • 当 Activity 处于可交互期间(resume)才需要处理一次性事件。
@Composable
fun MainScreen(
    viewModel: DemoViewModel = viewModel()    //普通获取VM
//  viewModel: DemoViewModel = viewModel(factory = DemoViewModelFactory(DemoRepository(DemoDataSource())))    //带参获取VM
) {
    //只在Activity处于可交互时处理事件
    LifecycleResumeEffect(Unit) {
        lifecycleScope.launch {
            viewModel.uiEvent.collect { event ->
                when (event) {
                    is DemoUiEvent.ShowDialog -> {}
                }
            }
        }
        //必须调用,可清理资源
        onPauseOrDispose {}
    }
    //读取状态并处理
    ScreenContent(
        data = viewModel.uiState.success,
        onClick = { viewModel.dispatchAction(DemoAction.OnClick("url")) }
    )
}

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

四、封装 BaseViewModel

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

  • 不要定义抽象方法 initData() 让子类实现图省事,如果子类实现在其中调用了 Repository 的方法,由于会先调用父类中的 init{} 代码块,此时子类的 Repository 还未初始化完成,initData() 会空指针异常。
abstract class BaseVM<State: UiState, Action: UiAction, Event: UiEvent> : ViewModel() {

    var state by mutableStateOf(initUiState())
        protected set
    private val actionChannel = Channel<Action>()
    protected val _eventSharedFlow = MutableSharedFlow<Event>()    //protected暴露给子VM发射数据
    val eventSharedFlow = _eventSharedFlow.asSharedFlow()    //暴露给UI只能收集

    //属性和init代码块按顺序执行,这里放在状态之后
    init {
        handleAction()
    }

    private suspend fun handleAction() {
        viewModelScope.launch {
            actionChannel.consumeAsFlow().collect {
                onAction(it)
            }
        }
    }

    fun dispatchAction(action: Action) {
        viewModelScope.launch {
            actionChannel.send(action)
        }
    }

    protected abstract fun initUiState(): State
    protected abstract suspend fun onAction(action: Action)

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

}

interface UiState
interface UiAction
interface UiEvent

子ViewModel中使用:

class DemoVM : BaseVM<DemoState, DemoAction, DemoEvent>() {

    override fun initUiState() = DemoState()

    override suspend fun onAction(action: DemoAction) = when (action) {
        DemoAction.InitData -> initData()
    }

    override suspend fun initData() {
        state = state.copy(name = "")
        _eventSharedFlow.emit(DemoEvent.ShowToast(""))
    }

}
data class DemoState(
    val name: String = ""
) : UiState

sealed interface DemoAction : UiAction {
    object InitData: DemoAction
}

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

UI中使用:

@Composable
fun DemoScreen( vm: DemoVM = viewModel()) {
    LaunchedEffect(Unit) {
        vm.eventSharedFlow.collect { event ->
            when (event) {
                is DemoEvent.ShowToast -> TODO()
            }
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值