1. 项目缘起与整体设计思路蓝牙温度贴这类设备最早是在一些需要连续监测体温的场景里进入我视野的。它的形态很简单一枚硬币大小的贴片贴在身上之后通过低功耗蓝牙持续广播温度数据手机端接收并解析。相比传统的水银体温计或者耳温枪它的价值不在于单次测量的精度有多高而在于“连续”和“远程”——你可以把它贴在人身上然后隔着一两米甚至更远的地方用手机实时看到温度曲线。我接触这个项目是因为一个朋友的需求家里有需要长期关注体温变化的情况手动每隔一小时量一次实在折腾于是想看看能不能自己写个App把温度贴的数据读出来做成曲线记录。市面上虽然有配套的官方App但数据导出和自定义报警都不太灵活。这个需求其实很典型——蓝牙温度贴的数据获取核心就是BLE低功耗蓝牙的GATT通信。先把这个项目的整体思路讲清楚。蓝牙温度贴本质上是一个BLE从设备Peripheral它对外暴露若干个GATT服务Service每个服务下面有若干特征值Characteristic。手机作为中心设备Central去连接它然后订阅或者读取特定的特征值就能拿到温度数据。整个流程拆开来看是这么几步扫描广播、建立连接、发现服务、找到温度特征值、订阅通知、解析数据、断开连接。听起来不复杂但真正动手的时候坑主要集中在“怎么找到正确的特征值UUID”和“数据怎么解析”这两块。为什么选择Android平台来做一方面Android的BLE API相对开放BluetoothGatt这套接口虽然用起来啰嗦但可控性强另一方面Android手机普及率高调试方便。iOS的CoreBluetooth虽然更简洁但封闭性更强某些自定义服务的数据读取反而受限。所以这个项目定位在Android端用Kotlin或者Java都可以我下面会以Kotlin为主来写因为协程处理BLE这种异步回调密集的场景确实舒服很多。这个内容适合谁看如果你已经了解Android基础开发知道Activity、权限申请这些概念但对BLE通信不太熟悉那这篇正好。如果你是完全的新手建议先把Android的权限机制和回调机制补一补否则BLE的回调嵌套会让你头晕。另外做物联网、健康监测、智能硬件对接的同行这里面的GATT解析思路是通用的换成其他BLE设备也一样适用。整体设计上我采用的是分层架构底层一个BleManager负责所有蓝牙操作中间一层DeviceDataParser负责数据解析上层UI只负责展示和交互。这样做的原因是BLE的回调非常零散如果全部写在Activity里代码会变成一团乱麻。分层之后底层可以独立测试解析逻辑也可以单独验证排查问题时能快速定位是通信层的问题还是解析层的问题。注意蓝牙温度贴这类设备通常使用自定义的GATT服务而不是标准的健康温度计服务Health Thermometer Service。标准服务的UUID是0x1809但很多厂商为了自己的数据格式和功能扩展会用私有UUID。所以第一步永远是“摸清设备到底暴露了哪些服务”。2. 蓝牙温度贴的核心技术点拆解2.1 BLE通信的基本模型与温度贴的对应关系BLE的通信模型用一句话概括中心设备扫描到从设备的广播发起连接然后读写从设备上的特征值。温度贴作为从设备它的广播包里通常包含设备名称、MAC地址、以及一些厂商自定义数据。连接之后它会暴露一个或多个服务温度数据就藏在某个服务的某个特征值里。这里要区分两个概念广播数据和连接后数据。有些温度贴会在广播包里直接携带当前温度这样甚至不需要连接就能读到非常省电。但大多数温度贴为了数据安全和完整性会选择在连接后通过Notify通知的方式推送数据。我手上这个设备就是后者——广播包里只有设备标识温度必须连接后订阅才能拿到。GATT的层级结构是这样的Profile Service Characteristic Descriptor。温度贴一般会有一个主服务里面包含几个特征值一个用于接收温度通知Notify一个用于写入配置Write可能还有一个用于读取电量或设备信息Read。每个特征值都有唯一的UUID16位的短UUID通常用于标准服务128位的长UUID用于厂商自定义。2.2 关键UUID的识别与匹配策略这是整个项目最核心也最耗时的一步。厂商文档通常不会给你或者给得很含糊。我的做法是先写一个“服务发现”的调试工具把设备所有的Service和Characteristic的UUID全部打印出来然后根据特征值的属性Property来推断哪个是温度数据。特征值的属性有几种Read、Write、Notify、Indicate。温度数据通常是Notify因为设备需要主动推送。配置参数通常是Write因为手机需要下发指令。只读的可能是设备信息。所以看到Notify属性的特征值基本可以锁定是数据通道。但这里有个坑有些设备有多个Notify特征值比如一个推温度一个推电量一个推设备状态。这时候就要靠UUID的规律来判断或者逐个订阅然后观察数据变化。我当时的做法是全部订阅然后打印原始字节看哪个特征值的数据会随着温度变化而波动。实操心得不要迷信网上的UUID列表。不同批次的温度贴可能用不同的UUID甚至同一批次不同固件版本都会变。最可靠的方法永远是自己扫描一遍把UUID和属性记录下来做成一个映射表。2.3 数据解析从原始字节到摄氏度拿到Notify的原始字节之后下一步是解析。温度贴的数据格式没有统一标准常见的有几种IEEE-754浮点数4个字节直接按float解析。定点数比如2个字节高字节整数部分低字节小数部分或者整体除以10、除以100。BCD编码每个字节表示两位十进制数。我遇到的这个设备用的是小端序的16位有符号整数单位是0.01摄氏度。也就是说收到两个字节[0x2C, 0x09]小端序组合成0x092C十进制是2348除以100就是23.48摄氏度。这个格式在国产温度贴里很常见因为整数运算比浮点省资源。解析的时候要注意字节序。BLE默认是小端序Little-Endian但有些厂商会按大端序发。判断方法很简单如果解析出来的温度明显不合理比如几百度或者负数就换个字节序试试。另外要注意有符号和无符号的区别零下温度需要用有符号整数来解析。2.4 连接参数与功耗的平衡BLE连接有几个关键参数连接间隔Connection Interval、从设备延迟Slave Latency、监督超时Supervision Timeout。连接间隔决定了数据传输的频率单位是1.25毫秒。比如连接间隔设为80就是100毫秒通信一次。温度贴这种设备数据变化慢不需要太高的通信频率。连接间隔设大一点比如500毫秒到1秒可以显著降低功耗。但设太大又会导致数据延迟所以要根据实际需求权衡。我一般设连接间隔为200到500毫秒既能保证数据及时性又不会太费电。还有一个细节MTU最大传输单元。默认MTU是23字节实际可用载荷是20字节。温度数据一般很短20字节足够。但如果设备一次推送多个数据包可能需要请求更大的MTU。Android的requestMtu()方法可以协商MTU但要注意不同手机的支持程度不一样。3. 完整实操流程与关键环节实现3.1 权限申请与蓝牙初始化Android的蓝牙权限在版本之间变化很大。Android 12API 31之前需要BLUETOOTH和BLUETOOTH_ADMIN权限Android 12及之后需要BLUETOOTH_SCAN和BLUETOOTH_CONNECT而且这两个是运行时权限必须动态申请。另外扫描BLE设备还需要位置权限因为蓝牙扫描可以用来推断位置这是系统层面的限制。// AndroidManifest.xml 中的权限声明 uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 /neverForLocation这个标志很重要它告诉系统你的扫描不是为了推断位置这样在Android 12以上就不需要位置权限了。但前提是你的扫描结果确实不用于位置推断否则会被系统过滤掉部分设备。初始化蓝牙适配器val bluetoothManager getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val bluetoothAdapter bluetoothManager.adapter if (bluetoothAdapter null || !bluetoothAdapter.isEnabled) { // 提示用户开启蓝牙 val enableBtIntent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) }注意从Android 13开始ACTION_REQUEST_ENABLE这个Intent在某些设备上行为有变化建议直接用BluetoothAdapter.enable()配合权限检查或者引导用户去系统设置里手动开启。3.2 扫描与设备过滤扫描BLE设备用BluetoothLeScanner.startScan()。扫描有两种模式低功耗模式和低延迟模式。低功耗模式扫描频率低省电但发现设备慢低延迟模式发现快但费电。调试阶段用低延迟模式正式使用可以用低功耗模式。val scanner bluetoothAdapter.bluetoothLeScanner val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() val filters listOf( ScanFilter.Builder() .setDeviceName(Temperature Patch) // 根据实际设备名称过滤 .build() ) scanner.startScan(filters, settings, scanCallback)扫描回调里拿到ScanResult里面包含设备对象、RSSI信号强度、广播数据。我一般会把扫描到的设备去重后展示在列表里让用户选择。温度贴通常信号强度在-60dBm以内比较稳定太远会断连。这里有个经验扫描不要一直开着。Android系统对扫描有频率限制连续扫描超过一定时间通常是30分钟会被系统静默停止。所以扫描到目标设备后应该立即停止扫描连接成功后再按需扫描。3.3 建立连接与发现服务连接用device.connectGatt(context, false, gattCallback)。第二个参数autoConnect设为false表示直接连接设为true表示等待设备可用时自动连接。调试阶段用false因为true的行为不太可控。连接状态的回调在BluetoothGattCallback里private val gattCallback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { // 连接成功开始发现服务 gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { // 连接断开清理资源 gatt.close() } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { // 遍历所有服务和特征值 for (service in gatt.services) { Log.d(TAG, Service: ${service.uuid}) for (characteristic in service.characteristics) { Log.d(TAG, Characteristic: ${characteristic.uuid}, properties: ${characteristic.properties}) } } } } }onServicesDiscovered是关键时刻这里打印出来的UUID列表就是你的“藏宝图”。把Notify属性的特征值记下来那就是温度数据的通道。实操心得有些设备在连接后需要先写入一个“使能”指令才会开始推送数据。这个指令通常是写入某个Write特征值内容可能是0x01或者一串厂商自定义的握手数据。如果订阅了Notify但一直没数据先检查是不是漏了这一步。3.4 订阅通知与数据接收找到温度特征值后通过setCharacteristicNotification开启本地通知然后写入CCC DescriptorClient Characteristic Configuration来告诉设备开始推送。// 开启本地通知 bluetoothGatt.setCharacteristicNotification(tempCharacteristic, true) // 写入CCC Descriptor val cccDescriptor tempCharacteristic.getDescriptor( UUID.fromString(00002902-0000-1000-8000-00805f9b34fb) ) cccDescriptor.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE bluetoothGatt.writeDescriptor(cccDescriptor)数据到达的回调override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { val rawData characteristic.value val temperature parseTemperature(rawData) // 更新UI或存储 }解析函数fun parseTemperature(data: ByteArray): Float { // 小端序16位有符号整数单位0.01摄氏度 val raw (data[1].toInt() shl 8) or (data[0].toInt() and 0xFF) val signed if (raw 32767) raw - 65536 else raw return signed / 100.0f }这个解析逻辑要根据实际设备调整。如果数据是4字节浮点就用ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).float。如果是BCD编码就逐字节转十进制。3.5 数据存储与曲线展示拿到温度数据后我一般用Room数据库存起来字段包括时间戳和温度值。展示曲线用MPAndroidChart或者自己画Canvas。这里不展开UI细节重点说数据层每次收到Notify就插入一条记录同时更新UI。为了避免频繁写数据库可以做一个缓冲比如每10条或者每5秒批量写入一次。温度贴的数据频率通常不高几秒到几十秒一次所以直接写入问题不大。但如果设备推送很频繁就要考虑用Flow或者LiveData做节流。4. 常见问题与排查技巧实录4.1 连接失败与断连问题排查BLE连接失败的原因很多我整理了一个排查表现象可能原因排查方法扫描不到设备设备未广播/距离太远/权限不足检查设备是否在广播状态靠近到1米内确认权限已授予连接返回133错误GATT内部错误常见于连接参数不兼容重试连接或先调用gatt.close()再重新连接连接后立即断开设备已被其他手机连接/电量不足确认设备未被占用检查电量服务发现为空连接不稳定/设备需要认证重新连接或检查是否需要配对绑定订阅后无数据未写入使能指令/CCC写入失败检查是否遗漏握手步骤确认CCC描述符写入成功133错误是Android BLE开发中最常见的坑。它的官方解释是“GATT_ERROR”但实际原因可能是连接间隔不兼容、设备端拒绝、或者Android蓝牙栈的内部状态异常。我的经验是遇到133先别慌断开重连通常能解决。如果反复出现就要检查连接参数是否合理或者设备是否已经被其他中心设备连接。4.2 数据解析异常的定位方法数据解析错了表现是温度值明显不合理。排查步骤打印原始字节先把characteristic.value的十六进制打印出来看看数据长度和内容。确认字节序小端序和大端序都试一遍看哪个结果合理。确认单位除以10、除以100、还是直接是整数根据数值范围判断。人体温度在35到42度之间如果解析出来是350到420那就是除以10如果是3500到4200那就是除以100。确认符号零下温度需要按有符号解析如果设备支持零下测量要注意这一点。避坑技巧有些设备在Notify的第一次会发送一个“配置确认”包不是温度数据。解析时要判断数据长度比如温度数据固定2字节那长度不对的就跳过。4.3 多设备连接与资源管理一个手机同时连接多个温度贴是可以的但Android的BLE连接数有上限通常是6到8个具体看手机型号。连接多个设备时每个设备都需要独立的BluetoothGatt实例和回调。资源管理上要注意不用的时候一定要调用gatt.close()否则会泄漏连接导致后续连接失败。另外BluetoothGatt的操作是串行的同一个Gatt实例上不能同时发起多个操作。比如你正在写Descriptor就不能同时读Characteristic。所以如果有多个操作要排队执行。我一般用一个队列来管理Gatt操作确保一次只执行一个。4.4 后台运行与电量优化温度贴需要长时间监测App可能在后台运行。Android 8.0之后对后台蓝牙扫描有限制需要用前台服务Foreground Service来保持扫描和连接。前台服务需要显示一个通知这是系统要求。电量优化方面除了前面说的连接间隔调大还可以在屏幕关闭时降低数据处理频率比如把数据先缓存等屏幕亮了再批量更新UI。另外如果设备支持可以在不需要实时数据时主动断开连接需要时再重连。实操心得我试过在后台保持连接一整天电量消耗大概在10%到15%左右主要取决于连接间隔和数据频率。如果把连接间隔从200毫秒调到1秒电量消耗能降到5%以下。所以如果对实时性要求不高连接间隔尽量设大。5. 进阶优化与扩展思路5.1 数据校准与温度补偿温度贴贴在皮肤上测的是体表温度和核心体温有偏差。有些设备会提供校准参数或者通过算法补偿。如果设备没有提供可以自己做一个简单的线性校准用标准体温计同时测量记录几组数据拟合一个偏移量。比如体表温度比核心温度低0.5度那就在解析后加0.5。但要注意体表温度和核心温度的偏差不是固定的受环境温度、贴的位置、个人体质影响。所以校准只能作为参考不能完全依赖。5.2 断线重连与数据补传BLE连接不稳定是常态断线重连是必备功能。我的做法是监听onConnectionStateChange断开后延迟几秒自动重连重连次数有限制比如5次超过就提示用户。重连成功后重新发现服务、重新订阅。数据补传方面有些温度贴内置存储断线期间的数据会在重连后补发。如果设备支持可以在重连后读取历史数据特征值。如果不支持那就只能接受断线期间的数据缺失。5.3 多平台兼容性考虑不同Android手机的BLE实现有差异尤其是国产ROM对后台权限和蓝牙扫描的限制各不相同。测试时至少要覆盖几个主流品牌。另外Android版本差异也要考虑API 31以上的权限模型和之前完全不同代码里要做好版本判断。如果要做iOS版本CoreBluetooth的API设计不同但GATT的概念是一样的。UUID和解析逻辑可以复用只是连接和回调的写法要重写。6. 个人实操体会与建议这个项目做下来最大的感受是BLE开发的门槛不在代码而在调试。代码框架是固定的但每个设备都有自己的“脾气”。UUID不公开、数据格式不标准、连接参数不兼容这些问题只能靠耐心调试来解决。我的建议是动手之前先花时间做“侦察”写一个最简单的扫描和连接Demo把设备的所有服务和特征值打印出来把Notify的数据原始字节记录下来。这个侦察阶段可能占整个项目一半的时间但它是后续所有工作的基础。侦察做好了后面的解析和展示就是水到渠成的事。另外不要怕133错误。我一开始遇到133就以为是代码写错了后来发现这是Android BLE的“家常便饭”。断开重连、调整连接参数、换台手机测试大部分问题都能解决。真正需要担心的是设备端的问题比如固件bug或者硬件故障那就要联系厂商了。最后分享一个小技巧如果设备有官方App可以用手机抓包工具比如Android的HCI日志看看官方App是怎么和