Compose学习和使用(二):状态和附带效应

191 阅读5分钟

前言

重组是 Compose 核心机制之一。页面和状态的变化依赖函数的执行,换句话,我们也很难确定函数是什么时候执行的,以及它执行的次数。所以,在重组函数中,我们不能按照常规的方法定义状态(如var clickCount = 0),因为重组函数是幂等的。

比如,我们想在一个button上展示点击的次数,我们需要定义一个clickCount存储次数数值,如果直接定义在重组函数体内,那么每一次重组都会导致clickCount的初始化。当然我们可以使用viewmodel来进行存储,但是这种操作不够优雅。

可组合函数作用域之外的应用状态的变化就是指附带效应,简单来说,如果我们希望一个状态或者操作从组合开始到组合结束,无论中间有多少次重组,这个状态都全剧唯一,我们就要使用到这个。

状态

状态最核心的api是remember。

可组合函数可以使用 remember将对象存储在内存中。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。

@Composable
fun DetailsDescription(podcast: PodcastInfo, modifier: Modifier) {
    var isExpanded by remember { mutableStateOf(false) }
    Box(
        modifier = modifier.clickable { isExpanded = !isExpanded },
    ) {
        Text(
            text = podcast.description,
            style = MaterialTheme.typography.bodyMedium,
            maxLines = if (isExpanded) Int.MAX_VALUE else 3,
            overflow = TextOverflow.Ellipsis,
            modifier = modifier,
        )

    }
}

remember避免了每一次DetailsDescription重组导致isExpanded的重建。mutableStateOf 会创建可观察的 MutableState。在这里isExpanded的值的变化,会触发受isExpanded值影响的代码块的重组。

还有个衍生api : rememberSaveable,它与remember的区别是它将数据存在了saveSavedStateHandle中,这导致在配置变化时,比如旋转屏幕时,数据不会丢失。注意rememberSaveable存储的数据必须可序列化。

我们在实际开发中,常常将viewModel和state联合起来,以生命周期感知型方式从 Flow 收集值,从而使得整个compose更加优雅。

collectAsStateWithLifecycle帮助我们将状态从flow变成了state,从而使使得开发模式更加符合数据的形式。

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

我对Compose的状态的理解,类似于flutter的stateful和stateless控件,因此相比之下,如果能够将重组函数的状态统一管理,能够大大简化底层函数的复杂度。在官方文档中,大力推荐了状态提升的开发模式,即将状态都上移到顶部函数,通过闭包函数的方式作为参数下发给底层函数。这个对于统一状态管理和控件复用,非常有意义。

附带效应

理论上,重组函数适用于UI展示的,但是在很多情况下,我们可能会做一些逻辑操作。比如,网络请求和IO处理。我们不可能每次重组就进行一次逻辑操作,同时这种高性能输出的操作势必需要跟随生命周期,从而避免内存泄漏。

我们可以用LaunchedEffect来启动协程。

@Composable
fun DataLoadingScreen() {
    var key by remember { mutableStateOf(true) }
    
    // 当组件进入组合时启动协程加载数据
    LaunchedEffect(key) { // 传入Unit表示只执行一次
      // 模拟网络请求
    }
}

LaunchedEffect的生命周期,从Compose组合开始启动,从组合终止结束,除非key值变化,不然重组时不会再次启动协程。

大部分情况下,LaunchedEffect足以满足我们的业务需求,但是LaunchedEffect生命周期固定,并且只能在Compose函数中使用。因此Compose给我提供了rememberCoroutineScope。它会返回一个 CoroutineScope,该 CoroutineScope 绑定到调用它的组合点。调用退出组合后,作用域将取消。

@Composable
fun execute(viewModel: NetViewModel) {
    val scope = rememberCoroutineScope()
    viewModel.setCoroutineScope(scope)
    Button(onClick = { viewModel.fetchData() }) {
        Text("获取数据")
    }
}

