什么时候该用 BLE,什么时候该用 SPP?很多 Android 项目一开始就做错了

做 Android 蓝牙,很多团队一上来就选 BLE。

原因也很简单。BLE 更常见,低功耗这个标签也足够有吸引力,手机系统支持成熟,外设生态也大。于是项目刚立项时,大家往往很少认真追问一句话:这个业务到底更像 BLE,还是更像 SPP?

这个问题如果一开始没问清,后面经常就会变成另一种熟悉的局面。代码越写越多,状态越来越乱,重连越来越重,分包和队列越补越厚,最后大家会下一个结论:Android 蓝牙真难做。

但很多时候,难做不是因为 Android 蓝牙天然有多坑,而是因为一开始就把通信模型选错了。本来更适合 SPP 的场景,硬往 BLE 里塞,后面所有复杂度几乎都会沿着这个错误持续增长。

这篇文章不想再重复讲"BLE 怎么重连更稳"这种实现层问题,而是想把更上游的事讲透:什么时候该用 BLE,什么时候该用 SPP。

先别比较新旧,先比较通信模型

很多人讨论 BLE 和 SPP,喜欢直接下判断。BLE 省电,SPP 吞吐更直接;BLE 结构化,SPP 更像传统串口;BLE 适合 IoT,SPP 适合实时传输。这些判断都不算错,但它们还是太像标签,不能真正指导选型。

真正有用的判断方式,是先看业务到底需要什么样的连接。

如果你的业务本质上是"偶尔交换一点状态",比如同步电量、固件版本、开关状态、模式信息,或者用户点一下按钮,设备回一条结果,那它更接近 BLE 的设计初衷。BLE 很擅长这种低频、轻量、结构化的交互。它并不要求你一直维持一条持续高吞吐的数据通道,而是围绕 service、characteristic、read、write、notify 这一整套模型来组织通信。

如果你的业务本质上是"我需要一条稳定的数据流通道",情况就完全不一样了。比如设备连接上以后要持续推数据,App 这边也要连续发命令;协议天然是串口帧格式;你关心的是吞吐、节奏、流式处理、缓冲区和粘包拆包;那这个项目的味道通常就更像 SPP。

从这个角度看,BLE 和 SPP 不是谁更高级,而是谁更贴合业务的数据形态。

BLE 适合的是状态交互,不是把串口换个壳

BLE 最大的特点不是"也能传数据",而是它传数据的方式是被模型约束过的。

你不会像操作 socket 一样去用 BLE。你通常会先拿到 BluetoothGatt,再通过服务和特征值定位能力点,再决定是读、写还是订阅通知。这个过程天然带着一种"结构化状态同步"的味道。

比如你有一个设备,连接之后只需要做几件事:发现服务,读一次版本号,订阅一次状态通知,偶尔下发一个控制命令,这种业务就和 BLE 非常同频。

代码写出来大概也是这种感觉:

kotlin 复制代码
class BleDeviceClient(
    private val context: Context,
    private val device: BluetoothDevice
) {
    private var gatt: BluetoothGatt? = null
​
    fun connect() {
        gatt = device.connectGatt(context, false, callback)
    }
​
    private val callback = object : BluetoothGattCallback() {
        override fun onConnectionStateChange(
            gatt: BluetoothGatt,
            status: Int,
            newState: Int
        ) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                gatt.discoverServices()
            }
        }
​
        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
            val service = gatt.getService(SERVICE_UUID) ?: return
            val versionChar = service.getCharacteristic(VERSION_UUID)
            val stateChar = service.getCharacteristic(STATE_UUID)
​
            gatt.readCharacteristic(versionChar)
            gatt.setCharacteristicNotification(stateChar, true)
        }
​
        override fun onCharacteristicChanged(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic
        ) {
            if (characteristic.uuid == STATE_UUID) {
                val state = characteristic.value
                handleDeviceState(state)
            }
        }
    }
​
    private fun handleDeviceState(bytes: ByteArray) {
        // parse state packet
    }
}

这段代码为什么看起来顺?因为业务也顺着 BLE 的模型走。版本号是一个特征值,设备状态是一个通知特征,命令写入也会落在某个 write characteristic 上。这里的交互重点不是"我要一根持续写满字节的管道",而是"我在一组能力点之间做状态同步和事件触发"。

但很多项目其实不是这种路数。它们只是表面上接了一个 BLE 设备,骨子里却一直在追求"像串口一样不停收发"。这时候问题就来了。

当你总想把 BLE 用成流,说明它可能不是最合适的

不少团队做着做着就会进入一个很熟悉的节奏。先是为了更大的包调 MTU,再是为了连续发命令加发送队列,再为了收通知做拆包拼包,接着处理写入回调串行化,最后再补连接状态机和超时恢复。整个项目越来越像在做一层 BLE 版自制传输协议。

这当然不是不能做,但你要意识到一个信号:如果你在持续把 BLE 往"流式通道"方向掰,那业务大概率已经偏离 BLE 最舒服的使用区间了。

