Jetpack Compose 开发:副作用完全指南

副作用(Side Effect)是 Compose 开发中不可回避的核心概念。本文将系统讲解什么是副作用、为什么需要副作用,以及如何使用 Compose 提供的各种 Effect API 安全、高效地处理副作用,包含完整的代码示例、对比表格和最佳实践。


一、什么是副作用?为什么需要副作用?

1.1 什么是副作用?

在 Compose 中,副作用是指任何发生在可组合函数作用域之外的操作,它改变了程序的外部状态或与外部系统交互。典型的副作用包括:

  • 网络请求
  • 数据库操作
  • 读写 SharedPreferences
  • 启动定时器
  • 注册/反注册监听器、广播接收器
  • 更新其他非 Compose 管理的 UI(如传统 View)

Composable 函数本质是“纯函数”——相同的输入(状态)始终产生相同的输出(UI),且不产生任何外部影响。但实际开发中,我们不可避免地需要执行这些“非纯操作”,它们就是副作用。

1.2 为什么需要副作用?

Compose 的核心是重组(Recomposition)——当状态变化时,可组合函数会被重新执行。这意味着:

  • 可组合函数可能被多次执行,且执行顺序不可预测
  • 在可组合函数中直接执行副作用会导致严重问题:重复执行、内存泄漏、状态不一致

因此,Compose 提供了专门的 Effect API 来安全地处理副作用,确保它们:

  • 在正确的时机执行(如首次组合、状态变化时)
  • 在组件退出时自动清理
  • 与可组合函数的生命周期绑定

核心原则:副作用必须封装在 Effect API 中,绝对不要在可组合函数中直接执行。


二、LaunchedEffect:在 Composable 中启动协程

LaunchedEffect 是最常用的副作用 API,用于在可组合函数的作用域内启动一个协程。该协程在组件进入组合时启动,在组件离开组合时自动取消。

2.1 基础用法

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlinx.coroutines.delay

@Composable
fun TimerDemo() {
    var countdown by remember { mutableStateOf(10) }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("倒计时:$countdown 秒", fontSize = 24.sp)

        LaunchedEffect(Unit) {
            while (countdown > 0) {
                delay(1000)
                countdown--
            }
        }
    }
}

2.2 key 参数的作用:当 key 变化时重新执行

LaunchedEffect 接受一个或多个 key 参数。当 key 的值发生变化时,当前协程会被取消,并重新启动一个新的协程

  • 若 key = Unit,协程仅在组件首次进入组合时启动一次,后续重组不会重启。
  • 若 key 是某个状态(如 userId),则状态变化时协程会重新执行。
@Composable
fun UserProfile(userId: String) {
    var userData by remember { mutableStateOf<User?>(null) }

    // key = userId:当 userId 变化时,重新请求数据
    LaunchedEffect(userId) {
        userData = fetchUserData(userId)
    }

    Text("用户: ${userData?.name ?: "加载中..."}")
}

常见 key 用法

  • LaunchedEffect(Unit) { } — 只在首次组合时执行一次
  • LaunchedEffect(searchQuery) { } — 当 searchQuery 变化时重新执行
  • LaunchedEffect(userId, isEnabled) { } — 任一 key 变化都会重新执行

2.3 常见副作用场景:网络请求与定时器

网络请求(带状态管理)

@Composable
fun ProductListScreen() {
    var uiState by remember { mutableStateOf(ProductUiState.Loading) }

    LaunchedEffect(Unit) {
        uiState = try {
            val products = fetchProducts()
            ProductUiState.Success(products)
        } catch (e: Exception) {
            ProductUiState.Error(e.message ?: "加载失败")
        }
    }

    when (val state = uiState) {
        is ProductUiState.Loading -> CircularProgressIndicator()
        is ProductUiState.Success -> LazyColumn {
            items(state.products) { product ->
                Text(product.name)
            }
        }
        is ProductUiState.Error -> Text(state.message)
    }
}

定时器(倒计时)

@Composable
fun CountdownTimer(initialSeconds: Int, onFinish: () -> Unit) {
    var currentSeconds by remember { mutableStateOf(initialSeconds) }

    LaunchedEffect(initialSeconds) {
        while (currentSeconds > 0) {
            delay(1000)
            currentSeconds--
        }
        onFinish()
    }

    Text("倒计时: $currentSeconds 秒", fontSize = 24.sp)
}

三、DisposableEffect:资源清理

