文章

Jetpack Compose 中的 Effect 全解析:掌握副作用处理的七种武器

Jetpack Compose 中的 Effect 全解析:掌握副作用处理的七种武器

在 Jetpack Compose 的声明式 UI 世界中,Composable 函数的核心职责是描述 UI 的状态,它应当是纯粹的、无副作用的。然而,在实际应用中,我们不可避免地需要与外界交互,例如:发起网络请求、读取数据库、响应生命周期事件等。这些操作被称为“副作用”(Side Effects)。为了在 Composable 的生命周期内安全、高效地处理这些副作用,Jetpack Compose 提供了一套强大的 Effect API

本文将深入解析 Jetpack Compose 中处理副作用的七种主要方式,助你根据不同场景选择最合适的“武器”。


核心 Effect Handler 概览

Jetpack Compose 主要提供了以下几种 Effect Handler,每种都有其特定的适用场景:

Effect Handler主要用途执行时机是否需要 key是否创建协程
LaunchedEffect在 Composable 进入组合时
执行一个挂起函数,通常用
一次性的异步操作,如网
络请求、动画。
进入组合时,
key 变化时
rememberCoroutineScope获取一个与Composable生
命周期绑定的协程作用域
用于在用户交互等非 Com
-posable上下文中启动协程。
返回一个作用域,
协程在需要时手
动启动
返回作用域
SideEffect每次 Composable 成
功重组后执行一个非挂起
Lambda 表达式,用于与非
Compose 管理的对象共享状态。
每次成功重组后
DisposableEffect用于需要清理资源的副
作用。它在 key 变化或
Composable 退出组合时执行
清理逻辑。
进入组合时,或
key 变化时
rememberUpdatedState在一个可能重启的 Effect
中引用一个最新的值,而不会
致 Effect 重启。
返回一个 State
对象
produceState非 Compose 状态(如
Flow)转换为Compose的
State
进入组合时,或
key 变化时
derivedStateOf当一个或多个 State 对象发
生变化时,派生缓存一个新的
State 对象,用于优化重组性能。
当依赖的 State
变化时

深入解析与代码示例

1. LaunchedEffect: 异步操作的起点

当你需要在 Composable 首次显示时或其依赖的某个状态发生变化时,执行一个异步任务(如网络请求或耗时计算),LaunchedEffect 是你的首选。

  • 工作原理: LaunchedEffect 会启动一个协程,该协程的作用域与 Composable 的生命周期绑定。当 Composable 离开组合时,该协程会自动取消,有效避免内存泄漏。
  • key 的作用: key 参数至关重要。当 key 的值发生变化时,LaunchedEffect 会取消当前正在运行的协程,并启动一个新的协程。如果 key 保持不变,即使 Composable 重组,协程也不会重新启动。传入 Unittrue 作为 key,可以确保协程只在 Composable 首次进入组合时执行一次。

示例:加载数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun UserProfile(userId: String) {
    val user = remember { mutableStateOf<User?>(null) }

    // 当 userId 变化时,重新获取用户信息
    LaunchedEffect(userId) {
        val fetchedUser = api.fetchUser(userId)
        user.value = fetchedUser
    }

    if (user.value != null) {
        // 显示用户信息
    } else {
        // 显示加载中
    }
}

2. rememberCoroutineScope: 响应用户交互的利器

LaunchedEffect 在 Composable 进入组合时自动执行,但有时我们需要在用户的交互事件(如点击按钮)中启动一个协程。这时,rememberCoroutineScope 便派上了用场。

  • 工作原理: 它会返回一个 CoroutineScope,该作用域同样与 Composable 的生命周期绑定。你可以在任何需要的地方(如 onClick Lambda)使用这个作用域来启动协程。

示例:点击按钮显示 Snackbar

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun MyScreen(scaffoldState: ScaffoldState) {
    // 获取一个与 MyScreen 生命周期绑定的协程作用域
    val scope = rememberCoroutineScope()

    Button(onClick = {
        // 在用户点击时启动协程
        scope.launch {
            scaffoldState.snackbarHostState.showSnackbar("Hello, Compose!")
        }
    }) {
        Text("Show Snackbar")
    }
}

3. SideEffect: 与外部世界同步

