资讯动态

Android BLE实战:原生BluetoothGatt扫描连接与CCCD订阅

发布时间:2026/10/26 2:44:56 来源:尧图企业网站定制
简介这是一份面向Android开发初学者与蓝牙功能实践者的BLE开发示例工程围绕蓝牙4.2的数据透传场景演示如何调用系统蓝牙模块完成设备扫描、建立连接以及双向收发数据代码经过刻意精简便于快速理解核心流程并移植到自己的项目中。压缩包共1244个文件整体约9.24MB除31个java源码与67个xml布局配置外还包含大量class、dex编译产物、flats与gradle构建脚本、properties与json配置以及apk安装包完整保留了可运行的工程结构与构建环境。资源已有510人学习下载读者可以从中获得一套可直接导入Android Studio运行的BLE最小闭环示例对照源码理解扫描回调、连接状态管理与数据收发线程的组织方式并借助模块划分与目录结构快速定位关键类减少在蓝牙调试与兼容性问题上反复试错的成本适合作为入门练手与二次开发的起手模板。1. 手环摆在桌上扫描列表却是空的手环摆在桌上手机蓝牙也开着App 里的扫描列表却始终是空的。接过 Android BLE 的人基本都撞过这个情况。bluetooth4.zip 是一份把蓝牙 4.2 全链路跑通的 Android BLE 示例工程打开适配器、按服务 UUID 过滤扫描、connectGatt、协商 MTU、订阅 CCCD 通知再到按协议解析心率与电量一条链路对应一段代码核心逻辑都摆在明面上没有被藏在第三方封装里。它适合两类人刚接手 IoT 应用、被 BluetoothGattCallback 那堆异步回调绕晕的 Android 开发以及要给手环、体脂秤、温湿度传感器做对接需要一个能直接改的代码底座的工程师。界面很朴素分层却是完整的扫描、GATT、协议、UI 各管一摊拿过来当自己项目的 BLE 通信层起点比从零搭省事得多。2. 技术栈与工程结构BLE 4.2 的分层拆解蓝牙 4.2 在应用层最实在的两个变化一是 LE Data Length Extension数据长度扩展单包有效载荷从默认的 20 字节抬到两百多字节二是隐私特性升级配网和绑定环节的行为更规范。前者直接决定了你一条命令要拆几包发后者影响的是重连时地址是否稳定。这份工程吃到了第一个红利MTU 协商那段写得很直白后面拆代码时会看到。整体走原生路线BluetoothAdapter 管开关和状态BluetoothLeScanner 管扫描BluetoothGatt 管连接与读写协议层自己按 byte[] 解析。没有引入任何 BLE 封装库依赖表干净得只有 AndroidX 和 Material。2.1 为什么直接用原生 BluetoothGatt而不是套一层库市面上不少 BLE 封装库会把回调包装成 Rx 流或协程 Flow链式调用读起来确实舒服但真出问题时就成了一个黑匣子。连接超时返回 133、通知偶发丢包、某些机型 MTU 协商完立刻断开——这几个问题一旦落到封装库内部排查就得去读库源码而库作者维护节奏未必跟得上新系统版本。原生的代价是代码啰嗦好处是每一层出错都能定位。这份工程的选择是把啰嗦封装一次封装在自己的代码里而不是别人的库里。BleClient 对外只暴露 connect/read/write/subscribe 四个动作和几个状态回调内部仍然是标准的 GATT 回调调试时打断点能一路跟到底。版本门槛上几个 API 要记住。BluetoothLeScanner 从 API 21 起才有所以 minSdk 定 21 是常见做法Android 13API 33之后 writeCharacteristic 有了带返回码的重载老写法开始走 deprecatedAndroid 14 对前台服务类型和蓝牙权限又收紧了一轮。工程里到处是这种版本判断// 版本分叉集中在一处业务层不用到处写 if (Build.VERSION.SDK_INT ...) static boolean writeChar(BluetoothGatt gatt, BluetoothGattCharacteristic ch, byte[] data) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // API 33新重载直接返回 int 状态码比布尔值信息量更大 int ret gatt.writeCharacteristic(ch, data, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); return ret BluetoothStatusCodes.SUCCESS; } // API 33 以前只能 setValue writeCharacteristic靠布尔返回值判断 ch.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT); ch.setValue(data); return gatt.writeCharacteristic(ch); }这段的三个参数分别对应gatt 是连接句柄ch 是目标特征值data 是要写下去的原始字节。WRITE_TYPE_DEFAULT 表示带响应写对方要回 ACK可靠但慢WRITE_TYPE_NO_RESPONSE 不回 ACK吞吐高丢了也不知道。返回值在两种模式下含义不同——老 API 返回 false 通常不是“写失败”而是“上一条 GATT 操作还没回调队列排不进去”这个差别后面避坑章节还会展开。MTU 的账要提前算清楚场景MTU单包可写字节说明系统默认2320三个字节被 ATT 头吃掉协商成功常见247244蓝牙 4.2 DLE 的典型值协商被拒2320设备固件或系统没接受回落到默认极限值517514部分芯片支持实际很少用到所以协议设计时单包载荷不能写死 20得留出 mtu-3 的动态空间。2.2 工程目录谁负责扫描谁负责 GATT谁负责协议目录结构是这份工程最值得抄的部分分层很清楚各层之间只靠接口和字节数组通信app/src/main/java/com/example/ble/ ├── BleApp.java 应用入口持有全局 BleClient 单例 ├── scan/ │ ├── BleScanner.java 扫描封装startScan / stopScan / 超时兜底 │ └── ScanResultAdapter.java ScanResult 转成 UI 用的设备模型 ├── gatt/ │ ├── BleClient.java 连接门面connect / disconnect / write / subscribe │ ├── BleGattCallback.java 回调分发状态机在这里 │ ├── BleCommandQueue.java GATT 操作队列串行化读写 │ └── BleException.java 统一异常类型带 status code ├── protocol/ │ ├── BleUuid.java 所有服务和特征值 UUID 的常量表 │ ├── FrameCodec.java 帧头/长度/校验/分包重组 │ └── model/ │ ├── DeviceInfo.java 电量、固件版本这类静态信息 │ └── HeartRateData.java 实时数据模型 ├── ui/ │ ├── ScanActivity.java 扫描页 │ └── DeviceActivity.java 设备详情页 └── util/ ├── BleLogger.java 带 tag 前缀的日志方便 logcat 过滤 └── HexUtil.java 字节数组与十六进制互转分层的原则可以归纳成三句话。UI 层不碰 BluetoothGatt连 gatt 这个对象都拿不到只订阅 BleClient 暴露的状态回调BleClient 不解析业务语义它只知道“往这个 UUID 写一段字节”不知道字节代表设置心率阈值protocol 层是纯函数输入 byte[] 输出数据模型不依赖 Android 任何类可以直接放到单元测试里跑。这个边界划得值。我之前见过把解析逻辑写在 onCharacteristicChanged 里的代码蓝牙线程直接更新 UI测试机跑得好好的换到某台国产机型上偶发崩溃查了两天才发现是回调线程不一定是主线程。分层之后这种问题从架构上就不可能发生。2.3 权限清单Android 12 是一道分水岭蓝牙权限在 Android 12API 31前后是两套体系很多工程在 targetSdk 升到 31 之后突然扫不到设备基本都是这里没改。!-- AndroidManifest.xml -- !-- 老体系API 30 及以下 -- uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / !-- API 23~30 扫描 BLE 必须有定位权限这是系统强制的 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / !-- 新体系API 31 起拆成扫描和连接两个权限 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-feature android:nameandroid.hardware.bluetooth_le android:requiredtrue /usesPermissionFlagsneverForLocation这一行要谨慎。加上它声明了“我扫描不是为了拿位置”在 Android 12 以上可以只申请蓝牙权限、不申请定位用户体验好但系统会因此过滤掉一部分广播包iBeacon 这类靠广播做距离估算的设备会直接看不见。做定位类产品就别加做手环对接加上更合适。运行时申请要按版本分叉工程里这段写成一个工具方法// 返回当前版本真正需要动态申请的权限集合 static String[] requiredPermissions() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { return new String[]{ Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT }; } if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { return new String[]{Manifest.permission.ACCESS_FINE_LOCATION}; } return new String[0]; // 6.0 以前装在 manifest 里就生效 }参数说明这段只负责“该申请哪些”不负责“申请结果怎么处理”。实际调用时要注意用户拒绝一次之后再申请系统可能直接返回“不再询问”得引导到设置页。另一个容易忽略的点是定位开关——API 30 及以下即使权限给了系统定位服务关掉时 BLE 扫描同样返回空列表代码里要单独判断并提示。3. 从扫描到连接一条 GATT 链路的完整代码拆解链路的时序是固定的开扫描、拿到 BluetoothDevice、connectGatt、onConnectionStateChange 回调、discoverServices、onServicesDiscovered、协商 MTU、找特征值、订阅通知、开始收发。每一步都是异步下一步必须在前一步的回调里发起写成顺序执行的同步代码就会翻车。3.1 扫描ScanFilter 和 ScanSettings 怎么配扫描参数配错后面全白搭。// BleScanner.start()开扫描同时挂一个超时兜底 public void start(NonNull ScanListener listener) { BluetoothLeScanner scanner adapter.getBluetoothLeScanner(); if (scanner null) { // 蓝牙被关掉时这里返回 null很常见 listener.onScanError(BleException.ADAPTER_OFF); return; } ScanSettings settings new ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 前台用占空比拉满出结果快、耗电高 .setReportDelay(0L) // 0 每个广播包单独回调 .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) // 提速优先 .build(); // 按服务 UUID 过滤比按名字匹配靠谱得多——广播名可以被设备随意修改 ListScanFilter filters Collections.singletonList( new ScanFilter.Builder() .setServiceUuid(new ParcelUuid(BleUuid.SERVICE_HEART_RATE)) .build()); scanner.startScan(filters, settings, callback); handler.postDelayed(stopTask, SCAN_TIMEOUT_MS); // 15 秒没结果就收手 }几个参数的取舍值得说清楚。SCAN_MODE_LOW_LATENCY 是前台扫描专用几乎不歇着地听广播功耗高但快放到后台还在用这个模式系统会很不高兴。后台场景换 SCAN_MODE_LOW_POWER间隔拉长功耗降下来代价是发现慢几十秒。setReportDelay(0) 表示逐包回调适合需要看 RSSI 波动的场景设成大于 0 会批量回调省一点 CPU但实时性差。MATCH_MODE_AGGRESSIVE 和 STICKY 的区别在于前者对每个广播包都回调后者只在第一次匹配时回调做重连扫描时用 STICKY 更合适。filter 里用服务 UUID 是有讲究的。BLE 设备的广播包分两种一种是广播名 服务 UUID 完整列出来另一种是不广播 UUID 只带厂商自定义数据。前一种能靠 serviceUuid 精确过掉无关设备后一种只能靠 manufacturerData 匹配。工程里先按 UUID 过扫不到再退化到不过滤两个阶段。回调里拿到的不只是设备地址private final ScanCallback callback new ScanCallback() { Override public void onScanResult(int callbackType, ScanResult result) { // getScanRecord() 里的 manufacturerData 常常藏着电量、开关状态这类信息 // 有些设备甚至把整条业务数据塞在广播里连连接都省了 ScanRecord record result.getScanRecord(); byte[] mfr (record ! null) ? record.getManufacturerSpecificData(0x01A2) : null; listener.onDeviceFound(result.getDevice(), result.getRssi(), mfr); } Override public void onScanFailed(int errorCode) { // 2 SCAN_FAILED_APPLICATION_REGISTRATION_FAILED典型原因是扫描开得太频繁 listener.onScanError(errorCode); } };0x01A2 是厂商 ID每个厂商不一样用之前要跟硬件那边确认。result.getRssi() 的值可以直接用来做远近粗判但别拿来做精确测距2.4G 的衰减波动大同一位置上下浮动十几 dBm 是常事。3.2 连接与 MTUconnectGatt 之后必须做的两件事连接这行代码看着简单参数全是坑// 连接autoConnect 的含义一定要想清楚再传 BluetoothGatt gatt device.connectGatt(context, false, // false 直连约 30s 超时true 后台挂起等待可能几分钟才连上 gattCallback, BluetoothDevice.TRANSPORT_LE); // 双模设备必须显式指定 LE否则可能被当成经典蓝牙autoConnect 传 true 时系统会把这条连接请求挂在底层只要设备出现就连省电但时延不可控而且这条连接会一直占着资源。做即时交互的界面传 false做后台自动回连的传 true两者不能混。TRANSPORT_LE 这个参数在双模设备上很关键——有些芯片同时支持经典蓝牙和 BLE不指定传输层系统按默认策略选可能给你连到经典蓝牙的 SPP 通道上去然后特征值一个都找不到。连上之后有两个动作是必须做的顺序也不能反Override public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { if (status BluetoothGatt.GATT_SUCCESS newState BluetoothProfile.STATE_CONNECTED) { // 链路通了但服务表还是空的必须主动去发现 gatt.discoverServices(); } else if (newState BluetoothProfile.STATE_DISCONNECTED) { // status 133 出现频率最高后面避坑章节专门说 closeQuietly(gatt); // 不 close 就重连下一次十有八九还是 133 listener.onDisconnected(status); } } Override public void onServicesDiscovered(BluetoothGatt gatt, int status) { if (status ! BluetoothGatt.GATT_SUCCESS) { listener.onError(BleException.SERVICE_DISCOVERY_FAILED); return; } // 服务表到手再谈 MTU顺序反了部分机型会直接忽略这次请求 gatt.requestMtu(REQUEST_MTU); // 建议值 247 } Override public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { // 协商失败不代表连接坏了回落到 23 继续用 this.mtu (status BluetoothGatt.GATT_SUCCESS) ? mtu : DEFAULT_MTU; // 到这一步才算“设备就绪”可以订阅通知、下发命令了 listener.onReady(this.mtu); }三个回调串成一条链连上、发现服务、协商 MTU、就绪。REQUEST_MTU 传 247 是蓝牙 4.2 DLE 的常见上限但 247 不是每个设备都接受有的固件只认 512、有的只认 185协商标失败会自动回落到 23代码里必须把这个回落兜住不能假设一定成功。3.3 读写与通知CCCD 订阅和按 MTU 分包通知订阅是新手最容易漏的一步。setCharacteristicNotification 只是告诉系统“我关心这个特征值”真正让设备开始推送数据的是往 CCCD 描述符里写使能位// 两步缺一不可只做第一步 onCharacteristicChanged 永远不会来 boolean ok gatt.setCharacteristicNotification(ch, true); if (!ok) { /* 特征值不支持通知去查它有没有 NOTIFY 或 INDICATE 属性 */ } BluetoothGattDescriptor cccd ch.getDescriptor(BleUuid.CCCD); // 固定 UUID 0x2902 if (cccd null) { /* 设备没声明 CCCD多半是固件问题 */ } cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE); // INDICATE 用 ENABLE_INDICATION_VALUE gatt.writeDescriptor(cccd); // 异步必须等 onDescriptorWrite 返回才算订阅完成写 CCCD 是异步操作很多人写完就直接下发第一条命令结果命令发出去了、通知没订上数据一条收不到。正确的做法是在 onDescriptorWrite 回调里再触发“就绪”状态工程里的状态机就是这么串的。写数据这块分包逻辑必须自己写// 按协商到的 MTU 切包mtu-3 是有效载荷上限 public boolean send(byte[] frame) { int maxPayload mtu - 3; if (frame.length maxPayload) { return writeChar(gatt, txChar, frame); // 单包直发 } // 大包拆分首字节带上序号末包置结束标志由协议层重组 for (int offset 0; offset frame.length; offset maxPayload) { int len Math.min(maxPayload, frame.length - offset); byte[] chunk new byte[len]; System.arraycopy(frame, offset, chunk, 0, len); if (!writeChar(gatt, txChar, chunk)) { return false; // 队列没排上交给上层重试 } } return true; }这里有个容易误解的地方连续调用 writeChar 时第二次大概率返回 false。不是写失败是 GATT 通道同一时刻只允许一个未完成操作第一条还在等 onCharacteristicWrite 回调第二条排不进去。所以 chunk 循环外面必须挂一个队列在回调里推进下一包而不是在 for 循环里硬发。4. 避坑与排查BLE 里那些说不清的问题蓝牙调试最难受的地方是错误信息太少——status 133 就是 133没有原因没有堆栈。下面几条是我在实际项目里反复撞到的按“现象、原因、解决”记下来下次遇到能省半天。4.1 扫描与权限明明有设备列表就是空的现象扫描十分钟一条结果都没有但用别的调试工具能看到周围一堆设备。原因八成是权限。API 23 到 30 之间BLE 扫描强制要求定位权限光给 BLUETOOTH_ADMIN 没用给了权限但系统定位服务关着扫描同样返回空升到 API 31 之后没换成 BLUETOOTH_SCAN一律静默失败连异常都不抛。解决按版本分叉申请申请完再检查 LocationManager.isLocationEnabled()关着就弹提示引导用户去开。另外 neverForLocation 标志要按产品形态决定加不加加了之后 iBeacon 类的广播会被系统过滤掉。现象onScanFailed 返回 2。原因SCAN_FAILED_APPLICATION_REGISTRATION_FAILED扫描开得太频繁被系统限流。Android 对应用层扫描有频率限制30 秒内超过 5 次就掐。解决扫描一定要配超时停止别在 onResume 里无条件 startScan页面退到后台主动 stopScan重试要加退避别用固定间隔死循环。4.2 连接与 GATT 队列133 和返回 false现象连接回调返回 status 133重试十次十次都失败重启手机才好。原因133 是 GATT_ERROR 的兜底码含义模糊实际触发场景集中在三类——上一条连接没 close 就发起新连接系统里 GATT client 数量超限系统有上限各厂商还不一样外设已经被别的手机连走了。解决每次断开都老老实实 gatt.close()close 之后等 200 毫秒再发起下一次连接把“正在连接中”的设备地址记在内存 Set 里防止用户连点导致并发连接重连做指数退避第一次 1 秒、第二次 2 秒、第三次 4 秒别死磕。现象连续下发两条命令第二条 writeCharacteristic 返回 false。原因GATT 是单通道串行协议同一时刻只允许一个未完成操作读、写、写描述符、请求 MTU 全都占这个通道。解决自己维护一个操作队列执行一条、等回调、再出队下一条。BleCommandQueue 就是为这个建的队列里每个任务带超时超时就丢弃并回调失败避免一条卡死全链路。4.3 通知与解析回调只来一次现象onCharacteristicChanged 只回调一次之后再也没有。原因CCCD 使能没写成功或者写了但没等 onDescriptorWrite 就继续下一步被后面的操作打断。解决setCharacteristicNotification 和 writeDescriptor 两步都做且在 onDescriptorWrite 返回成功后才标记“已订阅”再下发第一条业务命令。现象设备发来的数据总是缺一截或者解析出来是乱码。原因没按 mtu-3 分包或者设备固件默认只发 20 字节上层按 244 字节去解。解决协议头里带长度字段和序号收端做重组发送端固定用当前协商到的 mtu-3 作为切片大小不要写死常量。提示回调线程不一定是主线程。onCharacteristicChanged 在部分机型上跑在 Binder 线程池里直接在里面更新 UI 会偶发崩溃切主线程这一步别省。5. 跑起来与真机调试从导入到第一包数据这份工程不能靠模拟器验证——Android 模拟器没有真实的蓝牙适配器BluetoothAdapter 拿到的永远是 null。必须上真机而且最好准备两台一台 Android 12 以上一台 Android 10 左右权限体系两套都能覆盖到。5.1 导入、编译与 SDK 对齐工程用 Gradle 构建导入 Android Studio 之前先确认三件事JDK 版本、AGP 版本、compileSdk 三者是否匹配。AGP 8.x 要求 JDK 17用 JDK 11 打开会直接报 sync 失败这个报错信息还算清楚。minSdk 设 21 是因为 BluetoothLeScanner 的引入版本compileSdk 和 targetSdk 建议跟到 34否则 Android 14 上的前台服务行为会有出入。# 命令行先跑一遍比 Studio 的报错更直观 ./gradlew :app:assembleDebug --stacktrace # 装到已连接的设备上 adb install -r app/build/outputs/apk/debug/app-debug.apk # 确认设备真的连上了没连上后面全是白费 adb devices -l第一条命令加 --stacktrace 是为了在 Gradle 报错被吞掉时能看到完整调用栈尤其是依赖冲突这类问题不加参数只能看到一句“build failed”。第三条命令里的 -l 参数会打印设备型号和 transport 类型USB 和无线调试能一眼看出来。如果编译卡在依赖下载先检查仓库配置把 google() 和 mavenCentral() 放在前面。这个工程没有引入任何第三方蓝牙库依赖树很浅正常情况下不会卡太久。5.2 真机验证的完整动作序列跑起来之后别急着点连接按下面的顺序走一遍能把大部分环境问题挡在前面打开手机蓝牙打开系统定位服务API 30 及以下必须。首次进入 App 授予权限注意看系统弹窗里是哪几个权限和 manifest 里声明的是否一致。打开系统开发者选项里的“蓝牙 HCI 监听日志”后面抓包要用。先用厂商的调试工具或通用的 BLE 调试工具连一次目标设备确认设备本身广播正常、服务表能读出来。这一步是把“设备问题”和“代码问题”分开的关键。回到自己的 App 扫描比对列表里有没有目标设备RSSI 是否合理。连接后进设备页依次看服务列表、特征值列表、订阅状态、收到的第一包数据。第 4 步是我吃过亏之后固定加上的。有次排查了整整一个下午最后发现是手环固件升级之后广播间隔从 100ms 改成了 2s扫不到纯粹是等的时间不够。5.3 日志与抓包怎么定位一包数据日志分两层看。应用层用 logcat 过滤自己打的 tag工程里 BleLogger 统一加了前缀过滤起来很干净# 只看蓝牙相关日志同时落盘一份方便回溯 adb logcat -v time -s BleScanner:D BleClient:D BleGattCallback:D BluetoothGatt:E | tee ble.log # 清空缓冲区再复现避免旧日志干扰 adb logcat -c日志级别用 D 而不是 V是因为 GATT 层的 verbose 日志量大到能刷屏反而看不到自己关心的那几行BluetoothGatt:E 单列出来是为了抓系统抛的错误码。协议层对不上时logcat 就不够用了得上 HCI 抓包。开发者选项里打开“启用蓝牙 HCI 监听日志”之后抓到的文件在 /sdcard/ 或者通过错误报告导出格式是 btsnoop用抓包工具打开能看到每一条 ATT 层的收发。这一步能看到“我确实发出去了”和“设备确实回了个错误码”这种应用层看不到的事实是排查“命令发了没反应”的最后一招。6. 二次开发把示例改成能进项目的 BLE 通信层Demo 和生产代码的差距主要在状态管理和错误恢复上。示例里连接失败弹个 Toast 就完事真项目里得考虑后台重连、多设备切换、命令超时、固件升级时的长包传输。我的改法是把 BleClient 做成有明确状态机的单例对外只暴露四个方法和一个状态流内部所有 GATT 操作都走队列。// 状态机骨架每个状态只允许特定操作非法调用直接抛别让它悄悄失败 public enum State { IDLE, SCANNING, CONNECTING, DISCOVERING, READY, DISCONNECTING } // 队列任务一次只跑一个超时就丢弃避免一条卡死整条链路 final class BleTask { final byte[] payload; final long timeoutMs; // 单条命令超时建议 3~5 秒 final TaskCallback callback; // 成功/超时/断开三种结果 }改造清单按优先级排下来大概是这样改造点直接照抄的风险验证方法把 UUID 抽成配置硬编码在多处换设备要全局搜改一个常量跑通全流程加重连退避死循环重连把电量和系统资源耗干关掉设备电源观察 5 分钟日志命令加超时一条命令没回后面全堵死拔掉设备看队列是否自动恢复解析层脱离 Android逻辑和回调耦合没法单测用固定字节数组跑 JUnit通知走状态流直接回调 UI切换页面就泄漏反复进出设备页看内存协议层抽出来做纯函数是最划算的一步。FrameCodec 不依赖任何 Android 类输入 byte[] 输出数据模型边界值、粘包、半包这些情况全用单元测试覆盖跑一遍几毫秒比连着真机点一百次强。从那以后我每次接新的 BLE 设备都强制先用调试工具把服务表读一遍、把每个特征值的读写属性抄下来再动代码——不然光靠猜一个下午就没了。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