简介:BleSolution.zip 是一份面向 C# 开发者的 BLE 4.0 低功耗蓝牙通信解决方案,适合在物联网场景中需要与健康监测器、智能手表、智能家居等设备交互的 Windows 桌面或移动应用开发者,尤其适合具备一定 C# 基础、希望快速上手 BLE 开发的中高级程序员。资源包含 46 个文件,压缩包仅 1.87MB,以 14 个 C# 源码文件为核心,涵盖 BleCore、BluetoothLECode、BleEnum 等模块,另有 4 个可执行程序、4 个配置文件、2 个 DLL 和 Windows 运行时元数据(winmd),并附带完整 sln/csproj 工程文件,目录结构清晰,可快速打开编译。已有 5815 人学习浏览,适合作为 BLE 开发的实战参考。通过研究代码,可以掌握从 BluetoothLEDevice.FromIdAsync 获取设备、ConnectGattAsync 建立连接,到遍历 GattServices 发现服务与特征、使用 ReadValueAsync/WriteValueAsync 读写数据、订阅特征通知的完整流程,并学习如何封装便捷 API,规避异步处理和权限配置中的常见问题,从零开始理解 GATT 服务模型。
1. 解压之前:先弄清楚BleSolution在解决什么问题
BleSolution.zip 这个压缩包,第一眼看上去就是个普通的工程打包,解压完才知道这里面是一整套蓝牙低功耗(BLE)开发的解决方案。它不只是一个 Android Demo,而是把 BLE 开发里最常遇到的“扫描慢、连接断、数据乱”这三个老大难问题,连同代码封装、协议设计、调参经验一起打包了。如果你正在做智能硬件 App 联调、运动健康设备接入,或者手上有个设备要通过 BLE 定期上报数据,这个方案基本覆盖了“从扫描到稳定通信”的全流程。
有人可能会问:系统不是已经把 BLE API 封装好了吗,直接调不就行了?实际做过就知道,裸调 API 只能保证流程走通,走通不等于走得稳。真正的坑都在流程之外:扫描过滤条件怎么写才能又快又准,连接成功之后是不是马上就能传数据,连接参数怎么调才能兼顾功耗和稳定性,超过 MTU 的大包怎么安全地拆开再拼回去。这套方案做的工作,就是把上面这些“经验层”的东西沉淀成可以直接用的代码。
对新手来说,它是一份能抄作业的模板,按 README 里的步骤跑一遍,一套能用的 BLE 通信链路就出来了;对老手来说,它的价值在于分包协议和连接参数那部分,可以直接拿来对比自己的项目有没有埋伏雷。我建议解压后先别急着编译,花十分钟看一下工程结构和文档,弄清楚每一层在干什么,后面调起 Bug 来会顺手很多。
1.1 压缩包里的工程结构长什么样
解压后目录是这样的:
BleSolution/ ├── app/ │ └── src/main/java/com/example/blesolution/ │ ├── BleClient.kt // 对外统一入口,封装扫描连接收发 │ ├── BleScanner.kt // 扫描策略管理 │ ├── BleConnectionManager.kt // 连接生命周期与回调分发 │ ├── BlePacketProtocol.kt // 分包/拼帧协议 │ └── BleConstants.kt // UUID、服务、特征定义 ├── hardware/ │ └── peripheral_firmware_example/ // 对端固件示例工程 └── README.md这个结构想表达一件事:BLE 不能只写手机端。对端固件的行为——广播间隔、连接参数、特征值定义——会直接决定手机端能不能稳定工作。我见过太多项目只改了 App,却不知道设备固件用的是默认的 20 字节 MTU、默认的广播间隔,结果手机端怎么做都优化不上去。所以这个包里除了 Android 工程,还附带一个简单的固件示例,方便你对着改对端来配合调试。
BleConstants.kt 里集中放了服务 UUID、特征 UUID、分包协议的帧头定义。这个习惯很重要,BLE 联调中有一大半的沟通成本来自“双方 UUID 对不上”“字段定义不一致”,把它们集中到一个文件里,至少你自己这边不会乱。等跟硬件同事对接时,直接把这份文件发过去,比口头传话高效得多。
1.2 为什么统一封装一层会更可靠
BleSolution 的核心思路,是在系统 BLE API 上面包了一层自己的客户端。这层封装的目的不是炫技,而是把流量控制和状态管理收拢到一个地方。比如扫描回调、连接状态回调、特征值变化回调,系统 API 会把这些回调抛到不同的线程和逻辑里,如果不统一收口,业务层很容易出现“回调里做耗时操作”或者“状态没同步”的竞态问题。
封装之后,业务层只需要面向 BleClient 这个门面编程,连接、断开、发数据、收数据都是同步语义的方法,底层是线程切换还是回调拼接,对调用方不可见。这样还有一个额外好处:后续如果想从 Android 切到 iOS,或者换一套底层库,业务代码的改动面会小很多。这个道理跟做后端时封装数据访问层是一样的——底层实现可以换,上层的业务逻辑不该跟着频繁重写。
2. 核心链路拆解:扫描、连接、找服务、读特征、收通知
BLE 通信听起来很玄,但一条数据从设备到手机,走的链路其实是固定的:先扫描到设备,然后发起连接,连接建立后去发现服务和特征,最后往特征里写数据或者订阅通知。BleSolution 里的核心代码基本就是沿着这条链路组织的,下面按阶段拆开讲,每个阶段都结合代码说为什么要这么写。
2.1 扫描不是越久越好,过滤和回调要一起设计
扫描阶段最容易犯的错误是把startScan的ScanCallback写得很大,觉得回调越多越好。实际上扫描回调是高频回调,同一台设备可能几十毫秒就广播一次,回调里一旦做了耗时操作,主线程直接卡顿掉帧。正确做法是先用过滤条件把范围缩小,再在回调里做轻量处理。
val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() val filters = listOf( ScanFilter.Builder() .setServiceUuid(ParcelUuid(SERVICE_UUID)) .build() ) scanner.startScan(filters, settings, scanCallback)这里有两个细节。第一个是setScanMode,LOW_LATENCY模式扫描窗口长,结果速更快,适合冷启动后快速发现设备;如果做后台常驻扫描,建议用LOW_POWER,不然耗电极快。第二个是ScanFilter,用服务 UUID 过滤能挡掉一屋子无关设备,这在现场有几十个 Beacon 同时广播的环境下特别有用。实测下来,加了 UUID 过滤之后,从扫码到列表出现设备的时间通常能压到两三秒以内,远比全量扫描再自己过滤要稳。
扫描回调里建议只做一件事:把设备名和 MAC 地址塞进一个列表,等用户点击后统一处理。不要在回调里直接弹窗、跳页面,那是灾难。
2.2 连接之后先聊 MTU,别急着收发数据
很多联调现场的第一步卡在“连上了,但数据发不出去”,或者“发小包没事,发大包就丢”。这大概率是 MTU 没有协商。
BLE 的默认 MTU 是 23 字节,扣掉 ATT 头,单包有效载荷只有 20 字节。如果你的业务数据超过 20 字节,要么自己分包,要么先把 MTU 协商上去。Android 5.0 之后可以通过requestMtu发起协商,建议连接成功并发现服务后立刻调用:
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothGatt.STATE_CONNECTED) { gatt.requestMtu(247) } }然后等onMtuChanged回调,里面会回传实际协商结果。这里要注意,requestMtu(247)只是请求,外设端如果不支持大 MTU,协商结果可能还是 23。所以业务层拿到的 MTU 值必须来自回调,不能想当然用请求值。
一个容易忽略的点:协商 MTU 的时机。如果在连接后立刻读写特征,这时候 MTU 可能还没协商完成,数据还是会按 20 字节拆包,行为会表现成“写入成功但对方收不全”。稳妥的顺序是:连接成功 → 发现服务 → 请求 MTU → MTU 回调返回 → 再开启通知或读写,也就是把数据链路相关的动作都排在 MTU 协商之后。
2.3 通知订阅:把数据流从“拉”改成“推”
设备上报数据,有两种模式:一种是你主动去读特征值(Read),一种是设备主动往通知特征里推(Notify/Indicate)。绝大多数硬件上报场景用的都是后者,因为主动轮询既慢又费电。
订阅通知在 Android 上是一个两步操作,代码长这样:
gatt.setCharacteristicNotification(characteristic, true) val descriptor = characteristic.getDescriptor(CCCD_UUID) descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(descriptor)这里有个非常经典的新手坑:只调了setCharacteristicNotification,没有写 CCCD 描述符,结果设备端不推数据过来。前者是告诉手机本地接收这个特征的通知,后者是真正往设备端写值,告诉对端“我开始订阅了”。两步都完成之后,设备端才会真正把数据推下来。
订阅完成后,数据会陆续出现在onCharacteristicChanged回调里。这个阶段如果发现数据乱、收不全,不要怀疑回调丢了,大概率是分包和拼帧的问题,这部分在第四节展开讲。
3. 功耗与稳定性之间的隐性三角:连接间隔、从机延迟和超时时间
围绕 BLE 通信稳定性,绕不开三个连接参数:连接间隔(Connection Interval)、从机延迟(Slave Latency)和超时时间(Supervision Timeout)。很多人把它们当默认配置,能连就行,但项目的坑往往就藏在这三个参数里。它们的关系可以用一句话概括:连接间隔决定“多久说一次话”,从机延迟决定“没话说时可以跳几次”,超时时间决定“多久没说话就判定断开了”。
3.1 三个参数各自的物理含义和数量级
先把量级说清楚。连接间隔的单位是 1.25ms,允许范围是 6 到 3200,对应时间就是 7.5ms 到 4 秒。常用的值是 30ms 或 50ms,也就是主从设备每 30 到 50 毫秒同步一次时钟并交换数据。
从机延迟的单位是“个连接事件”,允许范围是 0 到 499。为 0 表示每个连接事件都必须参与,为 2 表示可以跳过 2 个连接事件再到,这样能显著降低从机功耗,但代价是数据延迟变大。
超时时间的单位是 10ms,允许范围是 100ms 到 32 秒。它有一个硬性的约束公式:
超时时间 > (1 + 从机延迟) × 2 × 连接间隔如果主从两端在这一项上算出来的值不满足条件,连接就会在几秒内被判定为超时断开。这个公式是协议栈里的底层要求,不少诡异断连问题查到最后都是这条没满足。
3.2 一套从“能连上”到“连得稳”的参数调整流程
我自己的习惯是,拿到一套新设备,先按默认参数跑通链路,然后分两轮调参。
第一轮追求实时性。比如做运动手环、车钥匙这种对延迟敏感的产品,我倾向于把连接间隔设到 30ms,从机延迟设为 0,超时时间设 2 秒。这种组合的优点是数据延迟低,按下按键到手机收到反馈基本感觉不到延迟;缺点是从机每一两个连接事件都要醒着,耗电明显。
第二轮追求续航。像温湿度传感器、门锁状态上报这种低频场景,把连接间隔调到 50ms,从机延迟调到 4,超时时间设到 6 秒以上。这样设备大部分时间可以睡过去,数据延迟增加几十毫秒到几百毫秒,但续航可能从“一周一充”变成“一个月一充”。
调参时需要和硬件同事配合,因为从机端固件里也有对应的参数配置。很多时候手机端发起请求,对端固件不响应,反过来也一样,得两边一起改。这也是为什么我在开头强调,这个包附带固件示例的原因——只调手机端,效果会大打折扣。
3.3 连接参数修改失败:Android 和 iOS 的不同表现
Android 做主机时,可以通过requestConnectionPriority请求调整连接参数,但它不像 MTU 那样可以指定任意值,只有三档:CONNECTION_PRIORITY_LOW_POWER、CONNECTION_PRIORITY_BALANCED、CONNECTION_PRIORITY_HIGH。如果你需要精确调到某个值,Android 这一端是没有办法直接指定的,得靠对端固件主动发起连接参数更新请求,Android 侧被动接受。
iOS 这边的机制更明显:作为中心设备时,iOS 不允许 App 主动修改连接参数,连接参数的更新只能由外设端发起,iOS 的 CoreBluetooth 在收到更新请求后会自动决定是否接受。所以如果你在做的是一个 iOS App 配合自研硬件,请务必在固件端实现连接参数更新请求,并且把参数设置在 iOS 能接受的范围内,否则你在手机上把代码翻出花来也没用。
另外,还有一个常见的“修改失败”原因:时机不对。在 GATT 发现服务还没完成时就调用requestConnectionPriority或requestMtu,协议栈会直接忽略请求。一定要等onServicesDiscovered之后再发起,这个顺序我踩过不只一次。
4. 实测里最容易翻车的三个问题:断连、分包和兼容性
代码写完了,链路走通了,真正的地方在联调现场。这一节记录我在实际使用 BleSolution 过程中反复遇到、排查过的三个高发问题,每个都是完整链路排查,不是直接给答案。
4.1 高频断连的完整排查链路
现象是:设备连接成功后,少则几秒,多则几十秒,就会自动断开。反复重连,反复断。
我的排查顺序是这样走的。第一步,先看onConnectionStateChange里的status值。Android 回调里的status是最直接的线索:133表示远端设备主动终止了连接,19表示连接被远端断开,8表示超时。如果日志里反复出现8,基本可以断定是连接超时,下一步直接查连接参数。
第二步,照着 3.1 的公式算一下超时时间和连接间隔、从机延迟的关系。我遇到过一个案例,对端固件把连接间隔设成了 100ms,从机延迟设成 6,超时时间只有 1 秒。按公式算(1 + 6) × 2 × 0.1 = 1.4 秒,已经超过 1 秒的超时阈值了,不断才怪。跟硬件同事确认固件参数后一改,断连立刻消失。
第三步,如果参数没问题,就要考虑环境干扰。BLE 跑在 2.4GHz,跟 Wi-Fi 同频段,现场如果路由器密集,很容易出现丢包重传导致的隐性断连。判断方法是用抓包工具长时间抓广播和连接事件,看看是不是有明显的丢包重传。
第四步,排查手机系统省电策略。这个问题国产 ROM 上特别明显,App 一旦切到后台,系统会主动休眠蓝牙或者杀掉连接。解决思路是使用前台服务保活,并且在应用内申请合理的后台运行权限。这一步跟 BLE 本身关系不大,但在实际项目里占了断连问题的相当比例。
4.2 超过 MTU 的大包如何分包和拼帧
默认 MTU 23 时的有效载荷只有 20 字节,就算协商到 247,最多也就 244 字节。如果你要传一组传感器校准数据或者升级包,几百上千字节几乎是必然的。
BleSolution 的做法是在应用层定义一套分包协议。大致思路是:发送方先把完整数据切成长度不超过 MTU 有效载荷的小块,每块加上帧头。帧头里包含帧类型、包序号、总包数,接收方收到后按包序号重组。
协议核心代码大概长这样:
class BlePacketProtocol(private val mtuPayloadSize: Int) { fun encode(data: ByteArray): List<ByteArray> { val totalPackets = (data.size + mtuPayloadSize - 1) / mtuPayloadSize return (0 until totalPackets).map { index -> val payload = data.copyOfRange( index * mtuPayloadSize, minOf((index + 1) * mtuPayloadSize, data.size) ) buildFrame(index, totalPackets, payload) } } fun decode(frames: List<ByteArray>): ByteArray { val sorted = frames.sortedBy { frameIndex(it) } val payloadSize = sorted.sumOf { framePayload(it).size } val result = ByteArray(payloadSize) var offset = 0 for (frame in sorted) { val payload = framePayload(frame) payload.copyInto(result, offset) offset += payload.size } return result } }这里要特别提醒:BLE 的数据包不保证按顺序到达,尤其多点并发传输时,后发先到很正常,所以协议里必须带包序号,接收端要按序号排序再拼帧。很多做串口出身的人习惯按顺序流式接收,在这上面容易吃大亏。
分包大小建议拿 MTU 回调的真实值减 3 算出来,比如协商到 247,单帧就取 244 字节。如果把分包大小写死在 20 字节,那 MTU 协商做得再好也白搭。
4.3 系统行为差异:从权限到回调都值得单独测
BLE 开发里最折腾人的是 Android 和 iOS 的行为差异,同一个硬件,两端表现完全不同。整理几个我踩过的:
第一个是权限模型。Android 12 之前只需要位置权限,Android 12 之后必须动态申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT,少了任一权限,扫描直接返回空。iOS 侧从 13 开始必须提供NSBluetoothAlwaysUsageDescription文案,否则直接崩溃。这些是硬门槛,打包前要专门检查。
第二个是回调线程。Android 的 BLE 回调默认跑在 Binder 线程,不是主线程,如果直接在回调里更新 UI 会崩溃。iOS 的 CoreBluetooth 回调在主线程,但主线程一卡,回调也会延迟。两边行为不一样,封装层最好统一把回调抛到业务指定的线程里,避免业务代码到处写runOnUiThread。
第三个是后台行为。iOS 在蓝牙后台模式下可以继续接收通知,但必须提前在 Capabilities 里打开Uses Bluetooth LE accessories。Android 这边,某些厂商深度定制的系统后台杀得狠,如果只是用 10 分钟就断开,先查这一条。
5. 从 demo 到业务:验证清单和扩展方向
代码能跑通,跟真正能上线,中间还隔着一整套验证流程。BleSolution 的方案拿来用之后,我通常建议团队先跑一遍下面这份清单,再决定要不要进入业务开发。我自己在实际使用中,每次改完连接参数或者分包逻辑,也都会拿这份清单回归一遍,防止新逻辑把老链路弄挂了。
5.1 五个可以自检的验收场景
- 冷启动场景:手机重启后第一次打开 App,从扫描到连接成功,时间应控制在 3 秒以内。超过 5 秒,可以怀疑扫描逻辑存在重复回调或者过滤条件过严。
- 连续收发场景:固定往设备端发 200 个 20 字节的小包,再发 50 个 244 字节的大包,统计丢包率。一步都不丢是不可能的,但小包丢包率应低于千分之一,大包要保证拼帧后完整还原。
- 静置场景:连接建立后不做任何操作,放在那里 30 分钟,看是否出现 Supervison Timeout。这个场景专门验证连接参数设置是否合理。
- 断线重连场景:手动关掉设备电源再打开,确认 App 能在 5 秒内发现断连并自动发起重连。重连逻辑一定要有退避策略,否则设备一回来就可能被十几条连接请求打挂。
- 前后台切换场景:App 切后台 10 分钟再切回来,确认连接状态是否正确,收数是否中断。这一步能暴露系统省电策略和后台服务是否正常工作。
5.2 按需扩展的方向
如果项目顺利跑过了验证阶段,后续扩展通常围绕三个方向。
第一个是吞吐量优化。如果要做 OTA 升级或者传输大文件,普通 BLE 4.0 的速度是远远不够的。办法是启用 BLE 5.0 的 2M PHY 和长包模式,在 Android 侧通过BluetoothLePhy相关接口设置,iOS 侧则可以在centralManager(_:didUpdateANCSAuthorizationFor:)周边能力里配合。这个扩展能把吞吐从几十 KB/s 拉到一两百 KB/s,但需要两端固件都支持,不是改 App 就能解决。
第二个是多设备连接。BleSolution 默认是单连接模型,但很多智能家居场景需要同时连接多个传感器。扩展思路是把BleConnectionManager改成用设备地址做 key 的复用池,每个设备一个独立 GATT 实例,同时注意 Android 系统并发连接数限制,以及回调线程和主线程的切换逻辑,避免多个 GATT 的回调交叉时状态互相污染。
第三个是配对与安全。如果设备涉及个人健康数据,建议增加配对绑定和加密链路。BLE 的配对过程涉及 LE Legacy Pairing 和 LE Secure Connections 两套机制,绑定之后双方会存储 Long Term Key,后续连接可以快速加密。但配对逻辑会增加研发量,且不同外设芯片支持程度差异大,适合项目进入量产阶段后再投入。
最后分享一个我个人的调试习惯:开发 BLE 最怕“设备灯亮了,功能却对不上”。每次拿到新的硬件,我一定会先在两个不同品牌的手机上跑同一套流程,再动固件代码。如果只能带一个工具,我会选抓包器,它能把广播、连接、断开的整个过程摊开在时间轴上,大多数连接异常看到波形就明白了。
本文还有配套的精品资源,点击获取