SideEffect 用于在每次 Composable 成功重组后执行一些非挂起的逻辑。它不创建协程,因此不能用于耗时操作。其主要用途是将 Compose 的状态同步给非 Compose 管理的对象。

  • 执行时机: 每次重组完成,准备将变更提交到 UI 线程时执行。

示例:更新分析工具

1
2
3
4
5
6
7
8
9
@Composable
fun AnalyticsScreen(user: User) {
    // 每次 user 对象变化导致重组后,更新分析工具
    SideEffect {
        analytics.setUserProperty("user_name", user.name)
    }

    Text("Welcome, ${user.name}")
}

4. DisposableEffect: 带清理功能的 Effect

当你的副作用需要进行资源清理时(例如,注册一个广播接收器、添加一个生命周期观察者或订阅一个回调),DisposableEffect 是不二之选。

  • 工作原理: DisposableEffect 的 Lambda 表达式必须返回一个 onDispose 对象。当 key 变化或 Composable 离开组合时,onDispose 中定义的清理逻辑将被执行。

示例:监听生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Composable
fun LifecycleLogger(lifecycleOwner: LifecycleOwner) {
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            Log.d("LifecycleLogger", "Event: $event")
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        // 当 Composable 退出或 lifecycleOwner 变化时,移除观察者
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

5. rememberUpdatedState: 捕获最新的值

在一个长时间运行的 LaunchedEffectDisposableEffect 中,如果其内部逻辑依赖于某个会频繁变化的外部状态,但你又不希望这个状态的变化导致 Effect 重启,rememberUpdatedState 就非常有用。

  • 工作原理: 它会创建一个特殊的 State 对象,该对象的值始终与 Composable 重组时的最新值保持同步,但读取该 State 不会触发重组。

示例:延时操作中使用最新的回调

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun DelayedActionScreen(onTimeout: () -> Unit) {
    // 使用 rememberUpdatedState 包装 onTimeout,确保 LaunchedEffect 中始终引用最新的回调
    val updatedOnTimeout by rememberUpdatedState(onTimeout)

    // 使用 Unit 作为 key,确保协程只启动一次
    LaunchedEffect(Unit) {
        delay(5000)
        updatedOnTimeout() // 即使 onTimeout 回调在 5 秒内发生了变化,这里也会调用最新的那一个
    }
}

6. produceState: 将外部流转换为 State

produceState 可以方便地将外部的、非 Compose 的状态源(特别是基于协程的,如 FlowChannel)转换为 Compose 的 State

  • 工作原理: 它会启动一个协程,并将其 producer Lambda 的结果作为 State 的值。

示例:从 Flow 中收集数据

1
2
3
4
5
6
@Composable
fun collectAsStateWithLifecycle(flow: Flow<T>, initial: T): State<T> {
    return produceState(initial, flow) {
        flow.collect { value = it }
    }
}

注意:对于 Flow,官方更推荐使用 flow.collectAsStateWithLifecycle() 扩展函数,其内部实现就类似 produceState

7. derivedStateOf: 优化派生状态的计算

当你的某个 State 是由一个或多个其他 State 计算得来时,可以使用 derivedStateOf 来避免不必要的重组。只有当计算结果真正发生变化时,读取 derivedStateOf 结果的 Composable 才会重组。

  • 工作原理: 它会缓存计算结果。只有当其依赖的 State 发生变化,并且计算出的新值与旧值不同时,才会通知其观察者(即使用它的 Composable)进行重组。

示例:根据列表状态决定按钮是否可用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Composable
fun TodoList(listState: LazyListState, items: List<TodoItem>) {
    val showScrollToTopButton by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 0
        }
    }

    LazyColumn(state = listState) {
        // ...
    }

    if (showScrollToTopButton) {
        ScrollToTopButton {
            // ...
        }
    }
}

在这个例子中,即使 listState.firstVisibleItemIndex 在滚动时频繁变化(0, 1, 2, 3…),showScrollToTopButton 的值只会在 > 0 的布尔结果变化时(即从 false 变为 true)才会改变,从而避免了不必要的重组。

总结

掌握 Jetpack Compose 的 Effect 处理机制是编写健壮、高效应用的关键。通过理解每种 Effect Handler 的核心用途和工作原理,你将能够游刃有余地处理各种副作用场景,构建出更加稳定和高性能的声明式 UI。

本文由作者按照 CC BY 4.0 进行授权