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 来管理状态,当设备列表频繁更新时,Compose 会过度重组。换成 StateFlow 后,因为 StateFlow 有去重机制(distinctUntilChanged),只有真正变化时才会触发重组,性能提升很明显。

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 本章知识体系

下面这张图总结了本章的核心知识点和它们之间的关系:

蓝牙 + Jetpack Compose 知识体系 蓝牙设备列表页面 状态管理:StateFlow UI 展示:LazyColumn 扫描动画:infiniteTransition sealed class collectAsState() DeviceItem 组件 key 去重优化 Card + Row 布局 旋转动画 脉冲波纹 核心原则:状态驱动 UI,动画提升体验 避免过度重组,注意内存泄漏

核心要点回顾:

  • StateFlow + sealed class 管理蓝牙状态,状态变更清晰可控
  • LazyColumn + key 优化列表性能,避免重复渲染
  • infiniteTransition 实现扫描动画,注意帧率和资源释放

小提示:如果你在真机上测试扫描动画,记得把手机竖屏锁定。横竖屏切换时,Compose 会重建整个页面,动画状态可能会丢失。用 rememberSaveable 可以解决这个问题。

警告:蓝牙扫描在 Android 12 以上需要 BLUETOOTH_SCAN 权限,并且要在运行时请求。别忘了在 Manifest 里声明,否则扫描会静默失败,连错误日志都没有。


公众号:蓝海资料掘金营,微信deep3321