[英]Android Compose with single event
Right now I have an Event class in the ViewModel that is exposed as a Flow this way:现在我在 ViewModel 中有一个事件 class 以这种方式公开为一个流:
abstract class BaseViewModel() : ViewModel() {
...
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val eventsFlow = eventChannel.receiveAsFlow()
fun sendEvent(event: Event) {
viewModelScope.launch {
eventChannel.send(event)
}
}
sealed class Event {
data class NavigateTo(val destination: Int): Event()
data class ShowSnackbarResource(val resource: Int): Event()
data class ShowSnackbarString(val message: String): Event()
}
}
And this is the composable managing it:这是管理它的可组合项:
@Composable
fun SearchScreen(
viewModel: SearchViewModel
) {
val events = viewModel.eventsFlow.collectAsState(initial = null)
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
) {
Column(
modifier = Modifier
.padding(all = 24.dp)
) {
SearchHeader(viewModel = viewModel)
SearchContent(
viewModel = viewModel,
modifier = Modifier.padding(top = 24.dp)
)
when(events.value) {
is NavigateTo -> TODO()
is ShowSnackbarResource -> {
val resources = LocalContext.current.resources
val message = (events.value as ShowSnackbarResource).resource
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
}
is ShowSnackbarString -> {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = (events.value as ShowSnackbarString).message
)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
I followed the pattern for single events with Flow from here .我从这里开始使用 Flow 遵循单个事件的模式。
My problem is, the event is handled correctly only the first time (SnackBar is shown correctly).我的问题是,事件仅在第一次被正确处理(SnackBar 显示正确)。 But after that, seems like the events are not collected anymore.但在那之后,似乎不再收集这些事件了。 At least until I leave the screen and come back.至少直到我离开屏幕再回来。 And in that case, all events are triggered consecutively.在这种情况下,所有事件都会连续触发。
Can't see what I'm doing wrong.看不出我做错了什么。 When debugged, events are sent to the Channel correctly, but seems like the state value is not updated in the composable.调试时,事件会正确发送到通道,但似乎 state 值未在可组合项中更新。
Rather than placing your logic right inside composable place them inside而不是将您的逻辑放在可组合中,而是将它们放在里面
// Runs only on initial composition
LaunchedEffect(key1 = Unit) {
viewModel.eventsFlow.collectLatest { value ->
when(value) {
// Handle events
}
}
}
And also rather than using it as state just collect value from flow in LaunchedEffect
block.而且,与其将其用作状态,还不如从LaunchedEffect
块中的流中收集值。 This is how I implemented single event in my application这就是我在应用程序中实现单个事件的方式
Here's a modified version of Jack's answer, as an extension function following new guidelines for safer flow collection .这是 Jack 答案的修改版本,作为遵循更安全流收集的新准则的扩展功能。
@Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
noinline action: suspend (T) -> Unit
) {
LaunchedEffect(key1 = Unit) {
lifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}
}
}
Usage:用法:
viewModel.flow.observeWithLifecycle { value ->
//Use the collected value
}
Here's a modified version of Soroush Lotfi answer making sure we also stop flow collection whenever the composable is not visible anymore: just replace the LaunchedEffect
with a DisposableEffect
这是 Soroush Lotfi 答案的修改版本,确保当组合不再可见时我们也会停止流收集:只需将LaunchedEffect
替换为DisposableEffect
@Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
noinline action: suspend (T) -> Unit
) {
DisposableEffect(Unit) {
val job = lifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}
onDispose {
job.cancel()
}
}
}
https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow/blob/master/core-ui/src/main/java/com/hoc/flowmvi/core_ui/rememberFlowWithLifecycle.kt https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow/blob/master/core-ui/src/main/java/com/hoc/flowmvi/core_ui/rememberFlowWithLifecycle。克拉
@Suppress("ComposableNaming")
@Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(T) -> Unit
) {
val flow = this
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(flow, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
flow.collect { currentCollector(it) }
}
}
}
}
class ViewModel {
val singleEvent: Flow<E> = eventChannel.receiveAsFlow()
}
@Composable fun Demo() {
val snackbarHostState by rememberUpdatedState(LocalSnackbarHostState.current)
val scope = rememberCoroutineScope()
viewModel.singleEvent.collectInLaunchedEffectWithLifecycle { event ->
when (event) {
SingleEvent.Refresh.Success -> {
scope.launch {
snackbarHostState.showSnackbar("Refresh successfully")
}
}
is SingleEvent.Refresh.Failure -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to refresh")
}
}
is SingleEvent.GetUsersError -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to get users")
}
}
is SingleEvent.RemoveUser.Success -> {
scope.launch {
snackbarHostState.showSnackbar("Removed '${event.user.fullName}'")
}
}
is SingleEvent.RemoveUser.Failure -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to remove '${event.user.fullName}'")
}
}
}
}
}
I'm not sure how you manage to compile the code, because I get an error on launch
.我不确定您是如何编译代码的,因为我在launch
遇到错误。
Calls to launch should happen inside a LaunchedEffect and not composition调用 launch 应该发生在 LaunchedEffect 而不是组合中
Usually you can use LaunchedEffect
which is already running in the coroutine scope, so you don't need coroutineScope.launch
.通常你可以使用已经在协程范围内运行的LaunchedEffect
,所以你不需要coroutineScope.launch
。 Read more about side effects in documentation .在文档中阅读更多关于副作用的信息。
A little kotlin advice: when using when
in types, you don't need to manually cast the variable to a type with as
. kotlin 的一点建议:在类型中使用when
,您不需要手动将变量强制转换为带有as
的类型。 In cases like this, you can declare val
along with your variable to prevent Smart cast to ... is impossible, because ... is a property that has open or custom getter
error:在这种情况下,您可以将val
与变量一起声明,以防止Smart cast to ... is impossible, because ... is a property that has open or custom getter
错误Smart cast to ... is impossible, because ... is a property that has open or custom getter
:
val resources = LocalContext.current.resources
val event = events.value // allow Smart cast
LaunchedEffect(event) {
when (event) {
is BaseViewModel.Event.NavigateTo -> TODO()
is BaseViewModel.Event.ShowSnackbarResource -> {
val message = event.resource
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
is BaseViewModel.Event.ShowSnackbarString -> {
snackbarHostState.showSnackbar(
message = event.message
)
}
}
}
This code has one problem: if you send the same event many times, it will not be shown because LaunchedEffect
will not be restarted: event
as key is the same.此代码有一个问题:如果多次发送相同的事件,则不会显示,因为不会重新启动LaunchedEffect
: event
与键相同。
You can solve this problem in different ways.您可以通过不同的方式解决此问题。 Here are some of them:这里是其中的一些:
Replace data class
with class
: now events will be compared by pointer, not by fields.替换data class
与class
:现在事件将通过指针,而不是由字段进行比较。
Add a random id to the data class, so that each new element is not equal to another:向数据类添加一个随机 id,以便每个新元素不等于另一个:
data class ShowSnackbarResource(val resource: Int, val id: UUID = UUID.randomUUID()) : Event()
Note that the coroutine LaunchedEffect
will be canceled when a new event occurs.注意协程LaunchedEffect
会在新事件发生时取消。 And since showSnackbar
is a suspend function, the previous snackbar will be hidden to display the new one.而且由于showSnackbar
是一个暂停功能,以前的小吃店将被隐藏以显示新的小吃店。 If you run showSnackbar
on coroutineScope.launch
(still doing it inside LaunchedEffect
), the new snackbar will wait until the previous snackbar disappears before it appears.如果您在coroutineScope.launch
上运行showSnackbar
(仍在LaunchedEffect
),新的小吃店将等到前一个小吃店消失后才会出现。
Another option, which seems cleaner to me, is to reset the state of the event because you have already reacted to it.另一种对我来说似乎更清晰的选择是重置事件的状态,因为您已经对其做出了反应。 You can add another event to do this:您可以添加另一个事件来执行此操作:
object Clean : Event()
And send it after the snackbar disappears:并在小吃店消失后发送:
LaunchedEffect(event) {
when (event) {
is BaseViewModel.Event.NavigateTo -> TODO()
is BaseViewModel.Event.ShowSnackbarResource -> {
val message = event.resource
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
is BaseViewModel.Event.ShowSnackbarString -> {
snackbarHostState.showSnackbar(
message = event.message
)
}
null, BaseViewModel.Event.Clean -> return@LaunchedEffect
}
viewModel.sendEvent(BaseViewModel.Event.Clean)
}
But in this case, if you send the same event while the previous one has not yet disappeared, it will be ignored as before.但是在这种情况下,如果您在前一个尚未消失的情况下发送相同的事件,它将像以前一样被忽略。 This can be perfectly normal, depending on the structure of your application, but to prevent this you can show it on coroutineScope
as before.这可能是完全正常的,具体取决于您的应用程序的结构,但为了防止这种情况,您可以像以前一样在coroutineScope
上显示它。
Also, check out the more general solution implemented in the JetNews compose app example.此外,请查看JetNews compose 应用程序示例中实现的更通用的解决方案。 I suggest you download the project and inspect it starting from location where the snackbar is displayed.我建议您下载该项目并从显示小吃店的位置开始检查它。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.