Compose 中的 Side-effects

1. 什么是副作用

副作用 是指应用程式状态在可组合函式的范围以外发生变化。由于可组合项的生命周期和属性可能有例如预期之外的重新组成、以不同顺序重新组成可组合项,或可舍弃的重新组成等等因素,因此可组合项最好应该没有任何副作用

但是有时候我们又必须用到副作用,例如想在特定状态条件下触发显示Snackbar 或导览到其他萤幕等单次事件时就必须利用副作用。您应透过会考量可组合项生命周期的受监控环境呼叫这些操作。在这个网页中,我们会说明Jetpack Compose 提供的各种副作用API。

2. 副作用 API

2.1 LaunchedEffect

在 Composable 作用域内启动协程的最基本方式,会在 Composable 进入组合时启动,并在退出组合或依赖项变化时取消并重启。

kotlin 复制代码
LaunchedEffect(key1 = userId) {
    // 在协程中执行副作用操作
    val userData = userRepository.fetchUser(userId)
    // 更新状态
    _userState.value = userData
}

关键点

  • 参数 key,用于确定何时需要重启LaunchedEffect
  • 内部代码运行在协程中,可以安全调用挂起函数
  • 自动管理协程生命周期,与 Composable 生命周期绑定,Composable 退出组合时自动取消内部协程

2.2 rememberCoroutineScope

获取与 Composable 生命周期绑定的协程作用域,用于在 Composable 函数之外(如按钮点击事件)启动协程。

kotlin 复制代码
val scope = rememberCoroutineScope()

Button(onClick = {
    scope.launch {
        // 在按钮点击时执行协程操作
        submitData()
    }
}) {
    Text("提交")
}

关键点

  • 协程作用域与 Composable 生命周期绑定,Composable 退出组合时自动取消

2.3 DisposableEffect

用于需要清理的副作用,确保资源在 Composable 退出组合或依赖变化时得到正确释放。

