资讯动态

手机模拟BLE广播:31字节广播包拼装与扫描验证

发布时间:2026/10/26 14:08:27 来源:尧图企业网站定制
简介面向Android蓝牙开发初学者与需要快速验证BLE通信方案的开发者这份工程用手机模拟BLE周边设备通过BLE 4.0发送广播并可与配套的接收广播App配合让两台Android手机分别作为周边设备与中央设备完成搜索、连接、数据收发及通知交互适合用来理解气体检测传感器、智能手环、体重秤等真实设备的通信原型。压缩包共39个文件约171KB包含11张png界面截图、10个xml布局与配置、3个java核心源码以及gradle、properties、jar等构建依赖文件工程结构完整可直接导入Android Studio参考。已有4668人学习下载读者可据此掌握广播包组织、设备发现、连接建立与双向数据传输的实现思路并在此基础上改造出自己的BLE传感器或外设演示程序。1. 开篇把一台手机变成 BLE 广播源先想清楚它替谁干活联调蓝牙扫描配网的那几天手里最缺的往往不是代码而是那台目标设备。硬件样机还在路上扫描端的过滤逻辑、去重策略、重连时机却已经要写了。这时候如果能把手上任意一台手机变成 BLE 外设让它按你填的名字、服务 UUID、厂商数据持续往外发广播整个联调节奏就反过来了——不是等硬件而是自己造一个可调的信号源。这个 App 干的就是这件事手机模拟 BLE 设备发送广播把广播参数从射频里抓到的黑匣子变成表单里能改、能复现、能存档的输入。适合三类人写扫描/配网/近场发现逻辑的 App 开发做蓝牙相关自动化回归的测试同学以及硬件对接前期需要自测扫描端的嵌入式方向开发者。2. 技术选型与分层架构从系统蓝牙栈到一条 31 字节广播包手机能不能发广播不取决于你写得多好取决于系统给不给这个能力。这一步选型错了后面所有代码都是白写所以先把边界摊开看。2.1 两端的物理边界Android 的 Advertiser 与 iOS 的 PeripheralManagerAndroid 侧走BluetoothLeAdvertiser从BluetoothAdapter.getBluetoothLeAdvertiser()拿到实例API 21 起提供。它允许你完全自定义广播内容Flags、本地名称、16 位/32 位/128 位服务 UUID、服务数据、厂商自定义数据都能塞。代价是权限模型这几年被拆得比较碎12 之后要单独的BLUETOOTH_ADVERTISE运行时权限。iOS 侧走CBPeripheralManager的startAdvertising能力就窄很多。CBAdvertisementData里能被外设侧使用的键实际上只有两个CBAdvertisementDataLocalNameKey和CBAdvertisementDataServiceUUIDsKey。也就是说想模拟一条带自定义厂商数据Manufacturer Specific Data的广播在 iOS 上基本做不到——这条限制不是权限能解决的是接口层就没开这个口子。能力项AndroidAdvertiseriOSPeripheralManager最低系统版本API 21iOS 13 起接口稳定自定义厂商数据支持不支持自定义服务数据支持不支持本地名称支持占字节预算支持但系统可能放到扫描响应里Flags 字段可手动构造由系统接管后台持续广播有限易被系统回收退到后台即停可连接性setConnectable控制由是否添加可写特征间接决定提示如果你的核心需求是模拟一条带厂商数据的广播包iOS 端只能退化成用服务 UUID 编码信息 连接后读特征的折中方案。做跨端工具时这一点要在界面层就明确提示别让用户以为是自己填错了。所以常见做法是两端共用一套参数模型名称、UUID 列表、厂商数据、发射功率、广播模式再各自写一个适配器实现把平台做不到的字段标成不可用而不是静默丢弃。静默丢弃是最坑的做法用户明明填了扫出来没有排查方向全歪。2.2 工程分层界面、广播服务、包构造各管一段这类工具看起来功能简单实则特别容易写成一坨点击事件里直接拼AdvertiseData参数校验散在三个地方换一个字段全文件改一遍。比较稳的切法是三层我一般按下面这个目录来组织app/src/main/java/com/example/simble/ ├── ui/ │ ├── BroadcastFormScreen.kt # 参数表单、开关按钮 │ └── BroadcastState.kt # 广播状态IDLE / ADVERTISING / ERROR ├── domain/ │ ├── BroadcastProfile.kt # 参数数据模型可序列化 │ ├── AdStructureBuilder.kt # 纯函数字节数组拼装与长度校验 │ └── ProfileValidator.kt # 校验总长、UUID 格式、名称编码 └── platform/ ├── BleAdvertiser.kt # 接口start/stop/isSupported ├── AndroidBleAdvertiser.kt # AdvertiseData 实现 └── IosBleAdvertiser.kt # CBPeripheralManager 实现跨端工程时domain层最关键它必须是纯 Kotlin/纯逻辑不依赖任何蓝牙对象。好处有两个一是拼包逻辑能写单元测试不用真机就能验证字节对不对二是换平台时只改platform层。BroadcastProfile建议直接做成可序列化的数据结构后面做广播配置存档从文件导入一组参数全指望它。BleAdvertiser接口定义就三个方法足够isSupported(): Boolean、start(profile, callback)、stop()。回调里区分两类失败——参数非法能在本地拦下和系统拒绝权限、蓝牙关闭、硬件不支持这两类给用户的提示完全不一样混在一起就成了那句经典的广播启动失败用户看了等于没看。2.3 31 字节的预算AD Structure 到底怎么排传统 BLE 广播包的有效载荷是 31 字节这个数字是硬约束所有字段都得在这个预算里抢位置。包内由若干个 AD Structure 首尾相接组成每个结构是固定格式Length(1 byte) | AD Type(1 byte) | AD Data(N bytes)Length的值等于AD Type 长度 AD Data 长度也就是 N1注意它不包含自己这一字节。这一点很多人第一次手写拼包时会算错表现就是扫出来的名称少一个字符或者厂商数据末尾被截断。AD Type含义典型数据长度0x01Flags1 字节如 0x060x03完整 16 位服务 UUID 列表2 字节 × N0x07完整 128 位服务 UUID 列表16 字节 × N0x09完整本地名称名称 UTF-8 字节数0xFF厂商自定义数据2 字节厂商标识 自定义内容算一笔账就明白预算多紧张Flags 结构占 3 字节Length Type Data一个 128 位服务 UUID 结构占 18 字节1116加起来 21 字节只剩 10 字节。这 10 字节里如果还要放名称结构头占 2 字节名称实际只能放 8 个字节——中文名基本别想了UTF-8 下一个汉字 3 字节。再想加厂商数据只能把名称放到扫描响应Scan Response里那又是另外 31 字节。注意Android 的setIncludeDeviceName(true)会自动把设备名塞进广播包而且是系统认为的设备名往往比你填的长。开着它又手动加名称很容易直接顶爆 31 字节然后系统不报错、只是发不出去或者被截断。我一般把它关掉名称自己拼。3. 跑起来环境、权限与第一次广播的验证闭环3.1 环境准备与 minSdk 的取舍Android 侧用 Kotlin 任意 UI 框架都行这里不绑死。工程配置上有两个硬指标// app/build.gradle.kts android { compileSdk 34 defaultConfig { minSdk 21 // 低于 21 没有 BluetoothLeAdvertiser targetSdk 34 // 34 起后台行为更严格前台广播不受影响 } }minSdk定在 21 是底线因为BluetoothLeAdvertiser就是 API 21 引入的。想兼容更低版本只有一个办法判断adapter.isMultipleAdvertisementSupported为 false 时直接禁用开始按钮并说明原因而不是让它崩在NullPointerException上。这个属性在部分低端机和定制 ROM 上会返回 false属于硬件能力缺失不是权限问题。iOS 侧用 Xcode 建工程CoreBluetooth框架最低 iOS 13。两边都有一个共同前提必须真机。模拟器没有蓝牙硬件CBPeripheralManager在模拟器上状态永远是unsupportedAndroid 模拟器同理。这一条能劝退一半第一次跑的人。3.2 权限清单12 之后的拆分是绕不过去的清单文件要把新旧权限分开声明靠maxSdkVersion隔开否则新系统上会出现声明了但用不上的混乱!-- Android 12 及以上按用途拆分均为运行时权限 -- uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / !-- Android 11 及以下旧模型 -- uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 /BLUETOOTH_ADVERTISE是 dangerous 级别必须运行时申请。用一个统一的申请入口别在按钮回调里临时写private val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result - // 只关心 ADVERTISECONNECT 失败不影响发广播但会影响后续连读 val advertiseGranted result[Manifest.permission.BLUETOOTH_ADVERTISE] true if (advertiseGranted) startAdvertisingFlow() else showPermissionHint() } fun ensurePermissionThenStart() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.S ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_ADVERTISE) ! PackageManager.PERMISSION_GRANTED ) { permissionLauncher.launch(arrayOf(Manifest.permission.BLUETOOTH_ADVERTISE)) } else { startAdvertisingFlow() } }参数说明RequestMultiplePermissions一次拿到结果 Map键是权限名。Android 11 及以下没有BLUETOOTH_ADVERTISE这个常量直接判断SDK_INT S走新分支老系统上权限在安装时就授予了不需要运行时弹窗——这也是为什么清单里要给旧权限加maxSdkVersion30。3.3 第一次广播与三处验证跑通的标准不是按钮变绿了而是另一台设备确实扫到了你以为你发出去的东西。按这个顺序验证一步都别跳确认蓝牙已开启且系统设置里没有对该 App 做限制。代码里先查bluetoothManager.adapter?.isEnabled true为 false 时引导用户去开不要硬启动。在表单里填最小参数集名称SIM-BLE-01、一个 16 位服务 UUID比如0x181A、一段 4 字节厂商数据。先别填 128 位 UUID先跑通再加。点开始看回调日志。Android 的AdvertiseCallback只有onStartSuccess和onStartFailure两个回调失败时errorCode有几个固定值比如ADVERTISE_FAILED_DATA_TOO_LARGE、ADVERTISE_FAILED_FEATURE_UNSUPPORTED、ADVERTISE_FAILED_TOO_MANY_ADVERTISERS。把这几个码映射成中文提示能省下大量返工。用另一台手机装一个通用 BLE 调试工具去扫重点看三样设备名对不对、厂商数据字节对不对、RSSI 是否随距离变化。三条都对了才算真的发出去了。日志侧建议直接过滤标签跑的时候开着看# 只看本 App 的蓝牙相关日志观察启动回调 adb logcat -s BleAdvertiser:D AndroidRuntime:E如果第 4 步扫不到先别改代码回到第 3 步看回调——绝大多数扫不到其实是根本没启动成功只是失败信息被吞了。4. 核心代码拆解从参数到字节从字节到射频4.1 手写 AD Structure 拼装器可测试的那一半把拼包做成纯函数是整个工程里最值钱的一步。它不碰蓝牙、不碰 Context输入是参数模型输出是字节数组和一条错误信息object AdStructureBuilder { const val MAX_PAYLOAD 31 /** * 按 AD Structure 规则拼装广播载荷。 * param localName 本地名称null 表示不写入 * param serviceUuid16 16 位服务 UUIDnull 表示不写入 * param manufacturerId 厂商标识0x0000-0xFFFF写入时按小端序 * param manufacturerData 厂商自定义数据 * return Pair(载荷, 错误信息)错误信息为 null 表示拼装成功 */ fun build( localName: String?, serviceUuid16: Int?, manufacturerId: Int, manufacturerData: ByteArray ): PairByteArray?, String? { val out java.io.ByteArrayOutputStream() // 1) FlagsLE 通用可发现 不支持 BR/EDR out.write(byteArrayOf(0x02, 0x01, 0x06)) // 2) 16 位服务 UUID完整列表 if (serviceUuid16 ! null) { out.write(0x03) // Length Type(1) Data(2) out.write(0x03) // AD Type: Complete List of 16-bit UUIDs out.write(serviceUuid16 and 0xFF) // 小端序低位在前 out.write((serviceUuid16 shr 8) and 0xFF) } // 3) 厂商自定义数据前两字节是厂商标识小端序 val mfr byteArrayOf( (manufacturerId and 0xFF).toByte(), ((manufacturerId shr 8) and 0xFF).toByte() ) manufacturerData out.write(mfr.size 1) // Length Type(1) Data(N) out.write(0xFF) // AD Type: Manufacturer Specific Data out.write(mfr) // 4) 本地名称UTF-8 编码长度按字节算而不是字符数 if (!localName.isNullOrEmpty()) { val nameBytes localName.toByteArray(Charsets.UTF_8) out.write(nameBytes.size 1) out.write(0x09) // AD Type: Complete Local Name out.write(nameBytes) } val payload out.toByteArray() return if (payload.size MAX_PAYLOAD) { null to 载荷 ${payload.size} 字节超出 31 字节上限请缩短名称或减少字段 } else { payload to null } } }逻辑说明四个结构按Flags → UUID → 厂商数据 → 名称的顺序追加每个结构写之前先算 Length。Length 的算法统一是Type 占 1 字节 Data 占 N 字节所以写进去的永远是N1。最后做一次总长校验超了就返回错误而不是硬发——这是把发不出去从系统层面提前到应用层面的关键一步。参数说明manufacturerId是那个 16 位的厂商标识蓝牙规范里由分配机构统一发放测试阶段随便填一个不冲突的值就行但字节序必须是低字节在前写反了扫描端按 ID 过滤就永远匹配不上。localName的长度一定要用toByteArray().size用String.length的话一个汉字按 1 算、实际占 3 字节必然超限。4.2 AdvertiseData 与 AdvertiseSettings 的正确组合拼好的字节最终要交给系统的AdvertiseData。这里有个取舍你可以用AdvertiseData.Builder的各个addXxx方法让系统帮你拼也可以用addManufacturerData把自定义段塞进去。前者的好处是系统保证格式合法后者是自由度更高。我的做法是标准字段服务 UUID、厂商数据用 Builder名称自己控制并且明确关掉自动带名称private fun startAdvertising(profile: BroadcastProfile) { val adapter bluetoothManager.adapter ?: return onFail(设备不支持蓝牙) if (!adapter.isEnabled) return onFail(请先打开蓝牙) val advertiser adapter.bluetoothLeAdvertiser ?: return onFail(本机不支持 BLE 外设模式部分机型缺失) // 广播包厂商数据 服务 UUID不自动带设备名 val data AdvertiseData.Builder() .setIncludeDeviceName(false) // 关键避免系统名称顶爆预算 .setIncludeTxPowerLevel(false) // 需要省字节就关掉 .addServiceUuid(ParcelUuid.fromString(profile.serviceUuid)) .addManufacturerData(profile.manufacturerId, profile.manufacturerData) .build() // 扫描响应把名称放这里等于多出 31 字节预算 val scanResponse AdvertiseData.Builder() .setIncludeDeviceName(false) .addServiceUuid(ParcelUuid.fromString(profile.serviceUuid)) .build() val settings AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) // 100ms 左右间隔 .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) .setConnectable(profile.connectable) // false 时只广播不可连 .setTimeout(0) // 0 表示不自动停止 .build() advertiser.startAdvertising(data, scanResponse, object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { onState(BroadcastState.ADVERTISING) } override fun onStartFailure(errorCode: Int) { onFail(mapError(errorCode)) // 把错误码翻译成人话 } }) } private fun mapError(code: Int) when (code) { AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE - 广播数据超过 31 字节 AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - 已有广播在运行先停止 AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED - 本机不支持广播功能 AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR - 系统内部错误重开蓝牙试试 else - 广播启动失败错误码 $code }逻辑说明广播包和扫描响应分开构造是绕开 31 字节限制最直接的手段——扫描端发起扫描时会同时收到这两段并合并展示等于可用预算翻倍。setIncludeDeviceName(false)必须显式关默认行为在部分 ROM 上是开的。参数说明ADVERTISE_MODE_LOW_LATENCY间隔最短、最容易被扫到但耗电高做联调工具无所谓做长时间挂机测试建议换成ADVERTISE_MODE_BALANCED。setTimeout(0)是永不超时非 0 时单位是毫秒到点系统自动停且不回调很容易被误判成广播自己断了。setConnectable(false)只广播不可连能减少被其他设备发起连接的概率。4.3 iOS 端的对等实现接口窄但思路一致iOS 侧代码量小得多因为能配的就那么点东西import CoreBluetooth final class IosBleAdvertiser: NSObject, CBPeripheralManagerDelegate { private var manager: CBPeripheralManager! override init() { super.init() // queue 传 nil 走主队列调试期方便正式环境建议自建串行队列 manager CBPeripheralManager(delegate: self, queue: nil) } func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { // 只有 .poweredOn 才能开始广播其余状态都要给出对应提示 if peripheral.state ! .poweredOn { onStateChanged(.unavailable) } } func start(name: String, serviceUuids: [CBUUID]) { guard manager.state .poweredOn else { return } // 注意advertisementData 只认这两个 key let payload: [String: Any] [ CBAdvertisementDataLocalNameKey: name, CBAdvertisementDataServiceUUIDsKey: serviceUuids ] manager.startAdvertising(payload) } func stop() { manager.stopAdvertising() } }逻辑说明CBPeripheralManager初始化后要等peripheralManagerDidUpdateState回调到poweredOn才能开始广播直接在 init 之后调startAdvertising是无效的而且不报错——这是 iOS 侧最常见的代码看着没问题但就是不发的原因。参数说明advertisementData字典里只有那两个 key 会被真正写进广播包其他 key 会被忽略且不报错。想在广播里带业务数据只能用服务 UUID 做编码比如把状态位映射到某个 128 位 UUID 的某几个字节或者改成广播存在 连接后读特征的两段式设计。这个差异在做跨端工具时必须写进界面提示里不然用户会以为是自己填错了厂商数据。4.4 扫描端回环自己扫自己最快验证链路广播发出去之后最省事的验证是用同一套代码里的扫描模块去扫形成闭环fun startVerifyScan(onFound: (name: String?, mfr: ByteArray?) - Unit) { val scanner bluetoothManager.adapter?.bluetoothLeScanner ?: return val settings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .build() // 想精确验证就按服务 UUID 过滤怀疑过滤条件写错时先传 null 全量扫 val filter ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString(0000181A-0000-1000-8000-00805F9B34FB)) .build() scanner.startScan(listOf(filter), settings, object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val record result.scanRecord ?: return val name record.deviceName val mfr record.getManufacturerSpecificData(0x1234) // 与发送端 ID 保持一致 onFound(name, mfr) } override fun onScanFailed(errorCode: Int) { // SCAN_FAILED_APPLICATION_REGISTRATION_FAILED 常见于权限被撤销后未重启扫描 onFound(null, null) } }) }逻辑说明getManufacturerSpecificData(id)里的 id 要和发送端addManufacturerData的 id 完全一致包括大小端理解。如果扫到设备但拿到的mfr是 null八成是 ID 对不上或者发送端根本没写这个字段。参数说明SCAN_MODE_LOW_LATENCY扫描窗口最密适合验证正式使用建议SCAN_MODE_LOW_POWER。过滤条件按服务 UUID 是对的但注意 16 位 UUID 展开成 128 位时基准 UUID 必须写对否则过滤永远不命中——调试阶段建议先传null全量扫确认能看到自己的广播后再加过滤条件定位问题。5. 避坑与排查广播发不出去、扫不到、连不上的 5 个现场5.1 现象点开始没报错但另一台手机死活扫不到原因载荷超了 31 字节被系统静默截断或直接拒绝而某些 ROM 上onStartFailure回调不触发。名称太长、128 位 UUID 加了好几个、又开着setIncludeDeviceName(true)随随便便就超了。解决在拼装器里做硬校验超长直接拦在本地并显示当前字节数。然后用adb logcat打一行拼装结果的十六进制眼睛数一遍总长。名称能放扫描响应就放扫描响应128 位 UUID 非必要不用。5.2 现象onStartFailure返回错误码日志里写着权限相关原因清单里只声明了旧的BLUETOOTH/BLUETOOTH_ADMIN在 Android 12 及以上不生效运行时的BLUETOOTH_ADVERTISE也没申请。解决清单按 3.2 那样新旧分开声明运行时用统一入口申请。申请被拒后不要反复弹窗改成跳设置页引导。蓝牙关闭状态也要单独判断别把没开蓝牙报成权限不足。5.3 现象本机压根不支持bluetoothLeAdvertiser返回 null原因部分低端机、部分定制 ROM、部分平板没有 BLE 外设能力isMultipleAdvertisementSupported为 false。这不是权限问题是硬件/驱动能力缺失。解决启动前先判断 null 并给出明确文案本机不支持广播功能请换一台设备。别让它崩在空指针上也别指望重装或重启能解决。测试机型最好准备两台一台发一台收提前确认两端能力。5.4 现象扫到了设备但厂商数据是乱码或者匹配不上原因厂商标识的字节序写反了。规范里是低字节在前手动拼byteArrayOf(idHigh, idLow)就全错了用addManufacturerData(id, data)时系统会自己处理字节序手动拼包和用 Builder 混着来最容易出这个问题。解决统一一处拼装逻辑不要两套并存。验证方法很简单发送端打日志id.toString(16)扫描端把收到的前两字节按小端还原成整数两边对一遍就清楚了。5.5 现象广播能扫到但一连接就断或者连不上原因setConnectable(false)时广播本身不可连即使设成 trueAndroid 侧如果没有添加 GATT 服务和特征连接建立后马上会被断开因为对端找不到任何可交互的属性。解决如果只是做信号源用途就把connectable显式设成 false 并在界面说明仅广播不可连接省得别人白试。如果确实需要连接就得补一个最小的 GATT 服务端至少一个可读特征这部分工作量不小选型阶段就要想清楚。6. 二次开发把硬编码广播改成配置驱动的广播组跑通单条广播只是起点真正省时间的是广播组把若干条参数存成配置让 App 按顺序轮播模拟不同设备轮流出现的现场。改造的核心是把BroadcastProfile变成可序列化、可存档的东西Serializable data class BroadcastProfile( val id: String, val localName: String, val serviceUuid: String, val manufacturerId: Int, val manufacturerDataHex: String, // 用十六进制字符串存方便手改 val connectable: Boolean false, val durationMs: Long 10_000 // 该条广播持续时长 ) class BroadcastGroupRunner( private val advertiser: BleAdvertiser, private val profiles: ListBroadcastProfile ) { private var index 0 private var running false fun start() { running true next() } private fun next() { if (!running) return val profile profiles[index % profiles.size] advertiser.start(profile) { // 到点切下一条形成轮播 handler.postDelayed({ advertiser.stop() index next() }, profile.durationMs) } } fun stop() { running false handler.removeCallbacksAndMessages(null) advertiser.stop() } }参数说明manufacturerDataHex用十六进制字符串而不是ByteArray是因为它要落盘成 JSON字节数组序列化之后人看不懂也改不动。durationMs控制每条广播的驻留时间测试扫描端的去重和超时逻辑时特别有用——把时长设成比扫描端的超时阈值短一点就能稳定复现设备反复上下线的场景。改造点按性价比排一下改造点工作量收益参数配置化 JSON 导入导出小高测试用例可版本管理广播组轮播中高一条命令复现多设备现场拼包字节的十六进制预览小高肉眼核对省掉一半排查最小 GATT 服务端可连接大中只在需要连读时做iOS 端完整对齐大低接口能力本身就窄验证方法我习惯用两步先在界面上把拼出来的字节数组打成十六进制和扫描端收到的原始字节逐位对比再开广播组轮播看扫描端日志里的设备名和出现时间戳是否严格跟着durationMs走。两步都过了说明拼包和时序都没问题剩下的就是业务逻辑的事。注意轮播切换的瞬间是最容易出问题的地方stop()和下一次start()之间建议留几十毫秒间隙部分机型上连续启停会返回ADVERTISE_FAILED_TOO_MANY_ADVERTISERS看着像广播泄漏其实就是上一次没停干净。血泪经验是这类工具最贵的不是代码量而是我以为我发出去的内容和实际射频里的字节之间的偏差。从那以后我每次改广播字段都先把拼出来的字节数组完整打一遍日志再点开始扫不到的时候第一件事就是拿这行日志和扫描端抓到的原始字节对一遍——省下来的时间比写功能多得多。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