简介:iBeacon.zip 面向需要实现 iOS 端 iBeacon 后台唤醒与蓝牙长连接的开发者,聚焦定位权限申请、信号监听、单例管理与连接事件处理等常见难点。压缩包共 2 个文件,由 1 个 .h 头文件和 1 个 .m 实现文件构成,整体约 3KB,体积轻量,便于直接查阅接口声明与核心实现逻辑。已有 581 人学习,说明其对 iBeacon 监控与后台唤醒场景具有一定参考价值。内容围绕管理类的封装思路展开,可帮助读者理解如何以单例统一初始化、监听 iBeacon 信号、在后台维持蓝牙连接,并兼顾定位权限与功耗控制;适合作为室内定位、近场推送或零售互动类 iOS 项目的实现参考,也可用于排查后台唤醒失效、连接异常等问题。
1. 从展台前那一下震动说起:一个 Beacon 包如何把杀掉的 App 拉回来
上周帮一个做展馆导览的朋友看现场,他演示给我看:手机揣在兜里、屏幕黑着,人走到某个展项前三米,手机震了一下,App 自己把讲解页推了出来。整个过程没有人点图标,也没有人解锁。把他这套东西拆开,核心就是iBeacon.zip里那套东西——BLE 广播、区域监听唤醒、进区后建立蓝牙长连接下发指令。
这个工程解决的是「近场自动触发」这一类需求:室内定位、展馆导览、考勤打卡、门店到店提醒、设备靠近即连。做 Android 和 iOS 双端、需要 Beacon 唤醒和后续 BLE 通信的开发者都能直接拿去改。它不复杂,但坑全在后台策略和连接时序上,这些才是真正值钱的部分。
2. 技术选型与工程骨架:双模设备、两套协议、一份 core 库
先把最容易误解的一点说清:iBeacon 本身不是「连接」,它是纯广播。广播帧发出去就完事,设备不知道谁收到了,也没法回传数据。工程里之所以能「唤醒 + 长连接」,靠的是设备双模:空闲时按 iBeacon 格式广播,App 收到并匹配区域后主动发起 GATT 连接,设备切到连接态,双方才开始真正通信。
2.1 为什么非要拆成「广播唤醒 + GATT 长连接」两条腿
如果只做广播,你拿不到任何上行数据。用户签到成功没有、电量还剩多少、固件版本是哪个,广播帧里都没有位置放,而且广播是单向的,没有 ACK,丢帧你也不知道。
如果只做长连接,问题是成本和并发。BLE 从设备同时能挂的连接数通常只有 3 到 8 个(看芯片型号),一个展项前面同时站五个人就开始抢连接;而且常驻连接的功耗比广播高一个数量级,纽扣电池撑不过几周。
所以这个工程的分工很明确:广播负责「发现与唤醒」,代价是几十微安级的平均电流,CR2032 能撑一年上下;GATT 负责「指令下发与数据回传」,只在真正需要交互时才建立。从设备侧的逻辑是「广播态待机,收到连接请求后停广播或把间隔拉到 1s 以上,进入连接态,断开后回到广播态」。
这个切换有个隐含约束要提前想清楚:广播间隔决定唤醒延迟。间隔 100ms,平均唤醒延迟在几百毫秒级;间隔 1s,最坏情况要等一秒多,再叠加系统层的区域判定去抖,体感就是「我都走到跟前了还没反应」。项目里默认给的是 300ms 到 500ms 这一档,是功耗和响应速度的一个折中。
2.2 目录结构:core 库和 App 分离,双端各一套
工程按平台分成两块,中间靠一份相同的报文定义对齐。Android 侧把核心逻辑抽成独立 module,App 只做界面和日志展示,这样二次开发时可以把 core 直接搬进自己的宿主工程。
iBeacon/ ├── android/ │ ├── app/ 演示 App:扫描列表、唤醒日志、连接控制面板 │ └── beacon-core/ 可复用的核心库,不依赖任何 UI │ └── src/main/java/com/demo/beacon/core/ │ ├── BeaconFrame.kt 报文数据模型 │ ├── BeaconParser.kt 厂商数据段解析 │ ├── BeaconScanner.kt 扫描生命周期管理 │ ├── RegionMonitor.kt 进出区域判定与去抖 │ └── GattSession.kt 连接、读写、退避重连 ├── ios/ │ ├── BeaconDemo/ 演示 App │ └── BeaconKit/ Swift 版本核心 │ ├── BeaconRegionManager.swift 区域监听与 ranging │ ├── RssiFilter.swift RSSI 滤波与距离估算 │ └── BeaconConnection.swift 外设连接与状态恢复 ├── tools/ │ ├── build_adv.py 按参数生成 31 字节广播数据,方便对齐嵌入式端 │ ├── frame_decoder.py 把抓包日志转成可读报文 │ └── rssi_calibrate.py 现场标定 n 值和一米校准值 └── README.md模块边界上有两个刻意的设计。beacon-core不持有 Activity 或 Context 的强引用,扫描回调通过接口往外抛,避免宿主页面销毁后泄漏;RegionMonitor和BeaconScanner是两个独立职责,前者只管「进区/出区」这个布尔状态的变化,后者管「持续拿到 RSSI 序列」,因为这两件事在系统层面的权限和调用方式完全不同,混在一起写后面很难拆。
2.3 核心数据模型:UUID、major、minor、txPower 四件套
所有平台的 iBeacon 报文都是同一套字段,差别只在 API 名字。工程里把它收敛成一个数据类,双端字段名保持一致,方便对照日志。
// beacon-core/BeaconFrame.kt data class BeaconFrame( val uuid: String, // 16 字节,业务上区分「哪一批设备 / 哪个项目」 val major: Int, // 2 字节无符号,0..65535,通常映射「楼层 / 区域」 val minor: Int, // 2 字节无符号,0..65535,通常映射「具体点位 / 设备号」 val txPower: Int, // 1 字节有符号,1 米处的 RSSI 校准值,实测量产固件多在 -59 到 -65 val rssi: Int, // 当前帧的信号强度,负数,越接近 0 表示越近 val timestamp: Long // 收到该帧的本地毫秒时间戳,用于去抖和滤波 ) { // 业务主键:一般用 uuid + major + minor 三元组唯一确定一个物理点位 val key: String get() = "$uuid:$major:$minor" }几个字段的业务映射建议直接照抄:uuid不要一个项目一个,而是一个业务线一个,方便在同一台手机上隔离不同厂商的设备;major用来分层,比如 1 是 B1 层、2 是一层;minor才是具体的点位号,从 1 开始顺序编号。这样后台配置表里查一条记录就能知道「用户在哪个位置的哪台设备旁」。
txPower是距离估算的基准,不是发射功率。它表示「设备在 1 米处,接收端应该测到多少 dBm」,出厂前要拿标准手机在 1 米处实测标定,不同批次固件这个值可能差 3 到 5 dBm,直接用默认值会让距离整体偏大或偏小。rssi则是每帧都在变的观测量,单帧不可信,必须做滤波,这一点在第 5 章会展开。
3. 广播与扫描链路:从 31 字节厂商数据到一次距离估算
链路上有三个角色:设备侧的广播封装、Android 侧的扫描解析、iOS 侧的监听与测距。三处对字节布局的理解必须完全一致,否则现象就是「能看到设备但永远匹配不上区域」,这种问题没有日志几乎查不出来。
3.1 报文封装:31 字节里到底放了什么
BLE 广播包的有效载荷上限是 31 字节,iBeacon 用掉其中 30 字节,格式是固定的:一个 AD 结构,长度字段0x1A(26 字节数据),类型0xFF(厂商自定义数据),然后是 2 字节公司标识、1 字节子类型0x02、1 字节长度0x15(21 字节),最后是 16 字节 UUID、2 字节 major、2 字节 minor、1 字节 txPower。
tools/build_adv.py就是干这个的,嵌入学或做测试夹具时可以直接用,比手算十六进制靠谱:
import struct def build_ibeacon_payload(uuid_hex: str, major: int, minor: int, tx_power: int) -> bytes: """生成 iBeacon 的厂商自定义数据段(不含外层 AD 长度/类型)""" uuid_bytes = bytes.fromhex(uuid_hex.replace("-", "")) assert len(uuid_bytes) == 16, "UUID 必须是 16 字节" assert 0 <= major <= 0xFFFF and 0 <= minor <= 0xFFFF, "major/minor 超出 2 字节范围" # 公司标识(2) + 子类型(1) + 数据长度(1) + UUID(16) + major(2) + minor(2) + txPower(1, 有符号) return ( b"\x4c\x00" # 固定的公司标识,小端写入 + b"\x02" # 子类型:Beacon + b"\x15" # 后续数据长度 21 字节 + uuid_bytes + struct.pack(">H", major) # 大端 + struct.pack(">H", minor) # 大端 + struct.pack("b", tx_power) # 有符号单字节,注意是负数 ) def to_full_adv(payload: bytes) -> bytes: """补上外层 AD 结构,凑成完整广播数据""" return bytes([len(payload) + 1, 0xFF]) + payload if __name__ == "__main__": p = build_ibeacon_payload("8f3a1c20-6b4d-4e7a-9c11-2d5b7e90a3f4", 1, 37, -59) print(to_full_adv(p).hex(" "))三个参数点要留意。major和minor是大端写入,解析端如果按小端读,会得到完全错乱的数字,而且因为都在合法范围内,不会报错,只会「匹配不上」。tx_power是有符号字节,-59写进去是0xC5,用无符号解析会变成 197,算出来的距离直接是天文数字。UUID 的十六进制串要先去掉连字符再转字节,少一位多一位都会错位。
3.2 Android 端扫描与解析:过滤器要下推到系统层
Android 侧最容易写错的是两点:把解析放在onScanResult里逐帧做字符串拼接(高频回调下会明显掉帧),以及没有下推ScanFilter导致系统把所有 BLE 设备都回调上来,功耗和误唤醒都上去了。
// beacon-core/BeaconScanner.kt class BeaconScanner( private val context: Context, private val onFrame: (BeaconFrame) -> Unit, ) { private val scanner = context.getSystemService(BluetoothLeScanner::class.java) // 同一设备 300ms 内的重复帧合并,避免上层被刷屏 private val lastEmitAt = HashMap<String, Long>() private val callback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val frame = BeaconParser.parse(result.scanRecord?.bytes, result.rssi) ?: return val now = SystemClock.elapsedRealtime() val last = lastEmitAt[frame.key] ?: 0L if (now - last < 300L) return // 去抖窗口,按广播间隔调 lastEmitAt[frame.key] = now onFrame(frame) } override fun onScanFailed(errorCode: Int) { // 常见:2 = 应用注册失败(权限或蓝牙开关),3 = 已被注册过 Log.e("BeaconScanner", "scan failed: $errorCode") } } fun start(uuid: String? = null) { val filters = buildList { if (uuid != null) { // 把 UUID 过滤下推到系统,未匹配的包不会回调到应用层 add(ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(uuid)).build()) } } val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 前台用;后台改 LOW_POWER .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) .build() scanner.startScan(filters, settings, callback) } fun stop() { runCatching { scanner.stopScan(callback) } lastEmitAt.clear() } }SCAN_MODE_LOW_LATENCY是前台模式,扫描占空比接近 100%,耗电明显,后台一定要换成SCAN_MODE_LOW_POWER或者改用PendingIntent形式的后台扫描。去抖窗口 300ms 这个值不是拍脑袋的,它要跟设备的广播间隔对齐:间隔 500ms 的话,300ms 窗口不会误杀有效帧,又能把同一设备的重复回调压下去一半以上。
解析部分单独拆出来,是因为它同时被扫描回调和日志回放工具复用:
// beacon-core/BeaconParser.kt object BeaconParser { private const val MIN_LEN = 30 // 完整 iBeacon 广播数据的最小长度 private const val TYPE_VENDOR = 0xFF fun parse(raw: ByteArray?, rssi: Int): BeaconFrame? { if (raw == null || raw.size < MIN_LEN) return null // 遍历 AD 结构,找到厂商自定义数据段 var i = 0 while (i < raw.size - 1) { val len = raw[i].toInt() and 0xFF if (len == 0) break val type = raw[i + 1].toInt() and 0xFF if (type == TYPE_VENDOR && i + 2 + 23 <= raw.size) { val p = i + 2 // 校验固定头:公司标识 2 字节 + 子类型 1 字节 + 长度 1 字节 if ((raw[p].toInt() and 0xFF) != 0x4C) return@while if ((raw[p + 1].toInt() and 0xFF) != 0x00) return@while if ((raw[p + 2].toInt() and 0xFF) != 0x02) return@while if ((raw[p + 3].toInt() and 0xFF) != 0x15) return@while val uuid = bytesToUuid(raw, p + 4) val major = ((raw[p + 20].toInt() and 0xFF) shl 8) or (raw[p + 21].toInt() and 0xFF) val minor = ((raw[p + 22].toInt() and 0xFF) shl 8) or (raw[p + 23].toInt() and 0xFF) val txPower = raw[p + 24].toInt() // 有符号,保持负值 return BeaconFrame(uuid, major, minor, txPower, rssi, System.currentTimeMillis()) } i += len + 1 } return null } }这段代码里最关键的一句话是「遍历 AD 结构」。广播数据里可能有多个 AD 段(Flags、设备名、厂商数据混在一起),厂商数据不一定在开头,写死偏移量在部分机型上会直接解析失败。另外txPower一定要用toInt()直接取,不能用and 0xFF,否则符号丢了。
rssi转距离用的是标准对数路径损耗模型:
fun rssiToMeters(rssi: Int, txPower: Int, n: Double = 2.5): Double { // d = 10 ^ ((txPower - rssi) / (10 * n)) return 10.0.pow((txPower - rssi) / (10.0 * n)) }n是环境衰减因子,自由空间是 2.0,办公室 2.0 到 2.5,有隔断和人的展馆现场我一般取 2.5 到 3.0。这个值必须现场标定:拿一台标定过的手机,站在 1 米、3 米、5 米各采 30 秒 RSSI 取中位数,反解n。直接写死 2.0 的后果是五米以内误差还能忍,超过八米就开始胡说八道。
3.3 iOS 端:区域监听和 ranging 是两件事
iOS 侧的 API 把「进出区域」和「距离测量」分得很清楚,这个区分是整个后台唤醒方案的基础。startMonitoring注册的区域可以在 App 进入后台甚至被系统回收后继续生效,触发didEnterRegion;startRanging只能在前台或刚被唤醒的那一小段时间里跑,而且系统会限制它的运行时长。
// BeaconKit/BeaconRegionManager.swift import CoreLocation final class BeaconRegionManager: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private let targetUUID = UUID(uuidString: "8F3A1C20-6B4D-4E7A-9C11-2D5B7E90A3F4")! func setup() { manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters // 后台唤醒必须拿到 Always 授权,WhenInUse 收不到区域回调 manager.requestAlwaysAuthorization() let constraint = CLBeaconIdentityConstraint(uuid: targetUUID) let region = CLBeaconRegion(beaconIdentityConstraint: constraint, identifier: "demo-site") 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") guard CLLocationManager.isRangingAvailable() else { return } manager.startRangingBeacons(satisfying: CLBeaconIdentityConstraint(uuid: targetUUID)) } func locationManager(_ manager: CLLocationManager, didRange beacons: [CLBeacon], satisfying constraint: CLBeaconIdentityConstraint) { guard let nearest = beacons.min(by: { $0.rssi > $1.rssi }), nearest.rssi != 0 else { return } // accuracy 是系统估算的距离(米),-1 表示无效 print("minor=\(nearest.minor) rssi=\(nearest.rssi) accuracy=\(nearest.accuracy)") } }notifyEntryStateOnDisplay = true这一行很实用:用户掏出手机点亮屏幕时,系统会补一次区域状态检查,能救回一部分「进区事件丢了」的情况,代价是多一点功耗。didEnterRegion回调里不要做耗时操作,也不要直接弹 UI,正确做法是写本地标记加发本地通知,把重活留给用户点开 App 之后。
4. 后台唤醒与 GATT 长连接:把「叫醒」接到「能通信」上
唤醒只是第一步,真正难的是唤醒之后稳定地把连接建起来,并且在断线后自愈。这一段是双端差异最大的地方,Android 靠前台服务和后台扫描兜底,iOS 靠系统托管的区域监听加状态恢复,两套思路没法互相照搬。
4.1 后台唤醒的三道门:权限、后台模式、系统策略
三道门缺一道,表现都是「前台好好的,一锁屏就没反应」。这三样必须在工程初始化阶段一次性配好,事后再补经常会漏。
| 平台 | 必须项 | 常见漏配 | 后果 |
|---|---|---|---|
| Android 12+ | BLUETOOTH_SCAN、BLUETOOTH_CONNECT运行时权限 | 只声明不申请,或没加neverForLocation | 扫描直接返回空列表 |
| Android 10+ | ACCESS_FINE_LOCATION且用户选「始终允许」 | 只申请ACCESS_COARSE_LOCATION | 后台扫不到任何设备 |
| Android 8+ | 前台服务 + 常驻通知 | 用普通 Service 做后台扫描 | 几分钟后被系统冻结 |
| Android 13+ | POST_NOTIFICATIONS | 通知发不出来 | 前台服务被降级,用户看不到保活状态 |
| iOS | NSLocationAlwaysAndWhenInUseUsageDescription | 只写了WhenInUse | 收不到didEnterRegion |
| iOS | UIBackgroundModes含bluetooth-central、location | 只配了location | 连接态进后台后被挂起 |
Android 侧的后台扫描,我一般不用常驻ScanCallback硬扛,而是用PendingIntent形式的后台扫描让系统在匹配上报时拉起一个短生命周期的组件:
// 把扫描结果通过 PendingIntent 投递给广播接收器,进程不在也能被拉起 val intent = Intent(context, BeaconReceiver::class.java).apply { action = ACTION_BEACON_FOUND } val pending = PendingIntent.getBroadcast( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE ) val filter = ScanFilter.Builder() .setServiceUuid(ParcelUuid.fromString("8f3a1c20-6b4d-4e7a-9c11-2d5b7e90a3f4")) .build() scanner.startScan(listOf(filter), ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .build(), pending)PendingIntent的 flag 里FLAG_MUTABLE在 Android 12 之后是必须的,系统要往里面填扫描结果,写成FLAG_IMMUTABLE会直接崩溃,这个坑我在两个项目里各踩过一次。SCAN_MODE_LOW_POWER的占空比很低,延迟会到几秒级,适合「人在附近停留」的场景,不适合「走过路过」的快触发。
4.2 连接态切换:从广播到 GATT 的时序
收到匹配帧之后不要立刻connectGatt,这是新手最常见的写法。设备刚发完广播,射频可能还在切换,立刻连接的成功率很低,容易拿到 133 错误。工程里的做法是延迟 100 到 200ms 再发起,连上后等连接稳定再协商 MTU。
// beacon-core/GattSession.kt class GattSession( private val context: Context, private val onState: (String) -> Unit, ) { private var gatt: BluetoothGatt? = null private var retry = 0 private val callback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { if (status != BluetoothGatt.GATT_SUCCESS) { onState("connect failed: $status") scheduleReconnect(g.device) return } if (newState == BluetoothProfile.STATE_CONNECTED) { retry = 0 gatt = g // 连上之后稍等再协商 MTU,立刻调用在部分设备上会失败 g.discoverServices() } else { onState("disconnected") g.close() scheduleReconnect(g.device) } } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { g.requestMtu(247) // 请求更大 MTU,提升单包数据量 g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) } } } fun connect(device: BluetoothDevice) { // autoConnect=false 走直连,速度快;true 会一直挂着等设备出现,功耗高 gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE) } private fun scheduleReconnect(device: BluetoothDevice) { // 指数退避加抖动,避免多台设备同时重连把射频打满 val delay = minOf(30_000L, 1_000L shl minOf(retry, 5)) + Random.nextLong(0, 500) retry++ Handler(Looper.getMainLooper()).postDelayed({ connect(device) }, delay) } }requestConnectionPriority(CONNECTION_PRIORITY_HIGH)会把连接间隔压到 11.25ms 左右,吞吐上去了但功耗也上去了。下发指令这种场景保持 HIGH 十几秒就够了,之后切回BALANCED,长期挂 HIGH 对电池不友好。scheduleReconnect里的抖动不能省——现场一个展项可能同时有五六台设备断线重连,固定间隔会让它们每次都在同一时刻撞车。
4.3 iOS 侧的长连接:别指望后台常驻
iOS 没有前台服务这一说,后台的 BLE 连接靠的是CBCentralManager的状态恢复。初始化时给一个固定的restoreIdentifier,系统在后台因为蓝牙事件把 App 拉起时,会带着之前的外设和状态一起还给你。
// BeaconKit/BeaconConnection.swift final class BeaconConnection: NSObject, CBCentralManagerDelegate { private var central: CBCentralManager! private var target: CBPeripheral? override init() { super.init() // 关键:带标识初始化,系统才能在后台把 App 拉起并恢复连接状态 central = CBCentralManager(delegate: self, queue: .main, options: [CBCentralManagerOptionRestoreIdentifierKey: "demo.beacon.central"]) } func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { // 系统还回来的外设,直接接管,不要重新扫描 if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] { target = peripherals.first target?.delegate = self } } func centralManagerDidUpdateState(_ central: CBCentralManager) { guard central.state == .poweredOn else { return } // 用已连接外设的 UUID 直接 retrieve,比重新扫描快得多 central.retrievePeripherals(withIdentifiers: []).forEach { p in target = p central.connect(p, options: nil) } } }willRestoreState里拿到的外设已经处于连接或待连接状态,重新走scanForPeripherals会把流程打乱。还有一点要提前接受:iOS 后台的连接不能被 App 主动保持活跃,系统会在没有数据交互时把连接挂起,收到通知时才唤醒。所以「长连接」在 iOS 上的真实含义是「可恢复的连接」,不是「一直在线」,产品文档里别写错。
5. 避坑与排查:唤不醒、连不上、距离乱跳的 5 个现场
这一节全是血泪经验,每条都在真实现场复现过。排查顺序建议固定下来:先确认权限和后台模式,再看广播间隔和系统策略,最后才怀疑代码。
5.1 唤醒与扫描类问题
现象一:App 被用户从多任务里划掉之后,走到设备跟前完全没反应。
原因:这是系统级限制,不是 bug。iOS 上用户主动上划强杀后,区域监听不再拉起进程;Android 上强杀会连带清掉前台服务和后台扫描注册。任何号称「强杀也能唤醒」的方案,实测都靠不住。解决:把验收标准改成「系统回收可唤醒、用户强杀不保证」,同时用服务端推送或地理围栏做兜底。演示时一定要用「锁屏」而不是「划掉」来验证,避免自己在客户面前翻车。
现象二:站在设备旁边十分钟,didEnterRegion才回调一次,或者干脆不来。
原因:三层延迟叠加。广播间隔本身如果是 1s 到 2s,系统区域判定还要连续看到若干帧才确认进入,后台扫描窗口又被系统拉长到几十秒一次。解决:广播间隔降到 500ms 以内;notifyEntryStateOnDisplay打开;测试时不要只看首次回调,连续走 10 次记录延迟,看 P90 而不是平均值。我一般要求 P90 在 3 秒内,超过就说明现场部署密度不够,需要加点位。
现象三:Android 12 以上扫描回调一直是空的,onScanFailed也不报错。
原因:权限申请走的是旧逻辑。Android 12 之后BLUETOOTH_SCAN是运行时权限,而且如果声明了android:usesPermissionFlags="neverForLocation",就不能再依赖定位权限;反过来,如果没声明这个标志又没拿定位权限,扫描结果也会被过滤成空。解决:清单里新旧权限都声明,运行时按Build.VERSION.SDK_INT分支申请,扫描日志里把权限状态打出来,别等回调空转。
5.2 连接与距离类问题
现象四:connectGatt之后三五秒就断,日志里 status 是 133 或 8。
原因:133 是 GATT 的通用失败码,包罗万象。最常见的是三个:连上后立刻调requestMtu,从设备还没准备好;设备侧同时连接数满了,把新连接踢掉;以及在非主线程发起了连接。status 8 是对端超时,一般是连接参数不匹配或者信号太差。解决:连接成功后延迟 100ms 再协商 MTU;设备侧做连接数上限并主动踢最老的连接;connectGatt放在主线程调用;重连走指数退避。
现象五:同一个人站着不动,距离从 0.5 米跳到 8 米。
原因:单帧 RSSI 根本不能用。2.4GHz 多径反射、人体遮挡、手机握持姿势、天线朝向都会让 RSSI 瞬间抖 10dB 以上,换算成距离就是几倍的误差。解决:滑动窗口取中位数(窗口 5 到 9 帧),或者上卡尔曼滤波;业务上不要给用户显示具体米数,改成「近 / 中 / 远」三档;n值现场标定,别用默认 2.0。
// 中位数滤波比均值抗脉冲干扰,适合 RSSI 这种偶发跳变的信号 fun medianFilter(window: ArrayDeque<Int>, newValue: Int, size: Int = 7): Int { window.addLast(newValue) if (window.size > size) window.removeFirst() val sorted = window.sorted() return sorted[sorted.size / 2] }窗口大小 7 是经验值,对应 500ms 广播间隔大约 3.5 秒的平滑窗口。窗口太大反应迟钝,用户走到跟前了距离还停在「中」,太小又滤不干净。三档阈值的参考值是:< 1.5m近,1.5m 到 5m中,> 5m远,具体数值按现场标定微调。
6. 二次开发:换掉 UUID 之后,这套工程还能怎么改
拿到工程第一步通常是把示例 UUID 换成自己的。改的地方不多但容易漏:android/beacon-core/BeaconParser.kt里的固定头校验不用动,动的是扫描过滤器和区域定义;ios/BeaconKit/BeaconRegionManager.swift里的targetUUID;以及tools/build_adv.py里的默认参数。三处必须同时改,只改两端不改脚本,后面用脚本生成测试夹具时会对不上。
真正值得改的是 ID 的编码方式。原始字段是扁平的三段,业务上可以叠一层语义:
| 字段 | 原始含义 | 改造后的编码建议 |
|---|---|---|
| uuid | 设备批次 | 按业务线划分,一条业务线一个固定值 |
| major | 楼层/区域 | 高字节放区域码,低字节放楼层,支持 256 个区域 |
| minor | 点位号 | 直接用后台配置表里的点位主键,不再二次映射 |
这样后台只需要维护一张(uuid, major, minor) -> 点位信息的表,客户端拿到三元组直接查,不用在代码里写任何 if-else 映射。我见过在客户端硬编码点位名称的写法,加一个展项就要发一次版,改起来非常痛苦。
第二个改造点是安全。iBeacon 广播是明文且无签名的,任何人都能拿一台手机模拟你的 UUID 和 major/minor,做假签到。工程里没有做防护,因为广播层面本来就防不住。可行的做法是:广播只负责唤醒,连接建立后走一次 challenge-response,设备端存一个预共享密钥,App 发随机数、设备返回 HMAC,校验通过才认这次签到。别把校验逻辑放在广播里,那等于没有。
第三个改造点是唤醒后的行为分发。didEnterRegion和onScanResult里只做「记录事件 + 发本地通知」,真正的业务逻辑放到一个独立的分发器里,按minor路由到不同的处理类。这样加新场景不用动扫描层,也方便单独给某个点位做灰度。
验证方法我固定用三样:抓包工具对着设备看广播帧,确认 30 字节布局和txPower符号没写反;tools/frame_decoder.py把日志转成可读报文,比对 App 里显示的值;埋点统计唤醒延迟分布,看 P50 和 P90,别只看「有没有触发」。最后一项最容易被跳过,但它决定了上线后用户会不会觉得「这功能时灵时不灵」。
讲个自己的教训:早期做这类项目,我总在功能跑通当天就交付,结果后台策略的问题全在真实用户手里爆出来,因为开发机永远插着电、永远在前台、永远没开省电模式。从那以后我每次提测前都强制走一遍「拔电、锁屏、开省电、静置两小时」的流程,再让测试同学用不同品牌的机器各跑一遍。这套流程救回来的问题,比代码 review 多得多。希望帮到你。
本文还有配套的精品资源,点击获取