在上文中,我们提到,状态上升以及状态变化通过闭包函数的下发,是一个推荐的操作。我们无法保证,重组后闭包函数不改变,如果由rember来持有这个闭包函数,就会导致函数是旧的,因此,我们可以使用,rememberUpdatedState。

@Composable
fun TimerComponent(onTick: () -> Unit) {
    val latestOnTick by rememberUpdatedState(onTick)
    
    LaunchedEffect(Unit) {
        while (true) {
            delay(1000)
            // 确保每次调用的是最新的 onTick 实现
            latestOnTick()
        }
    }
}

latestOnTick持有的永远是最新的onTick,当然,这里onTick变成String,Int都是一样的。

DisposableEffect用于在键发生变化或可组合项退出组合后进行清理的附带效应。类似于协程的awitCancel()。

    DisposableEffect(key) {
        // action 1
        onDispose {
           // action 2
        }
    }

进入组合执行action1,退出组合时,action 2会执行。需要注意的一点是,如果key变化,会执行action2,在执执行action1,这是一种清除原有状态,重新执行的操作。

设想还有一种情况,我们需要监听每次重组的情况,这个通常和打点有关。SideEffect在每次成功重组后执行相应操作。

@Composable
fun execute(isDarkMode: Boolean) {
    // 每次重组后记录主题模式状态
    SideEffect {
        Log.d("Settings", "当前主题模式:${if (isDarkMode) "深色" else "浅色"}")
    }
//.......
}

接下来再说一下其他一些常用Api。

produceState是Compose中将数据转化为state的操作,相比 flow.collectAsState(),produceState 更适合处理单次异步操作(而非持续的数据流)。

@Composable
fun load(userId: String) {
    val data by produceState<String>(
        initialValue = "开始",
        key1 = userId //key
    ) {
        // 模拟网络请求
    }
}

除了组合进入和退出外,只有当userId变化时,data会重建。

derivedStateOf用于将一个计算结果包装成一个新的 State 对象。这个通常用于滑动。例如当当前卡片滑动一半时,我们可以触发文案变化,但是时刻监听滚动条,并重组实在是太耗费性能。这时候可以用到derivedStateOf。

@Composable
fun scrollBar() {
    val items = (1..100).map { "Item $it" }
    // 获取滚动状态
    val listState = rememberLazyListState()
    
    // 只有当第一个可见项的索引变化且超过阈值时,才会触发重组
    val shouldShowScrollToTop by remember {
        derivedStateOf {
            // 当列表滚动超过第5项时,显示回到顶部按钮
            listState.firstVisibleItemIndex > 5
        }
    }

    Scaffold(
        floatingActionButton = {
            // 仅当shouldShowScrollToTop变化时才会重组该按钮
            if (shouldShowScrollToTop) {
                FloatingActionButton(onClick = {
                    // 滚动到顶部
                    coroutineScope.launch {
                        listState.animateScrollToItem(0)
                    }
                }) {
                    Icon(Icons.Default.ArrowUp, contentDescription = "回到顶部")
                }
            }
        }
    ) { padding ->
        LazyColumn(
            state = listState,
            contentPadding = padding
        ) {
            items(items) { item ->
                ListItem(
                    headlineContent = { Text(item) },
                    modifier = Modifier.height(80.dp)
                )
            }
        }
    }
}

snapshotFlow可以将state转化为flow,方便我们在协程中处理状态变化,或与其他基于流的逻辑(如数据层的 Flow)进行集成。

@Composable
fun serachText() {
    var query by remember { mutableStateOf("") }
    val queryFlow = snapshotFlow { query }
    
    // 收集流,添加防抖逻辑
    LaunchedEffect(Unit) {
        queryFlow
            .debounce(300) // 输入停止300ms后才处理
            .collect { searchQuery ->
                // 执行搜索操作
                performSearch(searchQuery)
            }
    }
//。。。。。
}

这种写法看起来真的很对原生开发的胃口。

总结

目前来看,Compose在和jetpack,协程契合这一块做了非常多的功夫,导致两者调用可以无缝链接。相比于flutter,这个是非常大的优势。