kotlin 复制代码
DisposableEffect(key1 = lifecycleOwner) {
    // 注册监听器
    val observer = LifecycleEventObserver { _, event ->
        // 处理生命周期事件
    }
    lifecycleOwner.lifecycle.addObserver(observer)

    // 返回清理函数
    onDispose {
        // 移除监听器,防止内存泄漏
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

关键点

  • 必须 返回一个 onDispose 函数来清理资源,当Composable 退出组合的时候执行
  • 适用于注册/注销监听器、订阅/取消订阅、释放资源等场景

2.4 SideEffect

在每次 Composable 成功重组后执行的副作用,没有键参数,每次重组都会执行。

kotlin 复制代码
SideEffect {
    // 记录重组次数或更新分析数据
    analyticsTracker.trackScreenView("HomeScreen")
}

关键点

  • 无键参数,每次重组都会执行
  • 同步执行,不能调用挂起函数
  • 通常用于轻量级操作,如记录日志或更新分析数据

2.5 produceState

将非 Compose 状态转换为 Compose 状态,在后台协程中计算并产生状态。

kotlin 复制代码
val userData by produceState<UserData?>(
    initialValue = null,
    key = userId
) {
    // 后台加载数据
    value = userRepository.fetchUser(userId)
}

关键点

  • 将非状态数据转换为 State 对象
  • 自动管理加载状态
  • 适用于从数据层获取数据并转换为 UI 状态

2.6 derivedStateOf

从其他状态派生出新状态,只有当依赖状态变化且计算结果不同时才会触发重组。

kotlin 复制代码
val searchResults by remember {
    derivedStateOf {
        // 只有当查询或列表变化时才重新计算
        items.filter { it.matches(searchQuery.value) }
    }
}

关键点

  • 用于优化性能,减少不必要的重组
  • 不是严格意义上的副作用 API,但用于状态转换和优化
  • 计算应快速完成,不应包含副作用

注意点

依赖收集机制

  • derivedStateOf 会在首次计算时自动追踪所有在计算过程中读取的 State 对象,并将它们作为依赖项。
  • 如果某个 State 只在条件分支中被读取,而首次计算时未进入该分支,则该 State 不会被注册为依赖项
kotlin 复制代码
val derivedValue = derivedStateOf { 
    if (conditionState.value) { // 如果首次计算时 conditionState.value == false
        stateA.value // 则 stateA 不会被注册为依赖 
    } else { 
        stateB.value // 只有 stateB 成为依赖 
    } 
}

3. 副作用管理原则

3.1 作用域与生命周期

  • 遵循最小作用域原则:副作用应在尽可能小的作用域内执行
  • 生命周期感知:确保副作用与 Composable 生命周期正确绑定,避免内存泄漏
  • 及时清理:所有注册操作都应有对应的注销操作

3.2 依赖参数

  • 谨慎选择 key:副作用 API 的 key 参数决定何时重新执行副作用
  • 最小化依赖集:只包含影响副作用结果的必要依赖项
  • 避免不稳定依赖:不要使用频繁变化的对象作为 key

3.3 避免不必要的副作用

  • 状态提升:将共享状态提升到更高层级管理
  • 纯 UI 逻辑:尽量将逻辑移至 Composable 之外或使用 ViewModel
  • 防抖处理:对频繁变化的状态使用防抖或节流

4. 常见副作用场景

4.1 数据加载与网络请求

使用 LaunchedEffectproduceState 在 Composable 进入组合时加载数据:

kotlin 复制代码
val uiState by produceState<UiState>(initialValue = UiState.Loading) {
    try {
        val data = repository.getData()
        value = UiState.Success(data)
    } catch (e: Exception) {
        value = UiState.Error(e.message)
    }
}

4.2 订阅与监听

使用 DisposableEffect 处理需要注册和注销的监听器:

kotlin 复制代码
DisposableEffect(key1 = connectivityManager) {
    val networkCallback = object : ConnectivityManager.NetworkCallback() {
        // 网络状态变化处理
    }

    connectivityManager.registerDefaultNetworkCallback(networkCallback)

    onDispose {
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }
}

4.3 UI 状态更新

响应状态变化并更新 UI 相关设置:

kotlin 复制代码
LaunchedEffect(key1 = isDarkMode) {
    // 根据主题模式更新系统状态栏
    systemUiController.setSystemBarsColor(
        color = if (isDarkMode) Color.Black else Color.White
    )
}

4.4 日志与分析

使用 SideEffect 记录用户交互和界面展示:

kotlin 复制代码
SideEffect {
    analytics.logScreenView(screenName = "ProductDetail")
}

Button(onClick = {
    scope.launch {
        trackButtonClick("addToCart")
        addToCart(productId)
    }
}) {
    Text("加入购物车")
}

5. 最佳实践与注意事项

5.1 单一职责原则

每个副作用函数应只负责一项任务,保持副作用代码简洁明确。

kotlin 复制代码
// 不推荐:一个副作用做太多事情
LaunchedEffect(Unit) {
    loadUserData()
    trackScreenView()
    setupAnalytics()
}

// 推荐:拆分多个副作用
LaunchedEffect(userId) { loadUserData() }
SideEffect { trackScreenView() }
DisposableEffect(Unit) {
    setupAnalytics()
    onDispose { cleanupAnalytics() }
}

5.2 测试副作用

  • 将复杂副作用逻辑移至 ViewModel 或仓库层,便于单独测试
  • 使用 runTestTestDispatcher 测试协程副作用
  • 考虑使用依赖注入提供测试替身(Fake)
kotlin 复制代码
@RunWith(JUnit4::class)
class UserScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loadUserData_displaysUserInfo() = runTest {
        // 使用测试调度器和假仓库
        val fakeRepository = FakeUserRepository()
        composeTestRule.setContent {
            UserScreen(
                userId = "123",
                repository = fakeRepository,
                dispatcher = testDispatcher
            )
        }

        // 验证UI状态
        composeTestRule.onNodeWithText("John Doe").assertIsDisplayed()
    }
}

5.3 性能考量

  • 避免过度使用副作用:不必要的副作用会导致性能问题
  • 合理设置 key 参数:避免因错误的 key 导致副作用频繁重启
  • 使用 derivedStateOf 优化计算:对复杂计算结果进行缓存
  • 限制重组范围 :使用 rememberLaunchedEffect 的 key 参数控制重组
kotlin 复制代码
// 优化前:每次滚动都会重新计算
val visibleItems = items.filter { it.isVisible }

// 优化后:只有 items 变化时才重新计算
val visibleItems by remember(items) {
    derivedStateOf { items.filter { it.isVisible } }
}

注意:过度使用副作用或不正确地管理副作用可能导致应用不稳定、性能问题或难以调试的错误。始终遵循 Compose 的单向数据流原则,将业务逻辑与 UI 分离。

相关推荐
Auspemak-Derafru几秒前
安卓上的迷之K_1171477665
android
你过来啊你6 分钟前
Android埋点实现方案深度分析
android·埋点
你过来啊你18 分钟前
Android开发中QUIC使用分析
android
你过来啊你24 分钟前
android开发中的协程和RxJava对比
android
你过来啊你25 分钟前
Android开发中线上ANR问题解决流程
android
qq_316837751 小时前
win11 使用adb 获取安卓系统日志
android·adb
火凤凰--凤凰码路1 小时前
MySQL 中的“双路排序”与“单路排序”:原理、判别与实战调优
android·数据库·mysql
wayne2141 小时前
Android ContentProvider详解:底层原理与最佳实践
android
一个天蝎座 白勺 程序猿1 小时前
Python(32)Python内置函数全解析:30个核心函数的语法、案例与最佳实践
android·开发语言·python