这一节主要了解一下snapshotFlow,在Compose开发中,snapshotFlow是连接Compose状态与协程流的核心API,属于androidx.compose.runtime包,用于将Compose可观察状态转换为冷流,当状态变化时自动发射新值。
API:
Kotlin
fun <T> snapshotFlow(
block: () -> T
): Flow<T>
block:Lambda表达式,返回需要观察的Compose状态值(如mutableStateOf包装的值)。
返回值:Flow<T>,当block中的状态发生变化时,流会自动发射最新值。
特性
状态感知:仅当block中的状态值实际变化(且前后值不同)时,才会发射新值。
Compose上下文绑定:需在Compose的Composition上下文中使用(如LaunchedEffect、rememberCoroutineScope中)。
冷流特性:流在被收集(collect)时才会开始监听状态变化,取消收集则停止监听。
场景
1 监听非State对象的变化:如果有某些数据不是通过mutableStateOf创建的状态,但是希望在它们发生变化时能够触发重组或执行某些操作,可以使用snapshotFlow来监听这些数据的变化。
2 合并多个状态到一个流:有时候你可能需要根据多个State的值来决定何时执行某个操作。在这种情况下,可以使用snapshotFlow来监听多个状态,并且只有当这些状态中的任何一个发生改变时,才会触发相应的逻辑。
3 异步操作:当你需要基于某些状态执行异步操作,并且只在状态变化时才重新执行这些操作,这时snapshotFlow就能派上用场。可以通过collect或其他方式订阅这个流,并在状态变化时执行你的异步代码。
栗子:
Kotlin
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
val searchSuggestions = listOf(
"Compose Animation", "Compose State", "Kotlin",
"Android Jetpack", "Material Design", "Coroutines"
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchDemo() {
var searchText by remember { mutableStateOf(TextFieldValue("")) }
var suggestions by remember { mutableStateOf(emptyList<String>()) }
LaunchedEffect(Unit) {
snapshotFlow { searchText.text }
.filter { it.isNotBlank() }
.debounce(1000L)
.distinctUntilChanged()
.map { query ->
delay(300)
searchSuggestions.filter { it.contains(query, ignoreCase = true) }
}
.catch { e -> println("搜索出错:${e.message}") }
.collect { result ->
suggestions = result
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("实时搜索") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
// 搜索框
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = "搜索") },
placeholder = { Text("输入关键词搜索...") },
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (searchText.text.isNotBlank()) {
if (suggestions.isNotEmpty()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(suggestions) { suggestion ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(2.dp),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = suggestion,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("暂无搜索结果", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
Kotlin
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.*
data class BmiCategory(val range: String, val color: Color, val description: String)
val bmiCategories = listOf(
BmiCategory("< 18.5", Color(0xFF64B5F6), "偏瘦"),
BmiCategory("18.5 - 23.9", Color(0xFF81C784), "正常"),
BmiCategory("24.0 - 27.9", Color(0xFFFFB74D), "超重"),
BmiCategory("≥ 28.0", Color(0xFFE57373), "肥胖")
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BmiCalculatorDemo() {
var heightCm by remember { mutableStateOf("") }
var weightKg by remember { mutableStateOf("") }
var bmiValue by remember { mutableStateOf(0.0) }
var currentCategory by remember { mutableStateOf<BmiCategory?>(null) }
LaunchedEffect(Unit) {
snapshotFlow {
val height = heightCm.toDoubleOrNull() ?: 0.0
val weight = weightKg.toDoubleOrNull() ?: 0.0
Pair(height, weight)
}
.map { (height, weight) ->
if (height > 0 && weight > 0) {
weight / ((height / 100) * (height / 100))
} else 0.0
}
.distinctUntilChanged()
.collect { bmi ->
bmiValue = bmi
currentCategory = when {
bmi < 18.5 -> bmiCategories[0]
bmi < 24.0 -> bmiCategories[1]
bmi < 28.0 -> bmiCategories[2]
bmi >= 28.0 -> bmiCategories[3]
else -> null
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("BMI计算器") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary
)
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(4.dp),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = heightCm,
onValueChange = { heightCm = it },
label = { Text("身高(厘米)") },
placeholder = { Text("请输入身高") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
)
)
OutlinedTextField(
value = weightKg,
onValueChange = { weightKg = it },
label = { Text("体重(公斤)") },
placeholder = { Text("请输入体重") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
if (bmiValue > 0) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "你的BMI值",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Card(
modifier = Modifier.size(180.dp),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = currentCategory?.color ?: MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(8.dp)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = String.format("%.1f", bmiValue),
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
color = Color.White
)
}
}
currentCategory?.let { category ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
) {
Icon(
Icons.Outlined.Info,
contentDescription = "信息",
tint = category.color,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "BMI ${category.range}:${category.description}",
style = MaterialTheme.typography.bodyLarge,
color = category.color
)
}
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "请输入有效的身高和体重",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
注意:
1.不要在snapshotFlow中捕获外部可变状态
2.避免在snapshotFlow内部执行副作用或耗时操作
3.注意snapshotFlow的初始发射行为
4.避免在snapshotFlow中监听非稳定(unstable)对象
源码:
Kotlin
val readSet = MutableScatterSet<Any>()
val readObserver: (Any) -> Unit = {
if (it is StateObjectImpl) {
it.recordReadIn(ReaderKind.SnapshotFlow)
}
readSet.add(it)
}
说明:记录block执行时读取的所有状态对象
StateObjectImpl:Compose内部所有mutableStateOf创建的状态都实现此接口。
recordReadIn(ReaderKind.SnapshotFlow):标记该状态"被 snapshotFlow 读过",用于后续筛选。
readSet:保存所有被读取的对象引用(包括非State对象,但只有State才会触发更新)。
Kotlin
val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ ->
val maybeObserved = changed.fastAny {
it !is StateObjectImpl || it.isReadIn(ReaderKind.SnapshotFlow)
}
if (maybeObserved) {
appliedChanges.trySend(changed)
}
}
说明:状态变更监听
registerApplyObserver注册一个全局监听器,当任何Snapshot状态被修改并提交(apply)时触发。
changed:本次事务中所有被修改的状态集合。
Kotlin
var lastValue = Snapshot.takeSnapshot(readObserver).run {
try {
enter(block)
} finally {
dispose()
}
}
emit(lastValue)
说明:首次执行与初始值发射
Snapshot.takeSnapshot(readObserver):创建一个带readObserver的快照上下文;
enter(block):在该上下文中执行block,此时所有状态读取都会被readObserver捕获;
执行完后立即emit初始值。
Kotlin
while (true) {
var found = false
var changedObjects = appliedChanges.receive()
// Poll for any other changes before running block
while (true) {
found = found || readSet.intersects(changedObjects)
changedObjects = appliedChanges.tryReceive().getOrNull() ?: break
}
if (found) {
readSet.clear()
val newValue = Snapshot.takeSnapshot(readObserver).run { ... }
if (newValue != lastValue) {
emit(newValue)
lastValue = newValue
}
}
}
说明:循环监听变化+防抖优化
批量处理变更(防抖):
使用while(tryReceive())循环拉取所有pending的appliedChanges;
避免因高频状态更新导致block被反复执行(例如连续快速点击按钮)。
精确依赖判断:
readSet.intersects(changedObjects):只有当本次变更的状态与上次读取的状态有交集,才重新计算。
值比较去重:
即使状态变了,如果block返回值未变(如filter后结果相同),不发射,避免无效更新。
重置readSet:
每次重新执行block前清空readSet,确保只记录最新一轮的依赖。
简单总结:
Kotlin
1.注册apply observer(监听全局状态变更)
2.首次执行block → 记录依赖(readSet)→ emit 初始值
3.进入无限循环:
3.1.等待appliedChanges(状态变更事件)
3.2.批量拉取所有pending变更
3.3.检查是否有变更影响当前依赖(intersects)
3.4.若有 → 重新执行 block → 比较值 → emit(若不同)