Jetpack Compose怎样在ViewModel中触发Compose中的方法

前言

ViewModel一般都是用于数据的处理,Compose中渲染数据,处理消息提示或路由跳转。

但是有时我们想在ViewModel中触发某个逻辑后触发页面消息,或者路由跳转,该怎样实现呢?

  • 一种是在vm中创建状态,页面中监听状态,只不过这样的写法不太优雅。
  • 另一种是vm中发送事件,页面中监听事件。

下面就以全局消息提示来演示用法:

全局消息提示

在 Jetpack Compose 中,通常不建议直接在 ViewModel 中处理 UI 相关操作(如弹出消息提示),因为这违反了单一职责原则和关注点分离。

ViewModel 应该只负责管理数据和业务逻辑,而 UI 相关的操作应该由 Composable 组件处理。

正确的做法是:

  1. 在 ViewModel 中定义一个状态来表示是否需要显示消息
  2. 在 Composable 中观察这个状态
  3. 当状态变化时,在 Composable 中显示消息提示

以下是一个实现示例:

事件类

首先,创建一个密封类来表示 UI 事件:

1
2
3
sealed class UiEvent {
data class ShowSnackbar(val message: String) : UiEvent()
}

VM发送事件

然后,在 ViewModel 中使用 ChannelStateFlow 来发送事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

class BaseViewModel : ViewModel() {
// 用于发送 UI 事件
private val _uiEvents = Channel<UiEvent>()
val uiEvents = _uiEvents.receiveAsFlow()

fun showSnackbarAction(msg: String) {
viewModelScope.launch {
_uiEvents.send(UiEvent.ShowSnackbar(msg))
}
}
}

页面事件接收

在 Composable 中观察事件并显示消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.xhkjedu.zxs_android.model.UiEvent
import com.xhkjedu.zxs_android.vm.BaseViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

@Composable
fun ZMsgReceiveComp(
vm: BaseViewModel
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()

// 观察 UI 事件
LaunchedEffect(Unit) {
vm.uiEvents.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> {
snackbarHostState.currentSnackbarData?.dismiss()
scope.launch {
snackbarHostState.showSnackbar(
message = event.message,
duration = SnackbarDuration.Indefinite
)
}
scope.launch {
// 2. 自定义时长:延迟 1600 毫秒后关闭
delay(1600) // 此处可替换为任意毫秒值(如 5000 表示5秒)

// 3. 关闭当前显示的 Snackbar(避免内存泄漏)
snackbarHostState.currentSnackbarData?.dismiss()
}
}

else -> {}
}
}
}



Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val mShape = RoundedCornerShape(8.dp)
// 用于显示 Snackbar 的宿主
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.Center)
) { snackbarData ->
Popup(
alignment = Alignment.Center,
onDismissRequest = {},
properties = PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = false
)
) {
Box(
modifier = Modifier
.padding(30.dp)
.wrapContentWidth()
.height(46.dp)
.background(color = Color(0x66000000), shape = mShape)
.padding(start = 20.dp, end = 20.dp)
) {
Text(
text = snackbarData.visuals.message,
fontSize = 14.sp,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}

}
}
}

注意

其中使用Popup,是为了防止消息被其他的Popup遮挡。

组件引用

直接使用

1
MsgReceiveComp(loginViewModel)

这种方式遵循了 Jetpack 的最佳实践,保持了 ViewModel 和 UI 层的分离,同时能够从 ViewModel 触发消息提示。

如果你需要使用 Toast 而不是 Snackbar,方法类似,只需在 Composable 中收到事件时调用 Toast.makeText() 即可。

全局使用

上面说的是组件内用,但是每个组件都这样整,优点繁琐,并且位置也不一定是整个屏幕,所以这里配置全局使用。

在App的组件中添加接收组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.xhkjedu.zxs_android.common.CommonData
import com.xhkjedu.zxs_android.componts.MsgReceiveComp
import com.xhkjedu.zxs_android.route.AppNavigation
import com.xhkjedu.zxs_android.ui.theme.AppAndroidTheme
import com.xhkjedu.zxs_android.vm.AppViewModel

@Composable
fun MyApp() {
val mViewModel: AppViewModel = viewModel()
CommonData.appViewModel = mViewModel
AppAndroidTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
AppNavigation()
MsgReceiveComp(mViewModel)
}
}
}
}

全局变量中添加

1
2
3
object CommonData {
var appViewModel: AppViewModel? = null
}

使用的时候这样调用

1
CommonData.appViewModel?.showSnackbarAction("未选择教材")