简介:《iBeacon.zip》面向需要实现室内定位、后台唤醒与蓝牙长连接的 iOS 开发者,聚焦 iBeacon 单例管理、始终定位权限申请、后台信号监听及连接事件处理等常见难点,适合已有一定 iOS 基础、希望快速验证方案的中级开发者。压缩包共 2 个文件,以 1 个 .h 头文件和 1 个 .m 实现文件为主,包体约 3KB,接口声明与核心逻辑分离,便于直接对照阅读和移植。已有 581 人学习,可作为轻量级参考样例。资源以 Objective-C 源码形式呈现,可帮助读者理解前后台切换时的行为差异。代码围绕 iBeacon 管理类展开,读者可从中梳理监控区域设置、权限请求时机、后台唤醒触发条件以及蓝牙连接维持与异常处理思路,包括单例避免重复初始化、进入或离开区域后的响应、连接中断重连与耗电控制等要点,并据此排查后台监听失效等问题,快速搭建可运行的 iBeacon 唤醒方案。
1. 一颗纽扣电池跑一年:iBeacon.zip 把「唤醒」和「长连接」串成了一条线
iBeacon 唤醒 App 和蓝牙长连接,在很多需求文档里被写成同一件事,真上板子才发现是两条完全独立的链路:前者靠系统级的区域监听,后者靠 GATT 会话,中间隔着一次冷启动和一次地址交换。iBeacon.zip 这个包把三段拼在了一起——广播包怎么拆、系统怎么把你从后台拉起来、拉起来之后怎么建立一条能撑住业务的长连接。它适合做近场触发、展馆讲解、巡更打卡、设备配网这类场景的 Android 与 iOS 开发者,尤其是那些已经能扫到 beacon、却卡在「锁屏不弹窗」和「连上三秒就断」的人。包里没有花哨的定位算法,重点全在那条从射频到业务回调的完整路径上。
2. 拆开 31 字节广播包:UUID、Major、Minor 与那个必须校准的 TX Power
拿到一个 beacon,第一件事不是写扫描代码,是先把空中飞的那串字节打印出来。因为后面所有的「扫不到」「唤不醒」「距离乱跳」,追到根上多半是这一段没对齐:厂商 ID 的位置、UUID 的字节序、Major 和 Minor 谁在前谁在后、TX Power 到底是出厂值还是实测值。这一段理清楚了,剩下的都是工程问题。
2.1 从 Preamble 到 CRC:一帧广播的四层结构
BLE 广播帧在 1Mbps PHY 下的结构是固定的,扫描端 API 通常只把 AdvData 这一段吐给你,但排查问题时要知道自己看到的是哪一层。
| 字段 | 长度 | 广播信道典型值 | 说明 |
|---|---|---|---|
| Preamble | 1 字节 | 0xAA | 1Mbps PHY 的前导码,2Mbps 下是 0xAAAA |
| Access Address | 4 字节 | 0x8E89BED6 | 广播信道固定值,连接态会换成随机值 |
| PDU Header | 2 字节 | 随类型变化 | 低 4 位是 PDU 类型,iBeacon 常见 0b0010 |
| AdvA | 6 字节 | 公共地址或随机地址 | 就是 Android 侧拿到的 device.address |
| AdvData | 0~31 字节 | 见 2.2 | 所有业务信息都在这 31 字节里 |
| CRC | 3 字节 | 由前向计算 | 24 位校验,硬件一般自动处理 |
PDU 类型那一栏值得单独记一下:0b0010 是 ADV_NONCONN_IND,不可连接、不可扫描;0b0000 是 ADV_IND,可连接可扫描。iBeacon 规范要求用前者,这就是第 4 章「为什么连不上」的物理层答案。你在扫描回调里看到设备,不代表你能连它。
AdvData 内部又是一串 AD Structure,每段格式是「长度 + 类型 + 数据」。长度字节统计的是「类型 + 数据」,不含它自己,这个差一位的错误在自研解析器里非常常见,会导致后面的段全部错位。
2.2 厂商数据段:1A FF 4C 00 02 15 这六个字节
一个标准 iBeacon 广播的 AdvData 通常是 29 字节,由两段 AD Structure 组成:
02 01 06 // Flags:LE General Discoverable,不支持 BR/EDR 1A FF 4C 00 02 15 <16 字节 UUID> <2 字节 Major> <2 字节 Minor> <1 字节 TX Power>逐字节解释这 26 字节的厂商段(0x1A = 26,含类型字节 0xFF):
FF:AD 类型,Manufacturer Specific Data。4C 00:厂商 ID。规范里固定为 0x004C,但空中是小端传输,所以先看到 4C 再看到 00。自己写过滤器时按 0x004C 这个整数去填,别照抄字节序。02:beacon 类型,标识这是 iBeacon 而不是同厂商的其他广播。15:后面跟的 beacon 数据长度,21 字节 = 16 + 2 + 2 + 1。UUID:16 字节,用来划分「哪一批设备」,通常一个项目一个 UUID。Major/Minor:各 2 字节,大端。Major 划「区域」,Minor 划「点位」。TX Power:1 字节,有符号 int8,单位 dBm。它的含义是「这个 beacon 在 1 米处的标称 RSSI」,用来做距离换算的参考基点。
TX Power是整段里最容易被忽略、也最容易翻车的一个字节。出厂值往往是产线用标准天线在屏蔽箱里测的,装进外壳、贴上电池、塞进展台背板之后就不准了。后面第 6 章会讲怎么重新标定它。
2.3 字节序陷阱:为什么你的 Major 总是差 256
三处字节序必须分开记:
- 厂商 ID 在规范里写成 0x004C,空中是小端,所以字节流里是
4C 00。很多 SDK 的setManufacturerData(0x004C, ...)收的是整数,内部自己处理,你只需要给 0x004C。 - UUID 按大端逐字节原样传输,没有翻转。把 16 字节直接转成标准 UUID 字符串即可。
- Major / Minor 是标准大端(网络序),
00 64就是 100。有些国产模块的配置工具会让你填十六进制,同时又按小端下发,结果扫描端解出来是 25600。这类问题在联调阶段一定要打印原始字节,别只信上层封装给你的 int。
iOS 侧通过CLBeacon拿到的major/minor是NSNumber,已经处理好了;Android 侧如果你自己解析 manufacturer data,就得自己ByteBuffer.order(ByteOrder.BIG_ENDIAN)。两边对不上时,先怀疑自己这边的解析,别急着怀疑设备。
2.4 用一段脚本把广播包解出来
联调时最省时间的做法,是写一个不依赖任何 SDK 的解析器,把手机日志里抓到的十六进制串直接喂进去。
import struct import uuid def parse_ibeacon(mfr: bytes) -> dict: """mfr 是 Android/iOS 回调里 manufacturerData 的原始字节, 形如 4C 00 02 15 <uuid16> <major2> <minor2> <tx1>,共 25 字节。""" if len(mfr) < 25: raise ValueError(f"长度不足: {len(mfr)}") if mfr[0:2] != b"\x4c\x00": raise ValueError("厂商 ID 不是 0x004C") if mfr[2] != 0x02 or mfr[3] != 0x15: raise ValueError("不是 iBeacon 类型段") beacon_uuid = uuid.UUID(bytes=mfr[4:20]) # 大端原样,不翻转 major, minor = struct.unpack(">HH", mfr[20:24]) # 大端无符号 tx_power = struct.unpack("b", mfr[24:25])[0] # 有符号 int8 return { "uuid": str(beacon_uuid).upper(), "major": major, "minor": minor, "tx_power": tx_power, } def rssi_to_meters(rssi: int, tx_power: int, n: float = 2.0) -> float: """n 是路径损耗指数,空旷环境 2.0,室内遮挡多时取 2.5~3.5""" return 10 ** ((tx_power - rssi) / (10 * n)) if __name__ == "__main__": raw = bytes.fromhex("4C000215E2C56DB5DFFB48D2B060D0F5A71096E000640001C5") info = parse_ibeacon(raw) print(info) print("距离估计:", round(rssi_to_meters(-72, info["tx_power"]), 2), "m")逻辑说明:mfr[0:2]校验厂商 ID、mfr[2:4]校验类型和长度,这两道关卡能挡掉同厂商其他格式的广播;uuid.UUID(bytes=...)直接按大端构造,不需要自己做 hex 拼接;struct.unpack(">HH", ...)里的大于号就是大端,漏掉会得到反过来的值;struct.unpack("b", ...)用有符号字节,因为 TX Power 一定是负数(-59 ~ -70 dBm 区间)。
参数说明:n是路径损耗指数,它决定了距离曲线多陡。空旷展厅用 2.0,有货架和人流的仓库用 2.5~3.0,隔墙场景直接到 3.5。这个值不具备通用性,必须按现场调,第 6 章给了一个标定流程。
提示:把这段脚本挂到手机日志的实时输出上,每次扫到就打印一行,比在 IDE 里打断点快得多。
3. 唤醒 App:区域监听、PendingIntent 与冷启动的 800 毫秒预算
「iBeacon 唤醒 App」这个需求,写清楚之后其实是三句话:App 不在前台时能被拉起来、拉起来之后知道是哪个 beacon 触发的、拉起来的这段时间里来不及做的事要有兜底。两端机制完全不同,iOS 靠系统级区域监听,Android 靠扫描回调投递,混淆着写必然出问题。
3.1 唤醒和测距是两套机制,别混着用
先把两端的四种能力对齐:
| 能力 | iOS | Android | 能否在进程未运行时触发 |
|---|---|---|---|
| 区域内/外事件 | startMonitoring(for:) | 无等价物,靠 PendingIntent 扫描回调 | iOS 可以(用户强杀除外) |
| 实时距离回调 | startRangingBeacons | 普通ScanCallback | 不能,需要进程存活 |
| 后台持续回调 | 需要 Always 定位权限 + 后台模式 | 需要前台服务或 PendingIntent | 视系统版本而定 |
| 拿到原始 RSSI | CLBeacon.rssi | ScanResult.rssi | 同上 |
关键差异在这一行:iOS 的 monitoring 是由系统蓝牙栈维护的,你的 App 即使被系统回收,进入区域时也会被重新拉起执行didEnterRegion;Android 没有这层系统服务,PendingIntent 只是把你的进程「拉起来投递一次扫描结果」,能不能起来还取决于厂商的后台策略。
所以架构上要分开:唤醒只负责「知道人来了」并做最小动作(发本地通知、写一条标记、启动短任务),真正的距离判断和业务逻辑放到前台或前台服务里做。把重活塞进唤醒回调,iOS 会被系统掐掉,Android 会 ANR。
3.2 iOS 端:CLBeaconRegion 的三级粒度与回调时机
CLBeaconRegion支持三级粒度:只给 UUID(最粗,区域大)、UUID + Major、UUID + Major + Minor(最细)。粒度越细,系统过滤越严格,误唤醒越少,但部署时改点位就要改代码,所以生产环境一般用 UUID + Major。
import CoreLocation final class BeaconWatcher: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() // 与设备端配置的 UUID 必须完全一致 private let regionUUID = UUID(uuidString: "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0")! func start() { manager.delegate = self // 唤醒依赖 Always 授权;OnlyWhenInUse 拿不到后台事件 manager.requestAlwaysAuthorization() let region = CLBeaconRegion(uuid: regionUUID, major: 100, minor: 1, identifier: "hall-a-booth-1") region.notifyOnEntry = true region.notifyOnExit = true region.notifyEntryStateOnDisplay = true // 亮屏时补一次状态判断 manager.startMonitoring(for: region) } func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { // 这里只有十几秒量级的执行窗口,不要做网络请求和蓝牙连接 UserDefaults.standard.set(Date(), forKey: "lastEnterAt") NotificationCenter.default.post(name: .beaconEntered, object: region.identifier) } func locationManager(_ manager: CLLocationManager, didDetermineState state: CLRegionState, for region: CLRegion) { // 冷启动时系统可能先给 state 而不是 enter 事件,两个回调都要处理 if state == .inside { NotificationCenter.default.post(name: .beaconEntered, object: region.identifier) } } }逻辑说明:notifyEntryStateOnDisplay = true解决的是「人已经到了但屏幕刚点亮」的场景,系统会在亮屏时补一次didDetermineState,很多「锁屏不弹、解锁才弹」的问题就是缺了这一个开关。didDetermineState必须处理,因为 App 被冷启动时,系统有时只给状态不给 enter 事件。
参数说明:identifier是本地标识,同一个 App 内不能重复,stopMonitoring(for:)时要用同一个值;major/minor传 nil 表示不限制该层级。requestAlwaysAuthorization()之后用户可能只给了 While In Use,这时后台事件会静默丢失,要在didChangeAuthorization里检查manager.authorizationStatus并做引导。
还有一个常见误区:只做进出场唤醒,不需要在 Xcode 里勾 location 后台模式;一旦你要在后台持续 ranging 拿距离,就必须同时具备 Always 权限和 location 后台模式,否则 ranging 回调会在退到后台后停掉。这两件事的配置要求不一样,别一把梭全勾上,审核时会被追问用途。
3.3 Android 端:PendingIntent 把扫描结果投递到接收器
Android 从 8.0(API 26)开始提供startScan(filters, settings, pendingIntent)这个重载,扫描结果不走回调接口,而是通过 PendingIntent 投递到你的 BroadcastReceiver,进程不在也能被拉起来。这是目前做 beacon 唤醒最省电也最稳的方式。
import android.app.PendingIntent import android.bluetooth.BluetoothLeScanner import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanSettings import android.content.Context import android.content.Intent private const val BEACON_COMPANY_ID = 0x004C fun startBeaconScan(context: Context) { val scanner = context.getSystemService(android.bluetooth.BluetoothManager::class.java) .adapter.bluetoothLeScanner ?: return // 只匹配厂商 ID + beacon 类型两个字节,后面的 UUID 交给应用层判断 val filter = ScanFilter.Builder() .setManufacturerData( BEACON_COMPANY_ID, byteArrayOf(0x02, 0x15), byteArrayOf(0xFF.toByte(), 0xFF.toByte()) // 掩码,必须给 ) .build() val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0) // 延迟上报会丢掉实时性 .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .build() val intent = Intent(context, BeaconReceiver::class.java).apply { setPackage(context.packageName) } val pi = PendingIntent.getBroadcast( context, 1001, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE // 12+ 必须 MUTABLE ) scanner.startScan(listOf(filter), settings, pi) }接收端:
class BeaconReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { // 只处理「首次发现」,避免每秒一次重复触发 val type = intent.getIntExtra(BluetoothLeScanner.EXTRA_CALLBACK_TYPE, -1) if (type != ScanSettings.CALLBACK_TYPE_FIRST_MATCH) return val rssi = intent.getIntExtra(BluetoothLeScanner.EXTRA_RSSI, 0) val device = intent.getParcelableExtra( BluetoothLeScanner.EXTRA_DEVICE, android.bluetooth.BluetoothDevice::class.java ) ?: return // 落地最小动作:写标记 + 本地通知,连接等进程稳定后再做 BeaconState.markArrived(device.address, rssi) } }逻辑说明:setManufacturerData(id, data, mask)的三参重载是关键——两参版本要求厂商数据整段完全相等,而 beacon 的厂商数据后面还跟着 UUID,永远匹配不上,这是「过滤器写了但一条都收不到」的头号原因。掩码FF FF表示只比较前两个字节。
CALLBACK_TYPE_FIRST_MATCH让系统在「从无到有」时投递一次,而不是每个广播周期都投一次,配合ScanSettings能显著降低唤醒频率。
参数说明:setScanMode(SCAN_MODE_LOW_LATENCY)在唤醒场景其实可以降到SCAN_MODE_LOW_POWER,因为 beacon 是持续广播的,低频扫描也能扫到,代价是延迟从秒级变成十几秒级。用 PendingIntent 做后台唤醒时,系统本身就会做扫描窗口调度,不要指望做到实时。
FLAG_MUTABLE是 Android 12 的硬性要求,系统需要往 Intent 里写 extras,改成FLAG_IMMUTABLE会直接抛异常。这是一个非常典型的升级翻车点。
注意:PendingIntent 注册的扫描不会因为进程被杀而停止,只要 PendingIntent 还活着就一直在扫。测试完记得
stopScan(pendingIntent),否则线上会收到大量「用户投诉耗电」。
3.4 唤醒之后的时序预算:冷启动那 800 毫秒花在哪
从系统回调到你能发第一条 GATT 写请求,中间这条链路的耗时要有数。经验值是:进程创建 + Application.onCreate 100~300ms,蓝牙适配器就绪 200~500ms(取决于是否刚被初始化),扫描到目标设备并拿到可连接句柄 300ms~2s,连接建立 100~500ms,服务发现 100~300ms。加起来轻松超过 1 秒。
所以Application.onCreate里绝对不能放重活:网络框架、埋点 SDK、图片库的初始化全部延后到首个 Activity 的onCreate之后,或者用懒加载。唤醒回调里只做三件事——记状态、发通知、把启动参数塞进队列。真正要连设备的时候,从队列里取。
4. 蓝牙长连接:拿到地址之后的连接参数、MTU 与断线重连
很多人以为扫到 beacon 就能直接连,代码一跑connectGatt返回一个空对象或者立刻回调 status 133。原因在第 2 章的表里写过了:iBeacon 用的是不可连接广播。这一章解决的是「怎么从不可连接过渡到可连接」,以及连上之后怎么让它别断。
4.1 iBeacon 广播为什么默认连不上
BLE 广播的 PDU 类型决定了这帧能不能被连接。ADV_NONCONN_IND 的语义就是「说完就走,别找我」,扫描端收到它只能读数据,不能发连接请求。这是 iBeacon 省电的根本:不维护连接、不响应握手,一颗纽扣电池能撑一年。
工程上有三种处理方式:
- 设备侧双广播。部分 BLE 芯片支持多广播集,同时发一路 iBeacon 广播(唤醒用)和一路可连接广播(业务用),两路的 AdvA 可能不同。这种方式体验最好,但对芯片有要求。
- 设备侧分时切换。平时只发 iBeacon 广播;收到某个触发(按键、定时器、或者上一次连接断开后的时间窗)后切到可连接广播 30~60 秒,手机在这个窗口内连上。功耗和体验折中,实现最多。
- 手机侧靠其他通道。beacon 只负责唤醒,连接地址从服务端拿,或者设备同时开着一个经典蓝牙 SPP / Wi-Fi 热点。这种方案复杂度最高,一般在有屏幕或网关的场景才用。
包里的示例走的是第 2 种:扫描到目标 beacon 后,先按 UUID + Major + Minor 查出缓存的可连接地址,再尝试直连;连不上就在退避队列里等下一轮。
4.2 建立连接:地址类型、连接参数与 MTU
Android 侧的连接骨架:
private var gatt: BluetoothGatt? = null fun connect(context: Context, device: BluetoothDevice) { // autoConnect = false:直连,快但失败率高;true 走后台白名单,慢但省电 gatt = device.connectGatt(context, false, object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { // 133/8 这类错误必须 close 后重建,不能复用旧对象 g.close() gatt = null ReconnectPolicy.schedule(device) return } if (newState == android.bluetooth.BluetoothProfile.STATE_CONNECTED) { // 先协商 MTU,等 onMtuChanged 再 discoverServices,顺序别乱 g.requestMtu(247) g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) } } override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { // 实际可用负载 = mtu - 3 LinkProfile.mtu = mtu g.discoverServices() } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { if (status != BluetoothGatt.GATT_SUCCESS) return // 到这里才可以写特征值 g.getService(DataServiceUUID)?.getCharacteristic(DataCharUUID) ?.let { LinkProfile.ready(g, it) } } }, BluetoothDevice.TRANSPORT_LE) }逻辑说明:TRANSPORT_LE必须显式指定,双模设备上不指定会走 BR/EDR 通道,表现是连接超时或连上但发现不了服务。requestMtu(247)放在服务发现之前是常见做法,但一定要在onMtuChanged回调之后再discoverServices,否则某些协议栈上会出现「服务列表为空」。requestConnectionPriority在传输完成后要降回CONNECTION_PRIORITY_LOW_POWER,否则一整条连接都用高功耗参数跑着。
参数说明:MTU 请求 247 是行业惯例(ATT 默认 23,减去 3 字节头剩 20 字节负载;247 减去 3 剩 244)。实际协商结果因芯片而异,常见落在 100~247 之间,要以回调值为准,不要硬编码。连接优先级三档的典型连接间隔是高优先级约 11.25ms、平衡约 30~50ms、低功耗 100ms 以上,间隔越小吞吐越高、功耗越大。
iOS 侧不暴露 MTU 协商接口,系统会自动协商到较优值(一般 180 以上),写入超过单包长度的数据时框架会自动分包,不需要手动切。
这里有个绕不开的坑:iOS 拿不到设备的 MAC 地址。CBPeripheral.identifier是系统为这台设备在这个 App 下生成的 UUID,换一台手机、重装 App 都可能变。所以主键设计必须是「beacon 的 UUID + Major + Minor」这种业务标识,MAC 只作为 Android 侧的加速缓存。把 MAC 当唯一 ID 写进数据库,上线后一定会返工。
4.3 保活与重连:把断线当成常态
BLE 连接在移动端不是「长连接」,是「频繁重连的短连接」。以下情况必然断:人走出范围、手机进省电模式、系统回收蓝牙资源、设备侧重启。设计目标不是不断,是断了能在可接受时间内自己回来。
object ReconnectPolicy { private val delays = longArrayOf(1_000, 2_000, 5_000, 10_000, 30_000) private val attempts = mutableMapOf<String, Int>() fun schedule(device: android.bluetooth.BluetoothDevice) { val key = device.address val n = (attempts[key] ?: 0).coerceAtMost(delays.lastIndex) attempts[key] = n + 1 val delay = delays[n] // 用 Handler 或 WorkManager 都可以;短时重连优先 Handler android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ // 重连前确认适配器还开着 if (android.bluetooth.BluetoothAdapter.getDefaultAdapter()?.isEnabled == true) { BleManager.connect(android.app.Application::class.java.cast(null) ?: return@postDelayed) } }, delay) } fun reset(address: String) { attempts.remove(address) } }逻辑说明:退避序列从 1 秒涨到 30 秒封顶,避免设备不在身边时疯狂重连耗电。连接成功后必须调reset()清零,否则下一次断线会直接从 30 秒起步。重连前检查适配器状态,因为蓝牙被用户关掉时connectGatt会直接返回 null。
参数说明:序列长度和封顶值按业务调。打卡类可以放宽到 60 秒封顶,实时控制类要压到 5 秒以内,但功耗会明显上升。supervision timeout(监督超时)由协议栈协商,Android 不直接暴露,但连接间隔和 slave latency 会影响它:间隔越大、latency 越高,超时越长,断线检测越迟钝。
对「人在附近但连接反复掉」的情况,我的处理顺序是:先看 RSSI 是不是长期低于 -85 dBm(太远),再看设备是否用了随机可解析地址(地址变了导致重连连到旧地址),最后才怀疑协议栈。前两个占了我踩过的坑里的大头。
5. 避坑与排查:扫描不到、唤醒失灵、连接 133 的现场记录
这一章是几条真实的排查记录,按「现象 → 原因 → 解决」写。每条我都尽量还原当时看到的东西,方便你对照自己的日志。
5.1 现象:iOS 上锁屏能收到通知,划掉进程后彻底没反应
原因:iOS 的区域唤醒由系统维护,但用户手动从多任务界面划掉 App 之后,系统会认为用户不希望它再运行,monitoring 事件不再拉起进程。这是设计如此,不是 bug,任何方案都绕不过去。同理,低电量模式下后台事件也会被大幅抑制。
解决:接受这个边界,把关键路径做成「不依赖冷启动也能完成」——比如进场通知由设备侧或服务端补推,App 下次启动时从本地缓存恢复状态。产品文档里必须写清楚「用户强杀后不保证唤醒」,否则验收时一定扯皮。另外要检查didChangeAuthorization,用户把 Always 降级成 While In Use 时同样会静默失效。
5.2 现象:Android 12 以上扫描列表永远是空的,权限全给了
原因:多半是BLUETOOTH_SCAN声明里加了android:usesPermissionFlags="neverForLocation"。这个标记的作用是「我承诺不拿蓝牙结果推断位置」,系统因此不要求定位权限,但部分厂商 ROM 会在这个标记下把带位置属性的广播(包括 iBeacon)直接过滤掉,或者只返回不含位置信息的结果。
解决:先去掉这个标记,补上ACCESS_FINE_LOCATION和(Android 11+ 后台扫描所需的)ACCESS_BACKGROUND_LOCATION做对比测试。如果确实需要neverForLocation来规避定位权限弹窗,就要接受「部分机型扫不到 beacon」的现实,在代码里做机型和结果数的兜底统计,别等灰度了才发现。
5.3 现象:过滤器写了厂商 ID,但一条回调都不触发
原因:ScanFilter.Builder().setManufacturerData(0x004C, byteArrayOf(0x4C, 0x00))用的是两参重载,语义是「厂商数据必须与给定数组完全相等」。beacon 的厂商数据有 25 字节,永远不可能等于 2 字节。
解决:改用三参重载并给掩码:setManufacturerData(0x004C, byteArrayOf(0x02, 0x15), byteArrayOf(0xFF.toByte(), 0xFF.toByte()))。注意第二个参数是从「beacon 类型」字节开始算的,不含厂商 ID 本身,很多人在这里又错一次。
5.4 现象:connectGatt 之后马上回调 status 133 或 8,反复重试都失败
原因:常见有四种。一是没等onMtuChanged就discoverServices或写特征值;二是复用了已经 close 的BluetoothGatt对象;三是系统级连接数超限(一台手机同时连 7 个左右就到顶了,很多应用自己泄漏连接);四是设备侧连接窗口已经关闭,还在对着不可连接广播发起连接。
解决:onConnectionStateChange里只要 status 非 0,一律gatt.close()并把引用置空,走退避重连,不要试图disconnect()后复用。每次连接前检查全局连接池,超过 5 条就主动释放最久未用的。连之前先用一次短扫描确认目标此刻是可连接广播(PDU 类型不对就别试)。
5.5 现象:距离数字乱跳,人站着不动从 0.5 米跳到 8 米
原因:RSSI 本身抖动就有 ±6~10 dBm,换算成距离在中远区间会被指数放大。另外 TX Power 用的是出厂值,装进外壳后偏差能到 10 dBm 以上,导致整条曲线整体平移。
解决:先按第 6 章重标 TX Power,再上滤波(滑动平均或卡尔曼),并且不要用瞬时距离做业务判断,改成「连续 N 次进入阈值区间才触发」。工程上更稳的做法是直接用 RSSI 阈值分档(近/中/远),而不是报一个看起来精确到小数点的米数。
6. 把 RSSI 换算成可信距离:1 米标定、滤波与上线前的验证脚本
距离这块我吃过亏,所以现在的做法固定成三步:先标定 TX Power,再选滤波器,最后跑一遍静态验证。
标定的方法很土但有效:把 beacon 放在卷尺 1 米处、离地 1.2 米、周围 2 米内没有金属和人体,用手机连续采 200 帧 RSSI,去掉首尾各 20%,取中位数。这个中位数就是该型号在该外壳下的真实 TX Power。同一批设备抽 5 台测,离散度超过 3 dBm 就说明产线一致性不行,得按设备单独标。
滤波器的选择可以直接看下面这张表:
| 方案 | 延迟 | 抖动抑制 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| 原始 RSSI | 无 | 无 | 无 | 只看有无,不做距离 |
| 滑动平均(窗口 8~16) | 中 | 中 | 低 | 人员进出、粗分区 |
| 中位数滤波(窗口 5~9) | 中 | 中高 | 低 | 有突发干扰的现场 |
| 一维卡尔曼 | 低 | 高 | 中 | 需要平滑距离曲线的展示 |
| 卡尔曼 + 阈值滞回 | 低 | 高 | 中 | 触发类业务,推荐 |
触发类业务我一般用最后一种:卡尔曼输出平滑值,再做「近场阈值 -65 dBm 进入、-72 dBm 退出」的滞回,避免人在边界上来回走导致反复触发。
class RssiKalman: """一维卡尔曼,状态是 RSSI 真值,观测是每次扫到的 RSSI""" def __init__(self, q=0.05, r=9.0, x0=-65.0, p0=1.0): self.q, self.r, self.x, self.p = q, r, x0, p0 def update(self, z: float) -> float: self.p += self.q # 预测:过程噪声 k = self.p / (self.p + self.r) # 卡尔曼增益 self.x += k * (z - self.x) # 用观测修正 self.p *= (1 - k) # 更新协方差 return self.x def calibrate(samples, trim_ratio=0.2): """离线标定:去掉首尾极值后取中位数,作为 1 米处 TX Power""" s = sorted(samples) k = int(len(s) * trim_ratio) core = s[k:len(s) - k] if k else s return core[len(core) // 2]q是过程噪声,越大越相信新观测、跟随快但抖;r是观测噪声,RSSI 场景一般给 8~12。p0是初始协方差,给 1.0 就够。这两个参数没有普适值,我在展厅现场用的q=0.05, r=9.0,仓库场景会把r提到 12 压抖动。
上线前我会跑一个静态验证脚本:让测试同学拿着手机在 1 米、3 米、5 米三个点位各站 60 秒,记录滤波后距离的均值和标准差。判据是 1 米点位均值落在 0.8~1.3 米、标准差小于 0.4 米,3 米点位均值误差小于 40%。达不到就回炉调n和 TX Power,不要硬着头皮上,因为后面所有的业务规则都是建立在这个距离上的。
从那以后我每次拿到一个新型号的 beacon,都会先把它按在卷尺 1 米处跑满 200 帧再写第一行业务代码,这个习惯帮我省掉了至少三轮返工。希望帮到你。
本文还有配套的精品资源,点击获取