资讯动态

手机模拟BLE广播设备:Android蓝牙外设模式开发实战

发布时间:2026/10/17 6:23:33 来源:尧图企业网站定制
简介一款用于安卓手机的BLE外设模拟工具可在手机上模拟低功耗蓝牙设备并持续发送广播配合配套的接收广播App两台安卓手机即可通过蓝牙4.0建立双向数据通道。该工具适合物联网调试、课堂实验和产品前期验证尤其便于模拟气体检测传感器、智能手环、体重秤、血压计等周边设备让开发者无需专用硬件即可测试中央设备的连接与通信逻辑。压缩包共39个文件体积仅171KB包含11张界面截图、10个XML布局与配置、3个Java核心源码、3个Gradle工程构建脚本以及少量辅助jar与bat文件目录结构清晰可快速导入安卓开发环境阅读。已有4665人浏览学习适合需要理解BLE4.0广播机制、GAP与GATT角色关系以及通知发送、特征读写等关键流程的开发者。通过这份资料既能掌握手机作为外设时的广播参数配置也能借鉴完整的双向通信实现思路进而搭建自己的蓝牙应用原型。1. 手机模拟BLE广播设备这App到底解决什么硬件设备还没打样、固件还没烧录网关和软件端却已经堆了一堆联调任务这是做物联网项目最常见的卡壳点。手机模拟BLE设备发送广播App解决的就是这个空窗期不需要改硬件不需要买开发板一台普通Android手机装上App就能把自己伪装成一个不停向外发BLE广播的温湿度计、门锁信标或资产标签让网关、小程序、上位机先把链路跑通。它本质上是把手机的蓝牙外设模式Peripheral利用起来调用系统自带的广播能力按标准BLE广播协议向外发数据。很适合做设备调试、演示Demo、产测工具原型也能用来验证广播冲突、多设备识别等场景。2. 广播协议层的三件事AD结构、广播模式与“为什么用系统API”做模拟器之前得先搞清楚手机发出去的到底是什么。BLE广播不是一条连续的数据流而是一帧一帧带固定格式的广播报文接收端靠帧里的结构化字段来识别设备和读取负载。这一章把广播帧的组装方式、三种广播模式以及为什么用系统自带API而不是其他方案讲清楚这些直接决定了后面App怎么写、参数怎么传。2.1 广播包不是数据流而是一张“自描述的结构化名片”BLE广播帧里真正能被上层利用的部分叫AdvData它由一段段AD Structure组成。每一段AD Structure的格式是固定的三部分长度、类型、数据。因为长度字段自带接收端可以像解析链表一样把一帧广播里的所有字段依次读完所以把哪一段放在前面、哪一段放后面没有严格顺序要求但类型不能乱。常见的AD类型就那么几种Flags标识设备能力、Complete Local Name放设备名称、Service UUID列表声明服务、Manufacturer Specific Data放厂商自定义数据。模拟器里最常用的就是最后两种。比如要给网关上报温度网关不会去看UUID而是直接解析厂商数据段约定好字节含义即可。理解了这个结构你就能解释为什么扫描工具里看到的广播内容是一串十六进制也能自己推算一帧广播到底塞了多少字节。2.2 可连接、可扫描、不可连接三种模式对应三种落地场景BLE广播按PDU类型分为三种可连接定向广播、可扫描非定向广播、不可连接非定向广播。Android的BluetoothLeAdvertiser不直接暴露这三种模式而是通过两个参数组合出来是否可连接setConnectable和是否携带扫描响应Scan Response。如果你把setConnectable设为true发出的就是可连接广播扫描方能主动发起连接适合做“先广播发现、再真正连接交互”的场景比如门锁配对。如果设为false但带了扫描响应则是可扫描广播扫描端可以再发一个扫描请求把额外的扫描响应数据拿回来。如果设为false且不带扫描响应那就是纯粹的信标广播一次广播就把所有信息发完接收端只读不连适合资产标签、传感器数据上报。做模拟器时要先想清楚目标设备是哪一种不要所有设备都做成不可连接的。2.3 为什么选系统BluetoothLeAdvertiser而不是自定义蓝牙协议栈实现手机模拟BLE广播有两条路调用Android系统API或者走root后用hcitool/BlueZ直接操作蓝牙协议栈。系统API这条路的好处是省电、稳定、权限模型清晰而且Android从5.0开始就原生支持外设广播绝大多数手机都能用。缺点是能暴露的参数有限比如扩展广播Extended Advertising在SDK层面基本不可控。hcitool直发的路子看着自由实际坑很多需要root、不同手机蓝牙芯片的行为不一致、广播数据和系统蓝牙栈之间还可能互相干扰调试成本非常高。我一般建议除非在做芯片级的蓝牙协议测试否则没必要碰。选择了系统API接下来的工作就集中在三件事上把权限申请对、把AdvertiseData拼好、把回调里的错误码认全。3. 最小可用的广播App权限、启动代码与回调状态机这一章给出能跑起来的最小编码路径。你跟着做完手机上就能看到一帧带自定义UUID的BLE广播被扫描端发现。核心就一段代码拿到BluetoothLeAdvertiser构造AdvertiseData和AdvertiseSettings然后startAdvertising。3.1 权限与蓝牙状态开始写代码前先过的两道门槛Android 12API 31开始蓝牙权限模型发生了大改原来的BLUETOOTH权限被拆成了BLUETOOTH_SCAN、BLUETOOTH_ADVERTISE、BLUETOOTH_CONNECT三个运行时权限。只需要广播不连接理论上只需要BLUETOOTH_ADVERTISE但为了兼容旧系统以及读取蓝牙开关状态我通常把SCAN和CONNECT一起申请不让权限成为排错时的疑点。uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE/ uses-permission android:nameandroid.permission.BLUETOOTH_SCAN/ uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT/ uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30/ uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30/Android 12以下定位权限是扫描到广播的前提所以需要保留到30。Android 12及以上则不再要求定位权限来做BLE广播。这些声明的区别容易踩坑如果漏了低版本的定位权限老手机会出现“扫描App能看到别人看不到你的模拟设备”的怪象。3.2 用BluetoothLeAdvertiser发出第一帧广播最小启动代码权限就位后核心启动代码很短。下面这段是Kotlin实现Activity或Service里能直接用。做了三件事从系统服务拿BluetoothLeAdvertiser、组装广播数据和广播参数、启动广播。val manager getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val adapter manager.adapter // 不支持外设广播的设备这里会返回null val advertiser adapter?.bluetoothLeAdvertiser ?: run { Log.e(TAG, 当前设备不支持BLE外设广播) return } val advertiseData AdvertiseData.Builder() .setIncludeDeviceName(false) // 设备名放进主广播很占字节先关掉 .addServiceUuid( ParcelUuid(UUID.fromString(0000fd00-0000-1000-8000-00805f9b34fb)), null ) .build() val advertiseSettings AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) // 250ms间隔折中档 .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) // 中等发射功率 .setConnectable(false) // 做传感器类广播不可连接 .build()这段代码里addServiceUuid的第二个参数是Service Data可以挂一段和UUID关联的额外数据null表示不带。setConnectable(false)决定了广播类型是不可连接广播扫描端能发现但不能连。如果你要模拟的是“可发现且可连接”的设备这里要改成true同时App里还得配上GATT Server工作量会大很多标题里的“广播”场景一般不需要到那一步。3.3 回调状态机startAdvertising的结果是异步的startAdvertising不会同步告诉你成功失败结果通过AdvertiseCallback回调返回。很多新手在这里翻车广播没发出去UI上还显示“已开启”。所以回调里的成功和失败两个分支都要处理到位。private val advertiseCallback object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { // settingsInEffect里是协议栈实际生效的间隔和功率可能跟请求值不一样 Log.i(TAG, 广播启动成功实际模式${settingsInEffect.advertiseMode}) } override fun onStartFailure(errorCode: Int) { // 1数据超长 2并发广播太多 3重复启动 4内部错误 5硬件不支持 6蓝牙关闭 Log.e(TAG, 广播启动失败错误码$errorCode) } } advertiser.startAdvertising( advertiseSettings, advertiseData, null, // 扫描响应这里先不传 advertiseCallback )关于回调的细节onStartSuccess里的settingsInEffect值得打日志因为部分手机协议栈会擅自降低广播频率你以为自己在用100ms间隔实际生效可能是250ms。关于onStartFailure的错误码Android API里有明确对应关系建议在代码里做一个映射表排错时能直接看到人类可读的报错原因而不是一个光秃秃的数字。还要记住一点广播参数一旦启动不能边广播边修改。要换UUID、换负载、换间隔都必须先stopAdvertising再重新start。这在做多设备模拟的时候尤其重要稍后第六章会细说。4. 把广播负载做成“像真设备”31字节预算、扫描响应与厂商数据发广播只是第一步网关或调试工具认不认你的数据取决于负载里的字节是否符合预期。这一章解决“广播内容怎么设计”的问题。重点讲legacy广播的31字节限制、扫描响应的用法以及厂商自定义数据段的字节级组装。4.1 31字节的取舍服务UUID、设备名与厂商自定义数据怎么分标准BLE广播Legacy Advertising的AdvData段最大31字节这个约束是广播协议写死的Android不提供超越这个限制的通用API。31字节听着少用起来其实够用前提是别乱塞。下表是常见字段的字节占用设计负载时先照这个表做加减法。字段内容AD类型字节占用Flags0x01316bit Service UUID0x03每个4字节32bit Service UUID0x05每个6字节128bit Service UUID0x07每个18字节完整设备名0x09名称长度2厂商自定义数据0xFF3自定义长度我见过最典型的超限设计是把“完整设备名”和128bit UUID一起往主广播里塞结果负载直接爆掉startAdvertising立刻报错码1。一个省字节的通用做法是主广播只放Flags、16bit服务UUID、厂商数据这三个必要字段设备名放到扫描响应里如果业务数据超过20字节则优先考虑压缩字段或者把数据拆成多帧用序列号拼接。模拟器设计负载时先写一个字节预算表再写代码能省去大量实测截断排查时间。4.2 手写一个厂商自定义广播段字节层面组装服务UUID只能表达“我是什么设备”真正的业务数据得靠厂商自定义段。假设要模拟一个温湿度传感器接收端协议要求按“版本号、温度、电量、状态位”四个字节上报组装代码可以这样写fun buildTempPayload(temperature: Float, batteryPercent: Int): ByteArray { val tempInt (temperature * 10).toInt() // 放大10倍保留一位小数 return byteArrayOf( 0x0A, // 协议版本号固定10 tempInt.toByte(), // 温度有符号字节单位0.1℃ batteryPercent.toByte(), // 电量0~100 0x01 // 状态位1在线 ) } val data AdvertiseData.Builder() .setIncludeDeviceName(false) .addManufacturerData(0x1234, buildTempPayload(26.5f, 87)) .build()addManufacturerData的第一个参数是厂商ID第二个参数是负载数据。系统在组装广播帧时会把厂商ID和负载拼在一起接收端看到的对应AD结构是长度类型0xFF厂商ID四个字节负载。计算一下总消耗这个厂商数据段本身占3字节头4字节负载7字节加上Flags的3字节整个主广播才10字节留给UUID和扫描响应非常充裕。这里要注意厂商ID不是随便填的。真实产品需要向蓝牙SIG申请但做模拟器阶段可以用0xFFFF或自定义范围内的ID只要保证接收端解析时用同一个ID就行。我在模拟阶段通常让“假设备”的厂商ID可配置方便切换不同网关协议。4.3 广播间隔、TX功率与连接性三个推荐配置组合广播参数直接关系到扫描端能不能稳定发现你。Android的AdvertiseSettings里三个核心参数值得仔细调广播模式、发射功率、连接性。广播模式三档对应不同频率LOW_LATENCY大约100ms一帧、BALANCED约250ms、LOW_POWER约1000ms。间隔越短扫描端发现越快但耗电也越高。发射功率则决定广播覆盖半径HIGH能把信号送得更远。参数推荐值适用场景advertiseModeLOW_LATENCY产测、现场调试要求秒级发现advertiseModeBALANCED长期运行的模拟信标续航优先txPowerLevelHIGH穿墙测试、大范围覆盖演示connectablefalse纯广播上报、信标模拟connectabletrue模拟可配对设备的发现阶段一个常见误解是“把间隔设到最小、功率设到最大就一定最好”。实际上很多手机的蓝牙协议栈会对广播参数做约束比如LOW_LATENCY在某些机型上实际仍按250ms左右发包这是芯片和系统固件共同决定的App层面无法绕过。所以调参之后一定要用扫描工具或硬件抓包确认实际生效值只看代码里的设置值会得出错误结论。5. 模拟BLE广播的避坑记录5个翻车现场与排查办法做这个方向代码通常半小时能写完真正耗时间的是广播发不出去、扫描端看不见、数据被截断这一类问题。下面这五条是实际调试中反复出现的坑每一条都是先讲现象再讲原因和解决办法。5.1 现象明明 startAdvertising 成功扫描端却看不到任何设备onStartSuccess已经回调日志也打印了成功但用手机上的扫描工具怎么刷都看不到模拟设备。这种情况我先确认扫描端有没有开启“被动扫描”之外的过滤条件很多扫描工具默认只显示可连接的设备而我们把setConnectable设成了false几百个广播帧被工具界面直接过滤掉了。解决办法把扫描工具的过滤条件清空或者换一个显示原始广播Data的工具。另外也要排查扫描端是否开启了重复设备过滤广播帧每100ms一帧如果过滤了重复设备界面上就只有一个条目容易误以为没广播。我做调试时习惯同时开两个工具一个看设备列表一个看原始广播字节两边对照能快速定位问题。5.2 现象回调里 onStartFailure 返回错误码 1 或 2错误码1是Data Too Large广播负载超过了31字节。我遇到的大部分情况是代码里setIncludeDeviceName(true)且设备名较长再加上UUID和厂商数据直接超限。错误码2是Too Many Advertisers同一时刻广播任务太多常见于调试机上同时跑的模拟App和系统蓝牙服务抢占广播资源。解决思路很直接错误码1就精简主广播把设备名挪到扫描响应里或者把128bit UUID换成16bit。错误码2就把其他调试App关掉重启蓝牙服务再试。需要注意的是错误码3重复启动也会在没做好状态管理时出现startAdvertising前要先判断当前是否已经在广播避免连续点按钮触发。5.3 现象App切到后台几分钟广播就“哑”了广播启动时一切正常手机熄屏或App切后台后扫描端就收不到广播了。这不是广播协议的问题而是系统进程管理把App冻结了。Android 8.0之后对后台应用有严格限制蓝牙广播的持续运行需要前台服务来保证进程存活部分机型还有更激进的省电策略前台服务不配合“电池白名单”也可能被清理。常规做法是启动一个前台Service在startAdvertising的同时startForeground并给用户一个常驻通知说明App正在模拟广播设备。个别国产定制系统还得引导用户把App加入后台运行白名单否则息屏后仍可能断广播。这一条与其说是技术坑不如说是机型适配坑排查时先用一台原生系统手机对比测试能快速区分是系统策略还是代码问题。5.4 现象广播内容被扫描端截断或解析成乱码扫描端看到的厂商数据只有前半段或者把负载解析成了一串看不懂的值。先说截断大概率是总字节超了31字节系统没有报错但协议栈把尾部数据丢弃了这种情况在Android低版本上更隐蔽。再说乱码通常是接收端拿到的厂商ID对不上网关按自己约定的厂商ID过滤数据而模拟端用了另一个ID数据显示错位。排查方法先把主广播负载缩到20字节以内排除截断干扰。然后在扫描工具里把广播的原始十六进制逐字节列出来手动按AD Structure格式拆一遍确认长度、类型、厂商ID、数据每一段都对得上。乱码问题大多能靠这一步发现往往是字节序大端小端没对齐。5.5 现象部分机型广播间隔设100ms实际表现出来却是秒级间隔扫描端发现设备的平均耗时明显变慢用HCI抓包确认广播间隔和设置值差了一个数量级。这是手机厂商功耗策略导致的协议栈对广播频率做了限制LOW_LATENCY在实际设备上可能被拉长到250ms甚至更久但回调里的settingsInEffect不会主动提示这个偏差。这基本无解App层面改不了协议栈的调度策略。能做的只有把广播间隔预算留足余量对发现时延有要求的场景搭配扫描端使用active scan主动扫描会额外请求扫描响应能加快发现速度对真实产品来说需要在固件侧设定合理的广播间隔并做真机验证。模拟器阶段如果发现时延异常先认定是机型限制别再花时间调参。6. 进阶验证iBeacon格式模拟与扫描端核对习惯广播模拟器真正见功力的是最后一步——让模拟数据在真实网关面前表现得和真设备一模一样。这里分享一个iBeacon格式的模拟技巧和我的验收习惯。iBeacon的厂商ID是0x004C负载由23个字节组成前两位固定0x02 0x15接着16字节UUID再跟2字节major、2字节minor、1字节txPower。如果网关端的协议是iBeacon解析就要严格按这个字节顺序组装负载fun buildIBeaconPayload(uuidHex: String, major: Int, minor: Int, txPower: Int): ByteArray { val payload ByteArray(23) payload[0] 0x02 payload[1] 0x15 val uuidBytes hexToBytes(uuidHex) System.arraycopy(uuidBytes, 0, payload, 2, 16) payload[18] (major shr 8).toByte() payload[19] major.toByte() payload[20] (minor shr 8).toByte() payload[21] minor.toByte() payload[22] txPower.toByte() return payload }组装时注意major和minor都是无符号短整型要手动拆高八位和低八位不要依赖默认的字节序。这个23字节负载加上厂商ID头之后整个厂商数据段正好27字节在31字节预算内还能再塞一个Flags字段。扫描端如果能看到完整的iBeacon信息说明广播负载的字节组织全部正确。我的验收习惯是三步走先在代码里把期望的负载用十六进制字符串写出来再让扫描工具显示原始广播数据最后逐字节比对。产出测工具时我还会把“期望值”和“实际值”直接打印到界面上一眼能看出哪一段对不上。多设备模拟场景我会用一个定时器轮流stop和start不同UUID的广播让一台手机在几分钟内轮播成三个不同的设备注意状态切换前必须先stop再start否则会触发ALREADY_STARTED错误。做这个方向到现在我的习惯是先算字节预算再看参数所有广播内容设计方案都先在纸上算一遍再写代码。广播模拟器看起来简单真正考验人的是格式的严谨和对机型差异的容忍度提前把预期管理好调试就能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