简介:面向Android开发者的BLE4.0通信实战Demo,以蓝牙低功耗标准在Android平台的完整交互流程为主线,涵盖设备扫描、Gatt连接、服务发现、特征值读写、通知订阅与断开重连等关键环节,适合初窥BLE开发、需要示例工程对照Google官方API的学习者。压缩包共56个文件,以Java源码、XML配置与布局、class字节码、PNG图片资源为主,另含可直接安装的APK与工程编译产物,整体约208KB,目录包含src、gen、bin、res等标准结构,便于按模块定位代码与资源。目前已有305人学习下载。内容基于BluetoothLeGatt示例改造,可重点参考扫描过滤器与ScanSettings的配置、BluetoothGattCallback各回调的处理方式,以及setCharacteristicNotification与writeDescriptor配合开启通知的完整写法;同时附有工程配置文件与lint检查项,帮助减少连接异常和功耗控制方面的常见坑点,为自研低功耗蓝牙应用提供可复用的代码骨架。
1. BLE 4.0 Demo是个什么水平的东西:别把跑通点亮当成蓝牙入门
这份BLE 4.0 Demo是一套把广播、连接、服务发现、数据回环四条链路全部打通的最小工程。市面上很多模块商的例程只让你看到广播灯在闪,真正把手机 App 和外设连起来收发数据时,才会发现协议栈里到处是坑。这套 Demo 的价值在于:外设端负责广播和 GATT 服务,Android 端负责扫描、连接、读写和订阅通知,两端代码可以对照着改。它适合第一次做蓝牙产品的嵌入式工程师,也适合要在 App 里对接第三方外设的移动端开发。你能通过改代码理解协议,而不是在文档里背协议。
2. 协议栈与工程结构:从GATT分层看懂服务、特征、UUID三段式的代码映射
2.1 GATT分层:Service、Characteristic、Descriptor在SDK里分别对应什么
BLE 4.0 的应用层交互几乎全部建立在 GATT(Generic Attribute Profile)之上。它的层级关系是:一个外设(Server)维护一个或多个 Service,每个 Service 下面挂若干 Characteristic,每个 Characteristic 可以带一个或多个 Descriptor。Characteristic 是真正承载数据的对象,它内部有一个 Value,读写和通知操作都作用在 Value 上;Descriptor 则是描述这个特征行为的元数据,最常见的 0x2902(CCCD)负责开关通知。
/* 典型的 GATT 服务结构体定义,SDK 内部通常长这样 */ typedef struct { ble_uuid_t service_uuid; /* 服务 UUID */ uint16_t handle; /* 服务句柄,SDK 分配 */ ble_gatt_char_t chars[MAX_CHARS]; /* 服务下的特征数组 */ } ble_gatt_svc_t; typedef struct { ble_uuid_t char_uuid; /* 特征 UUID */ uint16_t handle; /* 特征句柄 */ uint16_t value_handle; /* 特征值句柄 */ uint8_t properties; /* 读/写/通知/指示属性位 */ } ble_gatt_char_t;把结构体写成这样,是因为后面注册服务时,SDK 需要你提供完整的 UUID 和属性掩码。properties 字段值来自协议定义:0x02 表示可读,0x04 表示可写,0x10 表示支持通知,0x20 表示支持指示。调试时如果发现手机端读不到值,先去查外设端注册特征时这个属性掩码有没有置位。
2.2 广播报文:31字节怎么分配,才能装下设备名、服务UUID和厂商数据
BLE 4.0 的广播数据包在 ADV_IND 中最多携带 31 字节。这 31 字节不是自由格式,而是由若干个 AD Structure 组成——每个结构第一字节是长度(Length,含类型和数据的字节数),第二字节是类型(AD Type),之后才是真正的数据。这个容器非常挤,设备名、UUID、厂商自定义数据都往里塞,排布顺序和长度必须手动算清楚。
static void adv_data_init(uint8_t *adv_buf) { uint8_t idx = 0; /* AD Structure 1: Flags,长度固定3字节 */ adv_buf[idx++] = 0x02; /* Length = Type + Data 共2字节 */ adv_buf[idx++] = 0x01; /* AD Type: Flags */ adv_buf[idx++] = 0x06; /* LE General Discoverable, BR/EDR Not Supported */ /* AD Structure 2: 完整设备名 "BLE_DEMO" */ adv_buf[idx++] = 1 + 8; /* 1字节Type + 8字节名称 */ adv_buf[idx++] = 0x09; /* AD Type: Complete Local Name */ memcpy(&adv_buf[idx], "BLE_DEMO", 8); idx += 8; /* AD Structure 3: 128位服务UUID列表 */ adv_buf[idx++] = 1 + 16; /* Type + UUID 16字节 */ adv_buf[idx++] = 0x07; /* Complete List of 128-bit Service UUIDs */ idx += uuid_encode_128(&adv_buf[idx], CUSTOM_SERVICE_UUID); /* 剩余字节可用 0x00 填充 */ }这里有一个容易踩的细节:Length 字段的单位是"Type + Data 的总字节数",不包含 Length 自己那一字节。比如设备名字段,Type 占 1 字节、名称占 8 字节,Length 就是 9,写成1 + 8更直观。广播数据的解析是严格按长度边界走的,一旦 Length 写错,后面的 AD Structure 全部解析错位,就会出现手机能看到设备名但拿不到服务 UUID 的情况。
2.3 Demo文件结构:每个文件在连接链路里到底干哪一段
我拿到这套 Demo 的第一件事是先把文件职责理清楚。它没有把代码堆在一个 main.c 里,而是按 BLE 协议的层次拆开了,文件划分大致如下:
| 文件 | 职责 | 对应协议层 |
|---|---|---|
| main.c | 初始化时钟、按键、日志,启动协议栈 | 应用入口 |
| ble_stack_init.c | 协议栈注册、GAP 参数初始化、连接参数默认值 | LL / L2CAP |
| adv_config.c | 广播数据填充、广播参数(间隔/类型)设置 | GAP |
| custom_service.c | 自定义服务的 UUID、特征添加、回调注册 | GATT Server |
| gatt_evt_handler.c | 读、写、通知、MTU 更新事件分发 | GATT Application |
| 主机端 BLEManager.java | 扫描、连接、服务发现、CCCD 订阅 | GATT Client |
这个划分几乎就是照着协议栈的分层来切的。改广播相关内容时只动 adv_config.c,改业务逻辑时只动 custom_service.c。如果发现 Demo 代码里所有回调都堆在一个文件里,通常说明这个 Demo 只是为了跑通流程,做产品时要先把事件分发拆开。
3. 外设端搭建:广播参数、服务注册与回调函数的落地写法
3.1 广播初始化:间隔、类型、数据的完整参数列表
外设端广播参数的设置直接决定手机能不能发现设备、发现得多快、功耗多少。Demo 里初始化广播的逻辑可以拆成两部分:先填好广播数据(2.2 中的 adv_data_init),再配置广播参数并启动。
static void adv_start(void) { ble_gap_adv_params_t adv_params; memset(&adv_params, 0, sizeof(adv_params)); adv_params.adv_type = GAP_ADV_TYPE_ADV_IND; /* 可连接、非定向 */ adv_params.interval_min = 160; /* 间隔下限:160 * 0.625ms = 100ms */ adv_params.interval_max = 240; /* 间隔上限:240 * 0.625ms = 150ms */ adv_params.channel_mask = GAP_ADV_CHANNEL_ALL; /* 37/38/39 全开 */ ble_gap_adv_start(&adv_params); }注意广播间隔的单位不是毫秒,而是 0.625ms 的整数倍。示例里的 160 就是 100ms,240 是 150ms。间隔越短,手机越快扫到,但广播期间射频一直要醒来,平均电流会明显上升。我一般把开发调试阶段的间隔设到 100ms 左右,方便手机快速抓到设备;到了低功耗产品阶段再拉到 500ms 以上。需要特别注意GAP_ADV_TYPE_ADV_IND这个参数,它是"可连接非定向"广播。如果误配成ADV_NONCONN_IND,手电能扫到设备,但连接必然失败。
3.2 服务与特征:注册一个可读可写可通知的自定义UUID
服务注册是外设端最核心的部分。Demo 里做了一个自定义服务,下面挂了一个具备读、写、通知三种属性的特征。调用 SDK 的 GATT API 时,需要把服务 UUID、特征 UUID、属性掩码一次传对。
static ble_gatts_char_handles_t char_handles; static void custom_service_init(void) { ble_uuid_t svc_uuid; ble_uuid_t char_uuid; uint8_t uuid_type; /* 注册128位自定义服务UUID */ ble_uuid_decode_128(CUSTOM_SERVICE_UUID, &svc_uuid); ble_gatts_service_add(BLE_GATTS_SRVC_TYPE_PRIMARY, &svc_uuid, &svc_handle); /* 注册特征:可读 + 可写 + 通知 */ ble_uuid_decode_128(CUSTOM_CHAR_UUID, &char_uuid); ble_gatts_characteristic_add( svc_handle, BLE_GATT_CHAR_PROP_READ | BLE_GATT_CHAR_PROP_WRITE | BLE_GATT_CHAR_PROP_NOTIFY, &char_uuid, &char_handles); }注册完成后,SDK 会分配一个char_handles,其中包含特征句柄和值句柄。通知操作依赖值句柄——当外设端要主动推数据给手机时,SDK 会拿这个句柄去查当前有没有手机订阅了通知。常见错误是注册时漏掉BLE_GATT_CHAR_PROP_NOTIFY,结果手机端能读到值但永远收不到推送。建议每次注册完特征后,打印一下char_handles的值句柄,核对是否非零。
3.3 事件回调:写入数据进来之后,程序实际执行的路
数据链路最后能不能闭环,取决于事件回调是否挂在正确的位置。SDK 里 GATT 事件是个大回调,所有读写和通知事件都从同一个入口进入,Demo 的做法是解析事件类型后分发给对应的处理函数。
static void on_ble_gatts_evt(ble_evt_t *evt) { switch (evt->type) { case BLE_GATTS_EVT_WRITE: handle_write(evt->data.write.value, evt->data.write.len); break; case BLE_GATTS_EVT_READ: /* 把当前状态填充到读取缓冲区 */ fill_read_buf(); break; case BLE_GATTS_EVT_NOTIFY: /* 手机端订阅/取消通知时触发 */ break; } }写入事件里拿到的value和len就是手机端发过来的原始数据。这里需要注意:BLE 单个 ATT 包的体积默认只有 20 字节有效载荷(MTU 23 字节减去 3 字节 ATT 头),所以数据长度超过 20 字节时,接收端会连续收到多个 WRITE 事件,Demo 的 handle_write 里要自己拼包。我在这个 Demo 中采用的做法是定义一个 128 字节的环形缓冲区,收到一包就先暂存,协议帧尾标记到了再统一处理。
4. Android主机端:权限、扫描过滤与GATT读写的完整闭环
4.1 权限配置:从Location到BLUETOOTH_SCAN的演进
Android 端的 BLE 权限是新手翻车重灾区,因为不同系统版本要求完全不一样。Android 6 到 11 之间,扫描 BLE 设备需要ACCESS_FINE_LOCATION运行时权限,理由是其实现阶段延续了"蓝牙扫描可被用于推断位置"的安全策略。从 Android 12(API 31)开始,系统单独拆出了BLUETOOTH_SCAN和BLUETOOTH_CONNECT,扫码和连接权限分离,不再依赖定位权限。
<!-- AndroidManifest.xml 中按版本声明所需权限 --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />neverForLocation这个属性是 Android 12 新增的声明,它告诉系统"这个应用扫描蓝牙不是为了定位",有些商店审核会检查是否声明了它。要注意的是,即便声明了BLUETOOTH_SCAN,如果你的 App 仍然运行在 Android 11 以下的设备上,ACCESS_FINE_LOCATION依然需要运行时申请。逻辑上要按Build.VERSION.SDK_INT分两套权限请求流程走。
4.2 扫描与过滤:ScanFilter按UUID锁定目标设备
直接调用BluetoothLeScanner.startScan会把周围所有 BLE 设备都扫进来,回调里再看设备名去一个个挑,效率低而且容易误连。正确做法是构造ScanFilter,按服务 UUID 直接过滤。
val filter = ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString(CUSTOM_SERVICE_UUID)) .build() val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) /* 实时回调,不批量上报 */ .build() bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback)SCAN_MODE_LOW_LATENCY的回调频率最高,适合开发调试;正式产品里通常用SCAN_MODE_BALANCED,减少功耗。setReportDelay(0)表示每发现一个设备立刻回调,如果设成大于 0 的值,系统会攒一批结果再统一返回。扫描回调里也需要做去重逻辑——同一个设备可能被上报多次,我一般用设备地址作为 key 存一个 Set,只有首次发现才继续走订阅流程。
4.3 GATT连接与数据收发:从onServicesDiscovered到CCCD订阅
连接和读写不在同一个线程,所有 GATT 回调都来自系统 binder 线程。很多新手把BluetoothGatt对象存成全局变量,到处乱调,结果回调里拿到的 gatt 连接早已断开。Demo 里给出的做法是:连接回调里只做状态判断,发现服务成功后再拿 gatt 对象去取特征、写值、订阅。
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) return val svc = gatt.getService(UUID.fromString(CUSTOM_SERVICE_UUID)) val ch = svc.getCharacteristic(UUID.fromString(CUSTOM_CHAR_UUID)) /* 第一步:开启通知,让外设知道手机要收数据 */ gatt.setCharacteristicNotification(ch, true) /* 第二步:写CCCD描述符,值必须是0x0001 */ val cccd = ch.getDescriptor(CCCD_UUID) cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE gatt.writeDescriptor(cccd) } }CCCD_UUID是固定值00002902-0000-1000-8000-00805f9b34fb。订阅通知时"两步走"缺一不可:先调setCharacteristicNotification告诉本地 GATT 客户端开始拦截通知,再写 CCCD 描述符告诉对端"我已经准备好接收"。如果顺序反了或漏了一步,外设端根本不会开始推数据。还有一种情况:writeDescriptor的回调返回GATT_WRITE_SUCCESS,但通知仍然不来,多半是对端外设的代码里压根没实现通知事件发送。
5. 避坑记录:五个看起来正常却必翻车的BLE细节
5.1 扫描不到或连不上:广播标志与权限的双重约束
现象:手机 App 扫不到外设;偶尔扫到了,点击连接立即失败,状态回调返回 133(GATT_ERROR)或直接超时。
原因:外设端的广播类型设成了ADV_NONCONN_IND,这是不可连接广播,扫描能看到但系统层面拒绝建立连接。另一个原因是广播数据里 Flags 字段(AD Type 0x01)把 BR/EDR 位置成了 1,部分手机协议栈会因此拒绝走 LE 连接流程。权限问题则在 Android 12 以上表现为扫描回调没有任何结果,不报错,只是静默失败。
解决:把广播类型改成ADV_IND,Flags 置成0x06(LE General Discoverable + BR/EDR Not Supported)。Android 端检查:12 以上必须已授予BLUETOOTH_SCAN,11 以下必须已授予ACCESS_FINE_LOCATION,且定位服务开关要打开——部分国产 ROM 在定位关闭时会把蓝牙扫描结果一并滤掉。
5.2 通知收不到:CCCD没写0x0001等于白订阅
现象:外设端的写操作正常,手机端也能读特征值,但外设调用发送通知的 API 后,手机端 onCharacteristicChanged 就是不被回调。
原因:通知机制依赖一个约定——客户端必须在 CCCD 描述符里写入0x0001表示"我要开始监听"。这个值不会自动写入,必须开发者手动通过writeDescriptor设置。漏掉这一步是 BLE 开发中最常见的失误,没有之一。
解决:在onServicesDiscovered里先setCharacteristicNotification(ch, true),再写 CCCD。注意:如果外设端特征还支持指示(Indicate),则需要写0x0002,通知写0x0001,两者不能混。用 Wireshark 抓包看链路上有没有 Write Descriptor Request,一眼就能确认问题出在谁身上。
5.3 数据粘包与丢段:MTU=23字节下的分包真相
现象:手机一次写入 60 字节,外设端只收到 3 个 WRITE 事件,每个 20 字节,拼起来发现顺序错乱或丢段;有时外设发来 100 字节,手机端回调被截断。
原因:BLE 4.0 的 ATT 层默认 MTU 是 23 字节,减去 3 字节 ATT 头部,单包数据上限 20 字节。更长的数据在链路层会被拆成多个包发送。协议栈不会帮你重组,也不会保证多个 WRITE 事件在应用层的先后到达顺序与数据分片一致。
解决:通信双方要先明确一个自定义的分包协议,帧头里加序号和总长度。外设端每收一包就往缓冲区里对应位置写,收满一帧再整体交给业务逻辑。Android 端如果做主机,可以在服务发现完成后用requestMtu(247)把 MTU 拉大,但外设端必须支持更大 MTU,否则请求会被拒绝。
5.4 功耗异常:广播间隔、连接间隔、从机延迟的参数玄学
现象:外设用的纽扣电池,几天就掉到没电;或者连接状态下电流一直在几十毫安级别,没有空闲窗口。
原因:广播间隔设置成 50ms 甚至 20ms,射频每几十毫秒醒来一次,平均电流轻松到 100µA 以上。连接建立后如果连接间隔固定成 7.5ms,而业务数据每分钟才发一次,射频依然以 7.5ms 的节奏不停监听,功耗完全下不来。
解决:参数选择要看业务和功耗的平衡。低频数据上报场景,广播间隔拉到 500ms,连接间隔放到 30ms,从机延迟设 4 个周期;需要实时双向交互的场景才把连接间隔压到 15ms 以下。调试功耗不要靠猜,用电流分析仪抓实际曲线,对比每一段射频活动间隔是否和配置吻合。
5.5 服务缓存陈旧:手机连到了修改前的服务
现象:外设端代码改了 UUID 或服务结构,重新烧录后手机再连接,拿到的还是旧服务列表,读特征提示找不到。
原因:Android 系统会把每个设备的 GATT 服务列表缓存在本地。外设端的数据库发生变化后,原来的缓存没失效,手机直接读了缓存,没重新发现服务。
解决:在服务发现失败或特征查找返回空时,显式调用gatt.refresh()(系统隐藏方法,需要通过反射调用)清掉缓存,再重新discoverServices()。另外,开发阶段改完外设代码后,顺手在手机蓝牙设置里解除配对并清掉设备缓存,能省掉大量"代码改了但没生效"的排查时间。
6. 进阶技巧:用连接参数协商把有效吞吐率提上去
默认连接参数是按省电逻辑配置的,比如连接间隔 30ms、从机延迟 0。做透传或 OTA 升级时,这个参数会让吞吐率低得让人抓狂。BLE 4.0 允许在连接建立后协商新的连接参数,Android 端可以直接调用requestConnectionPriority,三个档位对应不同的间隔区间:CONNECTION_PRIORITY_HIGH会把连接间隔压到 7.5ms~15ms,BALANCED是 30ms~50ms,LOW_POWER是 100ms~125ms。
/* 大块数据传输前,先把连接参数切到高速档 */ gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) /* 传输完成后切回均衡档,让出射频资源给其他设备 */ gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED)但这里有个容易被忽略的细节:requestConnectionPriority只是发起请求,实际能不能生效取决于对端协议栈是否接受。外设端如果没实现 L2CAP 的连接参数更新响应,这个请求会被静默忽略,连个报错都没有。而 iOS 端作为主机时,根本不理睬外设发起的参数更新请求,这把控制权完全收到了系统手里。所以在做跨平台方案时,最可靠的做法是外设端把初始连接参数就配置成目标值,而不是等手机来协商。
调试连接参数有个很实用的小技巧:打开 Android 开发者选项里的“蓝牙 HCI 日志”,连一次设备后,用 Wireshark 打开生成的 btsnoop 文件,过滤LL_CONNECTION_UPDATE_IND消息,能看到双方实际协商出来的连接间隔、从机延迟和超时时间。这样做的好处是直接看到链路层真相,而不是听代码里怎么声明。
从那以后,我每次拿到一个新模块或者改完外设代码,都会强制走一遍“扫描 → 连接 → 遍历 GATT 服务表 → 核对 UUID 和特征属性 → 订阅通知 → 看抓包确认实际连接参数”这个流程,把每一步的结果打印到日志里再动手写业务逻辑。蓝牙这东西,一旦链路参数不对,代码层面完全看不出问题,只能靠流程把黑匣子撬开。这个习惯帮我省了大量返工时间,希望帮到你。
本文还有配套的精品资源,点击获取