比如下面这种代码,在 BLE 项目里非常常见:

kotlin 复制代码
class BleWriteQueue(
    private val gatt: BluetoothGatt,
    private val writeChar: BluetoothGattCharacteristic
) {
    private val queue = ArrayDeque<ByteArray>()
    private var writing = false
​
    fun send(packet: ByteArray) {
        queue += packet
        if (!writing) {
            writeNext()
        }
    }
​
    fun onWriteComplete() {
        writing = false
        writeNext()
    }
​
    private fun writeNext() {
        val next = queue.removeFirstOrNull() ?: return
        writing = true
        writeChar.value = next
        gatt.writeCharacteristic(writeChar)
    }
}

如果你的代码已经开始大量围绕这种串行写队列、分包、节流、拼帧在转,说明你真正想要的很可能不是"若干个特征值的状态读写",而是一条更像串口的数据通道。

这就是 SPP 经常更合适的地方。

SPP 更像一条通道,而不是一组特征值

SPP 的思维方式和 BLE 很不一样。它不强调 service 和 characteristic,也不要求你围绕读写通知去搭建能力点。它更像建立一条持续连接的字节流通道,设备和手机都在这条通道上收发数据。

如果你的协议本来就是传统串口帧格式,那用 SPP 往往非常自然。你不需要先思考这条命令该挂在哪个 characteristic 下,也不需要反复处理"这次 write 能不能马上继续下一包"这种节奏问题。你更关心的是:连接建立了没有,输入流在不在,输出流能不能稳定发送,接收缓冲区如何切帧。

一个很典型的 SPP 客户端,大概是这样:

kotlin 复制代码
class SppClient(
    private val device: BluetoothDevice
) {
    private var socket: BluetoothSocket? = null
    private var readJob: Job? = null
​
    suspend fun connect() = withContext(Dispatchers.IO) {
        val uuid = UUID.fromString(SPP_UUID)
        socket = device.createRfcommSocketToServiceRecord(uuid)
        socket?.connect()
        startReadLoop()
    }
​
    suspend fun send(frame: ByteArray) = withContext(Dispatchers.IO) {
        val out = socket?.outputStream ?: error("socket not connected")
        out.write(frame)
        out.flush()
    }
​
    fun disconnect() {
        readJob?.cancel()
        socket?.close()
        socket = null
    }
​
    private fun startReadLoop() {
        val input = socket?.inputStream ?: return
        readJob = CoroutineScope(Dispatchers.IO).launch {
            val buffer = ByteArray(1024)
            while (isActive) {
                val size = input.read(buffer)
                if (size <= 0) break
                onBytesReceived(buffer.copyOf(size))
            }
        }
    }
​
    private fun onBytesReceived(chunk: ByteArray) {
        // append to frame parser
    }
​
    companion object {
        private const val SPP_UUID = "00001101-0000-1000-8000-00805F9B34FB"
    }
}

看得出来,这一套心智模型和 BLE 完全不是一回事。SPP 更像你熟悉的"建链以后持续收发",它天然适合那些原本就是流式协议、串口协议、持续推送协议的业务。

如果你的设备一连上就要源源不断发数据,或者 App 侧要高频率持续写入,SPP 往往会比 BLE 更顺手。不是因为它神奇,而是因为它本来就是按这种通信方式设计的。

选型错了以后,代码复杂度会怎么长出来

很多项目的痛苦不是立刻爆发的,而是会在实现过程中一点点显形。

开始的时候,大家只觉得 BLE 也能发数据,于是先做。等协议越来越长、业务越来越频繁,就开始碰到 MTU 限制。为了处理大包,加分包。分完包以后发现不能无脑连续写,于是加发送队列。队列加完以后又发现对端回包不是按业务消息边界来的,于是加接收缓存和帧解析器。再往后,偶发断连一来,又要把重连和恢复状态全补上。

代码慢慢就会变成这样:

kotlin 复制代码
class BleFrameTransport(
    private val mtuPayloadSize: Int
) {
    private val receiveBuffer = ByteArrayOutputStream()
​
    fun splitForWrite(frame: ByteArray): List<ByteArray> {
        val chunks = mutableListOf<ByteArray>()
        var offset = 0
        while (offset < frame.size) {
            val end = minOf(offset + mtuPayloadSize, frame.size)
            chunks += frame.copyOfRange(offset, end)
            offset = end
        }
        return chunks
    }
​
    fun appendNotification(chunk: ByteArray): List<ByteArray> {
        receiveBuffer.write(chunk)
        return extractFrames()
    }
​
    private fun extractFrames(): List<ByteArray> {
        val data = receiveBuffer.toByteArray()
        val frames = mutableListOf<ByteArray>()
        var offset = 0
​
        while (offset + 2 <= data.size) {
            val length = ((data[offset].toInt() and 0xFF) shl 8) or
                (data[offset + 1].toInt() and 0xFF)
​
            if (offset + 2 + length > data.size) break
​
            frames += data.copyOfRange(offset + 2, offset + 2 + length)
            offset += 2 + length
        }
​
        receiveBuffer.reset()
        if (offset < data.size) {
            receiveBuffer.write(data, offset, data.size - offset)
        }
        return frames
    }
}

