Compose 中的 Side-effects
Compose App 使用各种 Effect API 来获得更可预测的行为和适当的生命周期管理。Side-effect 是在 composable 函数 scope 之外发生的应用程序状态变化。由于 composables 的生命周期以及诸如不可预测的重组以不同顺序执行 composables 的重组或者可能被丢弃的重组等特性在理想情况下composables 中应该没有 Side-effect 的代码。但有时 Side-effect 是必要的例如在特定状态条件下触发一次性事件如显示 Snackbar 或导航到另一个屏幕。这些操作应该从知晓 composable 生命周期的受控环境中调用。状态和效果用例正如 Thinking in Compose 文档中所述composables 应该是没有 Side-effect 的。当你需要更改 App 的状态时如 Managing state documentation 文档中所描述的你应该使用 Effect API以便这些 Side-effect 以可预测的方式执行。请确保你做的工作与 UI 相关并且不会破坏 Managing state documentation 文档中所阐述的单向数据流。LaunchedEffect在 composable 的作用域中执行挂起函数要在 composable 的生命周期内执行工作并能够调用挂起函数请使用 LaunchedEffect 。当 LaunchedEffect 进入 Composition 时它会启动一个协程该协程带有作为参数传递的代码块。如果 LaunchedEffect 离开该 Composition协程将被取消。如果 LaunchedEffect 使用不同的键重组请参见下面的 Restarting Effects 则现有协程将被取消新的挂起函数将在新协程中启动。例如下面是一个动画它通过可配置的延迟来脉冲式改变 alpha 值// Allow the pulse rate to be configured, so it can be sped up if the user is running// out of timevarpulseRateMsbyremember{mutableLongStateOf(3000L)}valalpharemember{Animatable(1f)}LaunchedEffect(pulseRateMs){// Restart the effect when the pulse rate changeswhile(isActive){delay(pulseRateMs)// Pulse the alpha every pulseRateMs to alert the useralpha.animateTo(0f)alpha.animateTo(1f)}}在上面的代码中动画使用挂起函数 delay 来等待一个给定的时间。然后它会使用 animateTo 先将 alpha 变成零再使用 animateTo 变回来这一过程会在 composable 的生命周期内重复进行。rememberCoroutineScope获取一个具有 composition 感知能力的作用域以便在 composable 外启动一个协程LaunchedEffect 是一个 Composable 函数它只能在其他 Composable 函数内部使用。如果要在 某个 composable 外启动协程但又希望该协程在离开这个 composition 时能自动取消可以使用 rememberCoroutineScope。此外当你需要手动控制一个或多个协程的生命周期时例如在用户事件触发时取消动画也可以使用 rememberCoroutineScope。rememberCoroutineScope 是一个 Composable 函数它会返回一个与调用它的 Composition 的位置绑定的 CoroutineScope。当该调用离开这个 Composition 时作用域会被取消。结合前面的示例你可以使用以下代码在用户点击按钮时显示 SnackbarComposablefunMoviesScreen(snackbarHostState:SnackbarHostState){// Creates a CoroutineScope bound to the MoviesScreens lifecyclevalscoperememberCoroutineScope()Scaffold(snackbarHost{SnackbarHost(hostStatesnackbarHostState)}){contentPadding-Column(Modifier.padding(contentPadding)){Button(onClick{// Create a new coroutine in the event handler to show a snackbarscope.launch{snackbarHostState.showSnackbar(Something happened!)}}){Text(Press me)}}}}rememberUpdatedStaterememberUpdatedState 几乎总是跟 LaunchedEffect 函数一起使用的rememberUpdatedState 函数主要用于解决在使用 LaunchedEffect 函数时可能存在的回调丢失的问题。什么情况下会导致回调丢失呢我们来看下面这个例子ComposablefunDemoScreen(){varcountbyremember{mutableIntStateOf(0)}// 我们创建一个“身份ID”它是在重组那一刻确定的valactionId我是第$count次重组时的回调valtimeoutAction{println(回调执行$actionId)}Column(modifierModifier.fillMaxSize().padding(20.dp),horizontalAlignmentAlignment.CenterHorizontally){Text(当前计数:$count,styleMaterialTheme.typography.headlineMedium)Text(当前回调身份:$actionId,colorColor.Gray)// 显示当前的身份Spacer(Modifier.height(10.dp))Button(onClick{count}){Text(点击增加计数 (触发重组))}Spacer(Modifier.height(40.dp))CountdownTimer(onTimeouttimeoutAction)}}ComposablefunCountdownTimer(onTimeout:()-Unit){// 这里的 LaunchedEffect 只会在首次组合时启动一次LaunchedEffect(Unit){println(协程启动开始倒计时 5 秒...)delay(5000)// Error这里捕获的是“首次组合”时的 onTimeout 引用// 即使你在外面改了 onTimeout 的逻辑这里依然执行旧的println(时间到执行回调...)onTimeout()}}代码启动后立即点击按钮 5 次此时界面文字变成了“当前回调身份我是第 5 次重组时的回调”等待 5 秒倒计时结束Log 打印的却是“回调执行我是第 0 次重组时的回调”。原因是因为这里 CountdownTimer 是一个 Compose 函数当参数 onTimeout 改变时它会重组。但是因为 LaunchedEffect 的 Key 是 Unit只会在首次组合时启动一次。此时新的 onTimeout 对象无法得到回调因为 LaunchedEffect 函数中引用的还是最开始的 onTimeout 对象。就好比倒计时开始的一瞬间count0 时协程拍了一张照片。5秒后即使 count 变成了 5协程拿出来的依然是那张旧照片。怎么解决这个问题呢使用 rememberUpdatedState 函数就可以了ComposablefunCountdownTimer(onTimeout:()-Unit){// 修复创建一个状态容器始终持有最新的 onTimeoutvalcurrentOnTimeoutbyrememberUpdatedState(onTimeout)// 这里的 LaunchedEffect 只会在首次组合时启动一次LaunchedEffect(Unit){println(协程启动开始倒计时 5 秒...)delay(5000)// Error这里捕获的是“首次组合”时的 onTimeout 引用// 即使你在外面改了 onTimeout 的逻辑这里依然执行旧的println(时间到执行回调...)currentOnTimeout()}}这里调用 rememberUpdatedState 函数并将 onTimeout 传递给它从而得到了一个新的currentOnTimeout 参数。这个 currentOnTimeout 可以保证永远指向的是最新的 onTimeout 参数。然后你在 LaunchedEffect 函数中调用这个 currentOnTimeout 对象就可以保证最新的回调不会丢失了。rememberUpdatedState 就像是给协程装了一个实时监控器。虽然协程是 5 秒前启动的但在执行的那一刻它会去监控器里查看最新的画面而不是看旧照片。DisposableEffect需要清理的 EffectDisposableEffect 应该可以算是和 LaunchedEffect 对称的一个 Side-effects 函数它的作用是对不再使用的资源进行安全合理地回收。DisposableEffect函数允许接收一个或多个参数在参数不变的情况下DisposableEffect 函数中的内容只会执行一次。从这点特性上来说DisposableEffect 和 LaunchedEffect 函数是非常类似的。不同点在于DisposableEffect 函数不提供协程作用域同时DisposableEffect 必须在其代码块的最后包含一个 onDispose 代码块否则 IDE 会显示编译时错误。每当 DisposableEffect 函数的任意一个参数发生变化时onDispose 函数中就会执行我们可以在这里进行资源释放。然后 DisposableEffect 函数会使用新的参数内容再次重复上述逻辑。下面我们来看一个具体的例子吧DisposableEffect 函数用的最多的场景就在是 Composable 函数中进行生命周期监听了代码如下所示ComposablefunHomeScreen(lifecycleOwner:LifecycleOwnerLocalLifecycleOwner.current,onStart:()-Unit,// Send the started analytics eventonStop:()-Unit// Send the stopped analytics event){// Safely update the current lambdas when a new one is providedvalcurrentOnStartbyrememberUpdatedState(onStart)valcurrentOnStopbyrememberUpdatedState(onStop)// If lifecycleOwner changes, dispose and reset the effectDisposableEffect(lifecycleOwner){// Create an observer that triggers our remembered callbacks// for sending analytics eventsvalobserverLifecycleEventObserver{_,event-if(eventLifecycle.Event.ON_START){currentOnStart()}elseif(eventLifecycle.Event.ON_STOP){currentOnStop()}}// Add the observer to the lifecyclelifecycleOwner.lifecycle.addObserver(observer)// When the effect leaves the Composition, remove the observeronDispose{lifecycleOwner.lifecycle.removeObserver(observer)}}/* Home screen content */}很明显我们不能直接在 Composable 函数中去添加 Observer不然每次只要一重组就会添加一个新的 Observer那监听器就要爆炸了。虽然 LaunchedEffect 函数可以解决这个问题但是 LaunchedEffect 函数并不适用监听器的场景因为它只负责添加却不能删除这样资源就无法回收了。而 DisposableEffect 函数就是为了这种场景而设计的我们可以看到上述代码中lifecycleOwner 作为参数传递给了 DisposableEffect 函数并给 Lifecycle 组件添加了一个新的 Observer。这样整个 Activity 的生命周期内我们都可以监听到诸如 onStart、onStop 这些生命周期回调了。同时如果我们离开了当前 Activity那么 lifecycleOwner 就会发生变化此时会触发 onDispose 函数的执行。那么在这里我们对刚才添加的Observer进行了移除这样也就完成了资源释放。SideEffect将 Compose 状态发布到非 Compose 代码中要与不受 Compose 管理的对象共享 Compose 状态请使用 SideEffect composable。使用 SideEffect 可以确保该 effect 在每次成功重组后执行。另一方面在确保成功重组之前执行 effect 是不正确的直接在 composable 中添加 effect 代码就会出现这种情况。例如你的分析库可能允许你通过将自定义元数据本例中为 “user properties”附加到所有后续分析事件来对用户群体进行细分。要将当前用户的用户类型告知你的分析库请使用 SideEffect 来更新其值。ComposablefunrememberFirebaseAnalytics(user:User):FirebaseAnalytics{valanalytics:FirebaseAnalyticsremember{FirebaseAnalytics()}// On every successful composition, update FirebaseAnalytics with// the userType from the current User, ensuring that future analytics// events have this metadata attachedSideEffect{analytics.setUserProperty(userType,user.userType)}returnanalytics}produceState将非 Compose State 转换成 Compose State我们来编写一个页面加载效果分别有加载中、加载成功和加载失败这三种页面状态。代码如下所示ComposablefunHomePage(success:Boolean){varloadingStatus0LaunchedEffect(Unit){loadingStatusStatus.LOADINGdelay(1000)loadingStatusif(success){Status.SUCCESS}else{Status.ERROR}}when(loadingStatus){Status.LOADING-{LoadingContent()}Status.SUCCESS-{HomePageContent()}Status.ERROR-{ErrorContent()}}}这段代码想表达的意图是调用 LaunchedEffect 函数创造一个协程作用域在这里执行加载逻辑。一开始将状态设置为加载中然后调用 delay() 函数延迟一下用于模拟加载的效果之后再根据加载的结果将状态设置为成功或失败。最后再根据当前状态的值来决定是显示 HomePageContent()、LoadingContent() 还是 ErrorContent() 函数中的内容。不用说这段代码肯定是无法正常为工作的因为在 composable 函数中修改了一个普通变量 loadingStatusCompose 不知道这个变量变了所以不会重绘界面。必须把它修改成 State 对象例如 mutableStateOf 包装的变量Compose 监听到变化才会刷新 UI。修改后代码如下ComposablefunHomePage(success:Boolean){varloadingStatusbyremember{mutableIntStateOf(0)}LaunchedEffect(Unit){loadingStatusStatus.LOADINGdelay(1000)loadingStatusif(success){Status.SUCCESS}else{Status.ERROR}}when(loadingStatus){Status.LOADING-{LoadingContent()}Status.SUCCESS-{HomePageContent()}Status.ERROR-{ErrorContent()}}}这里通过 mutableIntStateOf 函数创建了一个 Compose 的 State对象之后所有的状态变更都是针对这个 State 对象进行操作的Compose 就能监听到 State 的变化进行重组了。虽然上述代码确实可以解决问题但是不代表我们不能把代码写得更好。而 produceState 函数就是用来优化这部分场景的。刚才已经说了produceState 函数用于将一个非 Compose 的 State 转换成 Compose 的 State。除此之外produceState 函数还将提供一个协程作用域。因此它完全可以替代上述代码中的 mutableIntStateOf 和 LaunchedEffect。下面我们来看看使用produceState函数优化过后的代码吧ComposablefunHomePage(success:Boolean){valloadingStatusbyproduceState(initialValueStatus.LOADING){delay(1000)valueif(success){Status.SUCCESS}else{Status.ERROR}}when(loadingStatus){Status.LOADING-{LoadingContent()}Status.SUCCESS-{HomePageContent()}Status.ERROR-{ErrorContent()}}}相比之下使用 produceState 函数的版本要更加清爽一些。不用像刚才那样还需要在调用mutableIntStateOf 数时传入一个无意义的状态 0 用作初始值。produceState 函数允许通过initialValue 参数来设置初始值这样每个状态都是有意义的。同时使用 produceState 函数得到的转换后的 State 对象是可以声明成 val 的这样可以避免一些加载状态被误改的情况从而让代码变得更加安全。另外你还可以将 produceState 函数里的这段逻辑单独抽离成一个函数并放在任何你想放的位置这样可以更好地将业务代码和 UI 代码分离开如下所示ComposablefunstartLoading(success:Boolean):StateInt{returnproduceState(initialValueStatus.LOADING){delay(1000)valueif(success){Status.SUCCESS}else{Status.ERROR}}}ComposablefunHomePage(success:Boolean){valloadingStatusbystartLoading(success)when(loadingStatus){Status.LOADING-{LoadingContent()}Status.SUCCESS-{HomePageContent()}Status.ERROR-{ErrorContent()}}}当 produceState 进入组合Composition时生产者producer会启动当它离开组合时生产者会被取消。返回的 State 会 conflate设置相同的值不会触发重组。尽管 produceState 会创建一个协程但它也可用于观察非挂起的数据来源。要取消对该来源的订阅请使用 awaitDispose 函数。derivedStateOf将一个或多个 state object 转换为另一个 state在 Compose 中每当被观察的 state object 或 composable 输入发生变化时就会发生重组。state object 或输入的变化频率可能高于 UI 实际需要更新的频率这会导致不必要的重组。当 composable 的输入变化频率高于你需要进行重组的频率时你应该使用 derivedStateOf 函数。这种情况通常发生在某些内容频繁变化时例如滚动位置但 composable 只需在其越过某个阈值时对其做出反应。derivedStateOf 会创建一个新的可观察的 Compose state object该对象只会按你所需的频率进行更新。通过这种方式它的作用类似于 Kotlin Flows 中的 distinctUntilChanged() 运算符。正确的使用示例如下Composable// When the messages parameter changes, the MessageList// composable recomposes. derivedStateOf does not// affect this recomposition.funMessageList(messages:ListMessage){Box{vallistStaterememberLazyListState()LazyColumn(statelistState){// ...}// Show the button if the first visible item is past// the first item. We use a remembered derived state to// minimize unnecessary compositionsvalshowButtonbyremember{derivedStateOf{listState.firstVisibleItemIndex0}}AnimatedVisibility(visibleshowButton){ScrollToTopButton()}}}snapshotFlow将 Compose 的 State 转换为 Flows使用 snapshotFlow 将 StateT 对象转换为冷流。snapshotFlow 在被收集时会执行其代码块并 emit State objects。当 snapshotFlow 代码块内部读取的其中一个 State objects 发生改变时如果新值与上一次 emit 的值不相等not equal to该流会将新值 emit 给其收集器此行为与 Flow.distinctUntilChanged 类似。以下示例展示了一个 Side-effect用于在用户滚动列表时向分析工具记录相关信息vallistStaterememberLazyListState()LazyColumn(statelistState){// ...}LaunchedEffect(listState){snapshotFlow{listState.firstVisibleItemIndex}.map{index-index0}.distinctUntilChanged().filter{ittrue}.collect{MyAnalyticsService.sendScrolledPastFirstItemEvent()}}在上面的代码中listState.firstVisibleItemIndex 被转换成 Flow这样就能使用 Flow 运算符的强大功能了。重启 EffectCompose 中的一些 effects如 LaunchedEffect、produceState 或 DisposableEffect会接收数量可变的 keyskey 发生改变的时候会取消正在运行的 effect并使用新 key 启动一个新的。这些 API 的典型形式如下EffectName(restartIfThisKeyChanges,orThisKey,orThisKey,...){block}如果用于重启 effect 的参数不合适就可能会出现问题重启 effect 的次数少于应有的次数可能会导致 App 出现 bug。重启 effect 的次数多于应有的次数可能会造成效率低下。根据经验在 effect 代码块中使用的可变和不可变变量都应作为参数添加到 effect composable 中。除此之外还可以添加更多参数来强制重启 effect。如果修改某个变量不应导致 effect 重启那么该变量应使用 rememberUpdatedState 进行包装。如果变量因为被包装在没有键的 remember 中而从未改变那么就不需要将该变量作为键传递给 effect。在上面的 DisposableEffect 示例中该 effect 将其代码块中使用的 lifecycleOwner 作为参数因为它们的任何变化都应该导致 effect 重新启动。ComposablefunHomeScreen(lifecycleOwner:LifecycleOwnerLocalLifecycleOwner.current,onStart:()-Unit,// Send the started analytics eventonStop:()-Unit// Send the stopped analytics event){// These values never change in CompositionvalcurrentOnStartbyrememberUpdatedState(onStart)valcurrentOnStopbyrememberUpdatedState(onStop)DisposableEffect(lifecycleOwner){valobserverLifecycleEventObserver{_,event-/* ... */}lifecycleOwner.lifecycle.addObserver(observer)onDispose{lifecycleOwner.lifecycle.removeObserver(observer)}}}currentOnStart 和 currentOnStop 不需要作为 DisposableEffect 的 key因为使用了 rememberUpdatedState它们的值在组合中永远不会改变。如果你不将 lifecycleOwner 作为参数传递而它发生了变化那么 HomeScreen 会重新组合但 DisposableEffect 不会被销毁和重启。这会导致问题因为从那时起会使用错误的 lifecycleOwner。使用常量作为 key你可以使用像 true 这样的常量作为 effect 键使其遵循调用点的生命周期比如上面所示的 LaunchedEffect 示例。不过在这么做之前要三思而后行确保这是你所需要的。参考https://developer.android.com/develop/ui/compose/side-effects