DisposableEffect 用于管理需要清理的资源。它会在组件离开组合时调用 onDispose 块,确保资源被正确释放。

3.1 基础用法

import androidx.compose.runtime.DisposableEffect

@Composable
fun LocationTracker() {
    var location by remember { mutableStateOf<Location?>(null) }

    DisposableEffect(Unit) {
        val locationListener = object : LocationListener {
            override fun onLocationChanged(loc: Location) {
                location = loc
            }
        }
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER,
            1000L,
            10f,
            locationListener
        )

        onDispose {
            locationManager.removeUpdates(locationListener)
        }
    }

    location?.let {
        Text("纬度: ${it.latitude}, 经度: ${it.longitude}")
    }
}

3.2 应用场景:注册/反注册监听器、广播接收器

场景1:网络状态监听器

@Composable
fun NetworkStatusMonitor() {
    val context = LocalContext.current
    var isConnected by remember { mutableStateOf(false) }

    DisposableEffect(Unit) {
        val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val networkCallback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                isConnected = true
            }
            override fun onLost(network: Network) {
                isConnected = false
            }
        }
        connectivityManager.registerNetworkCallback(
            NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(),
            networkCallback
        )

        onDispose {
            connectivityManager.unregisterNetworkCallback(networkCallback)
        }
    }

    Text("网络状态: ${if (isConnected) "已连接" else "未连接"}")
}

场景2:广播接收器

@Composable
fun BatteryStatusMonitor() {
    var batteryLevel by remember { mutableStateOf(0) }
    val context = LocalContext.current

    DisposableEffect(Unit) {
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                batteryLevel = intent?.getIntExtra("level", 0) ?: 0
            }
        }
        context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))

        onDispose {
            context.unregisterReceiver(receiver)
        }
    }

    Text("当前电量: $batteryLevel%")
}

四、SideEffect:与外部非 Compose 世界同步

SideEffect 用于在重组完成后,将 Compose 状态同步到外部非 Compose 管理的对象。它会在每次成功重组后执行。

4.1 基础用法

@Composable
fun AnalyticsScreen(screenName: String) {
    // 每次重组后,将当前屏幕名称同步到分析 SDK
    SideEffect {
        AnalyticsTracker.trackScreenView(screenName)
    }

    Text("当前屏幕: $screenName")
}

4.2 应用场景:同步 Compose 状态到原生 Activity

class SideEffectDemoActivity : ComponentActivity() {
    var nativeCount = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            SideEffectSyncDemo()
        }
    }
}

@Composable
fun SideEffectSyncDemo() {
    val context = LocalContext.current as SideEffectDemoActivity
    var composeCount by remember { mutableStateOf(0) }

    SideEffect {
        context.nativeCount = composeCount
        println("同步 Compose 状态到原生:$composeCount")
    }

    Column {
        Text("Compose 计数:$composeCount")
        Text("原生 Activity 计数:${context.nativeCount}")
        Button(onClick = { composeCount++ }) {
            Text("增加计数")
        }
    }
}

五、其他常用的 Effect API

5.1 rememberCoroutineScope:在事件回调中启动协程

rememberCoroutineScope 返回一个协程作用域,用于在非重组回调中启动协程(如按钮点击)。

@Composable
fun SnackbarScreen() {
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { paddingValues ->
        Button(
            onClick = {
                scope.launch {
                    snackbarHostState.showSnackbar("操作完成!")
                }
            },
            modifier = Modifier.padding(paddingValues)
        ) {
            Text("显示提示")
        }
    }
}

5.2 produceState:将外部状态转换为 Compose State

produceState 用于将非 Compose 状态源(如 Flow、LiveData、RxJava)转换为 Compose 可观察的 State

@Composable
fun loadNetworkImage(url: String): State<ImageLoadState> {
    return produceState(
        initialValue = ImageLoadState.Loading,
        key1 = url
    ) {
        value = try {
            val image = imageRepository.loadImage(url)
            ImageLoadState.Success(image)
        } catch (e: Exception) {
            ImageLoadState.Error(e.message)
        }
    }
}

5.3 derivedStateOf:派生状态优化

derivedStateOf 用于创建依赖其他状态的派生状态,只有在依赖发生变化时才会重新计算。