这段代码本身没问题,很多时候也确实必须写。但它也说明了一件事:你已经在 BLE 之上额外搭了一层"仿通道传输层"。如果这种代码在项目里越来越多,就要回头问一句,自己是不是本来就在解决一个更接近 SPP 的问题。

一个简单判断:你的协议是在"读状态",还是在"跑会话"

实际选型时,有个很好用的思路,不用先去背一堆协议差异,先看自己的业务到底是在做什么。

如果设备连接后主要干这些事:读几个字段,写几个命令,订阅几个状态变化,平时大部分时间都很安静,只在有事件时才通知,那么这更像 BLE。

如果设备连接后主要干这些事:持续收传感器数据,实时推语音或日志,不断发送控制帧,双方长期维持一条活跃的收发链路,那么这更像 SPP。

这两种系统最大的差别,不是单次包有多大,而是会话的重心不一样。

BLE 更像"我连接你,是为了访问一组有语义的能力点"。 SPP 更像"我连接你,是为了拿到一条字节流通道"。

一旦你把这个区别想清楚,很多选型争议就会突然变简单。

别把"BLE 更流行"误当成"BLE 更适合"

这几年 BLE 的存在感太强了,以至于很多人默认它就是 Android 蓝牙开发的主路。这个判断对很多 IoT 和低功耗设备来说确实没问题,但它不应该被无差别套用到所有项目上。

有些业务真的天然适合 BLE。比如配网、绑定、状态同步、轻量控制、低功耗传感器上报。这些场景如果用 BLE,模型清晰,资源消耗也合理。

但有些业务从第一天开始就更像串口通道。比如设备调试、持续流式采集、频繁长帧收发、沿用现有 UART 协议做无线替换。如果这种场景还坚持用 BLE,最后大概率不是不能做,而是越做越贵。

贵在哪里?贵在复杂度不是业务本身需要的,而是通信模型错配之后额外长出来的。你写的很多代码,并不是在实现业务,而是在替一个不太匹配的选型擦屁股。

真正值得在立项阶段问清楚的两个问题

第一个问题是:设备真的需要低功耗到 BLE 这种程度吗?

如果你的设备本身就不是那种要长待机、极度省电的小型外设,而是更强调连接后的一段稳定工作时长,那功耗未必是压倒一切的因素。很多团队一提蓝牙就先想低功耗,这个起点本身就容易带偏选型。

第二个问题是:连接建立之后,通信的主体到底是"状态同步"还是"数据流通道"?

这个问题比"能不能传""兼不兼容""API 熟不熟"都更重要。因为它直接决定你是应该沿着 characteristic 去设计协议,还是应该沿着 frame stream 去设计传输。

如果这两个问题都更偏向数据流,那就别因为 BLE 更热门就硬选 BLE。反过来,如果业务本身很轻,设备又真的在意功耗,那 SPP 也没有必要硬上。

结尾

什么时候该用 BLE,什么时候该用 SPP?

最稳妥的答案不是背定义,也不是看流行趋势,而是先判断你的业务通信到底更像什么。

如果你要的是低频、轻量、状态驱动、功耗敏感的交互,BLE 通常是更自然的选择。它的优势不只是省电,更在于它提供了一套很适合结构化设备能力暴露的模型。

如果你要的是持续连接、高频收发、字节流通道、串口式协议承载,那 SPP 往往更顺。它不会替你解决所有问题,但至少它的通信模型不会和你的业务天然拧着来。

很多 Android 蓝牙项目后面越写越痛苦,问题不一定在代码质量,也不一定在 Android 系统本身,而是在一开始就拿 BLE 去做了一个本来更像 SPP 的系统。

真正贵的错误,不是 BLE 没写好,而是方向选错以后,还在那个方向上持续投入正确的努力。

相关推荐
donecoding2 小时前
Monorepo 里有 app 也有共享包,lerna 真的还需要吗?
前端·node.js·前端工程化
非凡ghost2 小时前
视频下载神器:直播回放、视频链接一键抓取,还能自动监听!
java·前端·javascript·音视频
用户游民2 小时前
Flutter GetX实现原理
前端·flutter
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_25:(数字音频概念完全解析)
前端·ui·html·edge浏览器·媒体
镜宇秋霖丶3 小时前
常驻大哥24分法,记得看
前端·javascript·vue.js
小赵同学WoW3 小时前
JS 核心之执行上下文详细解释
前端·javascript
心连欣3 小时前
跨越时代的对话:Vue 2 与 Vue 3 的终极对决与环境搭建指南
前端·javascript·vue.js
Csvn3 小时前
Vue Router 实战
前端·vue.js
IT_陈寒3 小时前
JavaScript实战技巧总结
前端·人工智能·后端