23、蓝牙与Jetpack Compose:Compose UI展示蓝牙设备列表、状态管理(StateFlow)、扫描动画
各位同学,今天我们来聊聊蓝牙开发中一个非常“爽”的话题——用 Jetpack Compose 来展示蓝牙设备列表。
说实话,我刚开始做蓝牙开发那会儿,用的还是传统的 XML + RecyclerView。每次扫描到新设备,都要手动 notifyDataSetChanged,还要处理各种生命周期问题。后来项目迁移到 Compose,我才发现——原来 UI 可以写得这么清爽。
这一章,我会带你从零搭建一个蓝牙设备列表页面。核心就三件事:设备列表怎么展示、状态怎么管理、扫描动画怎么做。
23.1 为什么选择 StateFlow 管理蓝牙状态?
先问大家一个问题:蓝牙扫描是异步的,设备会不断被发现,状态会不断变化。如果用传统的 MutableLiveData,你得手动 postValue,还要处理背压问题。用 StateFlow 呢?
StateFlow 是 Kotlin 协程家族的一员,它天生支持冷流转热流,而且和 Compose 的 collectAsState() 配合得天衣无缝。说白了,你只需要在 ViewModel 里暴露一个 StateFlow,Compose 页面就能自动感知变化并重组 UI。
我个人习惯把蓝牙状态封装成一个 sealed class,这样状态流转非常清晰:
sealed class BluetoothUiState {
object Idle : BluetoothUiState()
object Scanning : BluetoothUiState()
data class DevicesFound(val devices: List<BluetoothDevice>) : BluetoothUiState()
data class Error(val message: String) : BluetoothUiState()
}
然后在 ViewModel 里这样用:
class BluetoothViewModel : ViewModel() {
private val _uiState = MutableStateFlow<BluetoothUiState>(BluetoothUiState.Idle)
val uiState: StateFlow<BluetoothUiState> = _uiState.asStateFlow()
fun startScan() {
_uiState.value = BluetoothUiState.Scanning
// 模拟扫描过程
viewModelScope.launch {
val devices = bluetoothScanner.scan()
_uiState.value = BluetoothUiState.DevicesFound(devices)
}
}
}
嗯,这里要注意:千万不要在 Compose 里直接修改 StateFlow。所有状态变更都应该在 ViewModel 里完成,Compose 只负责“读”。
23.2 Compose 中如何收集 StateFlow?
在 Compose 页面里,我们通过 collectAsState() 来订阅 StateFlow。这个 API 会自动处理协程生命周期,页面销毁时自动取消订阅,非常省心。
@Composable
fun BluetoothDeviceList(viewModel: BluetoothViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) {
is BluetoothUiState.Idle -> {
// 显示初始界面
}
is BluetoothUiState.Scanning -> {
// 显示扫描动画
}
is BluetoothUiState.DevicesFound -> {
// 显示设备列表
}
is BluetoothUiState.Error -> {
// 显示错误信息
}
}
}
你看,代码非常简洁。每个状态对应一个 Composable,互不干扰。我在项目中遇到过一个问题:如果直接用 MutableState
23.3 设备列表的 LazyColumn 实现
设备列表用 LazyColumn 来展示。每个设备项我习惯封装成一个独立的 Composable,方便复用和测试。
@Composable
fun DeviceItem(device: BluetoothDevice, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Bluetooth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = device.name ?: "未知设备", fontWeight = FontWeight.Bold)
Text(text = device.address, style = MaterialTheme.typography.bodySmall)
}
}
}
}
然后在列表里这样用:
LazyColumn {
items(devices) { device ->
DeviceItem(device = device) {
// 点击连接
}
}
}
这里有个小技巧:给每个 item 加上 key,比如 device.address。这样 Compose 在列表更新时能精准定位哪些 item 需要重组,而不是全部重绘。
23.4 扫描动画:让 UI 动起来
扫描动画是提升用户体验的关键。我个人喜欢用无限旋转的图标来表示“正在扫描”。Compose 的 animateFloatAsState 配合 infiniteTransition 就能轻松实现。
@Composable
fun ScanningAnimation() {
val infiniteTransition = rememberInfiniteTransition()
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
Image(
painter = painterResource(id = R.drawable.ic_bluetooth_scanning),
contentDescription = "扫描中",
modifier = Modifier
.size(48.dp)
.rotate(rotation)
)
}
我曾经在项目里加了一个“脉冲波纹”效果,模拟雷达扫描的感觉。原理很简单:用多个同心圆,每个圆的透明度从内到外递减,然后通过 animateFloatAsState 让它们不断放大并消失。代码大概长这样:
@Composable
fun RadarPulse() {
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
initialValue = 0.5f,
targetValue = 2.0f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
)
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.8f,
targetValue = 0.0f,
animationSpec = tween(1500, easing = FastOutSlowInEasing)
)
Canvas(modifier = Modifier.size(100.dp)) {
drawCircle(
color = Color.Blue.copy(alpha = alpha),
radius = size.minDimension / 2 * scale
)
}
}
嗯,这里要注意:动画不要过度。我见过有些 App 扫描动画做得花里胡哨,结果手机发烫、掉帧严重。建议动画帧率控制在 60fps 以内,复杂动画用 Canvas 绘制,不要用多个 Image 叠加。
23.5 完整页面组合
把上面这些拼起来,就是一个完整的蓝牙设备列表页面:
@Composable
fun BluetoothScanScreen(viewModel: BluetoothViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 顶部扫描按钮和动画
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("蓝牙设备", style = MaterialTheme.typography.headlineSmall)
if (uiState is BluetoothUiState.Scanning) {
ScanningAnimation()
}
Button(onClick = { viewModel.startScan() }) {
Text("扫描")
}
}
// 设备列表
when (val state = uiState) {
is BluetoothUiState.Idle -> {
// 提示用户点击扫描
}
is BluetoothUiState.Scanning -> {
// 显示扫描中的占位
}
is BluetoothUiState.DevicesFound -> {
LazyColumn {
items(state.devices, key = { it.address }) { device ->
DeviceItem(device = device) {
viewModel.connectToDevice(device)
}
}
}
}
is BluetoothUiState.Error -> {
// 显示错误
}
}
}
}
你看,整个页面结构非常清晰。状态管理、UI 展示、动画效果各司其职。这就是 Compose 的魅力——声明式 UI 让代码逻辑和视觉呈现完美统一。
23.6 避坑指南
最后分享几个我踩过的坑:
- StateFlow 的初始值不要用 null。我刚开始用 MutableStateFlow
(null),结果 Compose 里每次都要判空,非常麻烦。后来改成 sealed class + Idle 状态,清爽多了。 - 扫描结果去重。蓝牙扫描会重复发现同一个设备,建议在 ViewModel 里用 HashSet 或 distinctBy 去重,否则列表会疯狂闪烁。
- 动画资源释放。如果页面退出时动画还在运行,可能会造成内存泄漏。用 DisposableEffect 来清理动画资源。
我曾经在一个项目里忘了处理扫描去重,结果列表里同一个设备出现了七八次,用户点连接时直接崩溃。从那以后,我每次写蓝牙列表都会先加去重逻辑。
23.7 本章知识体系
下面这张图总结了本章的核心知识点和它们之间的关系:
核心要点回顾:
- StateFlow + sealed class 管理蓝牙状态,状态变更清晰可控
- LazyColumn + key 优化列表性能,避免重复渲染
- infiniteTransition 实现扫描动画,注意帧率和资源释放
小提示:如果你在真机上测试扫描动画,记得把手机竖屏锁定。横竖屏切换时,Compose 会重建整个页面,动画状态可能会丢失。用 rememberSaveable 可以解决这个问题。
警告:蓝牙扫描在 Android 12 以上需要 BLUETOOTH_SCAN 权限,并且要在运行时请求。别忘了在 Manifest 里声明,否则扫描会静默失败,连错误日志都没有。
公众号:蓝海资料掘金营,微信deep3321