@Composable
fun ScrollableList(items: List<String>) {
    val listState = rememberLazyListState()

    val showScrollToTopButton by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 5
        }
    }

    Box {
        LazyColumn(state = listState) {
            items(items) { item -> Text(item) }
        }

        if (showScrollToTopButton) {
            FloatingActionButton(
                onClick = { /* 滚动到顶部 */ },
                modifier = Modifier.align(Alignment.BottomEnd)
            ) {
                Icon(Icons.Default.ArrowUpward, "回到顶部")
            }
        }
    }
}

六、Effect API 对比总结

API核心作用是否支持协程是否需要清理执行时机适用场景
LaunchedEffect启动协程自动取消进入组合或 key 变化时网络请求、定时器、数据流收集
DisposableEffect资源管理是,必须 onDispose进入组合或 key 变化时注册/反注册监听器、广播接收器
SideEffect同步外部状态每次重组后更新传统 View、日志记录、分析埋点
rememberCoroutineScope获取协程作用域组件退出时取消组合期间事件回调中的协程
produceState转换外部状态自动取消进入组合或 key 变化时将 Flow/LiveData 转为 State
derivedStateOf派生状态依赖变化时计算派生值、避免不必要的重组

七、常见陷阱与最佳实践

7.1 最佳实践

  1. 副作用必须封装在 Effect API 中,绝对不要在可组合函数中直接执行。
  2. 合理使用 key:通过 key 控制 Effect 的重新执行时机,避免不必要的重启。
  3. 使用 sealed class 表示 UI 状态:让状态变化更清晰、可预测。
  4. 在 DisposableEffect 中始终提供 onDispose:防止资源泄漏。
  5. 优先使用 LaunchedEffect 而非 GlobalScope:确保协程与组件生命周期绑定。

7.2 常见陷阱

陷阱错误示例正确做法
在 Composable 中直接启动协程CoroutineScope(Dispatchers.IO).launch { ... }使用 LaunchedEffect 或 rememberCoroutineScope
忘记清理资源DisposableEffect(Unit) { registerListener() }始终提供 onDispose 块
使用不当的 keyLaunchedEffect(Unit) { loadData() } 但数据源会变化将依赖状态作为 key
在 SideEffect 中修改状态SideEffect { count++ }可能导致无限循环
忽略异常处理LaunchedEffect(key) { val data = fetch() }使用 try-catch 捕获异常

八、完整示例:综合应用

@Composable
fun WeatherApp(cityId: String) {
    var weatherData by remember { mutableStateOf<Weather?>(null) }
    var isLoading by remember { mutableStateOf(true) }
    var errorMessage by remember { mutableStateOf<String?>(null) }

    // 1. LaunchedEffect:网络请求
    LaunchedEffect(cityId) {
        isLoading = true
        try {
            weatherData = fetchWeather(cityId)
            errorMessage = null
        } catch (e: Exception) {
            errorMessage = "加载失败: ${e.message}"
        } finally {
            isLoading = false
        }
    }

    // 2. SideEffect:页面访问埋点
    SideEffect {
        Analytics.trackScreen("WeatherDetail", mapOf("cityId" to cityId))
    }

    // 3. DisposableEffect:注册/反注册网络状态监听
    DisposableEffect(Unit) {
        val listener = NetworkChangeListener { isConnected ->
            if (!isConnected) errorMessage = "网络连接已断开"
        }
        NetworkManager.register(listener)

        onDispose {
            NetworkManager.unregister(listener)
        }
    }

    // UI 渲染
    when {
        isLoading -> CircularProgressIndicator()
        errorMessage != null -> Text(errorMessage!!, color = Color.Red)
        weatherData != null -> WeatherDisplay(weatherData!!)
    }
}

九、总结

概念核心要点
副作用可组合函数外部的操作,如网络请求、监听器、定时器
LaunchedEffect启动协程,与组件生命周期绑定,key 变化时重启
DisposableEffect资源管理,必须提供 onDispose 清理资源
SideEffect每次重组后执行,用于同步外部状态
rememberCoroutineScope获取协程作用域,用于事件回调
produceState将外部状态源转换为 Compose State
derivedStateOf创建派生状态,避免不必要的重组

核心原则

  • ✅ 副作用必须封装在 Effect API 中
  • ✅ 合理使用 key 控制执行时机
  • ✅ 始终清理资源,避免内存泄漏
  • ✅ 使用 sealed class 管理 UI 状态

十、线上资料链接

官方文档

优质文章

通过掌握副作用处理,你将能够安全地在 Compose 应用中执行网络请求、定时器、监听器等操作,构建健壮、可维护的应用程序。

 

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