简介iBeacon.zip 是面向 iOS 开发者的 iBeacon 实践代码包聚焦后台唤醒 App 与蓝牙长连接两个难点适合实现室内定位、近场推送或场景化提醒的开发者参考。包内共 2 个文件以 Objective-C 的 .h 头文件与 .m 实现文件为主分别承担接口声明和核心逻辑实现压缩包约 3KB体量轻便便于查阅与移植。已有 581 人学习具备一定参考价值。内容围绕单例管理 iBeacon 监控、解析 UUID/Major/Minor 信号、按规则触发唤醒以及申请“始终定位权限”以支持后台监听展开同时涉及蓝牙连接事件维护、异常处理与耗电优化思路。读者可据此梳理从初始化、监听、唤醒到权限配置的关键链路理解后台长连接保持与资源消耗的平衡并排查后台唤醒失效、权限不足或连接不稳定等问题。1. 走近展馆 App 自己醒了iBeacon 能做的不只是定位展馆里做导览的团队多半都撞过同一堵墙用户在门口打开过一次 App进馆就把进程划掉了等他站到某个展项前面那条本该弹出的讲解通知再也没来过。iBeacon 这条链路解决的正是这“最后一米”——它不依赖 GPS也不要求 App 停在前台一枚纽扣电池供电的小信标持续往外广播被动监听的手机在进入区域时就能把 App 从后台甚至已被系统终止的状态里拉起来。这份 iBeacon.zip 就是一个把链路跑通的双端示例iOS 侧走 CoreLocation 的区域监听加 rangingAndroid 侧走 BLE 扫描加 BeaconParser 解析被唤醒之后两端都接了一段 GATT 长连接用来和现场设备收发指令。它适合正在做室内导览、展项触发、到岗打卡、资产近场识别的团队尤其适合那些已经被“App 唤不醒”“连上三秒就掉”折腾过一轮的人。2. 广播包拆解26 字节里藏着三组 ID 和一枚距离标尺信标这玩意儿玄学感很强第一次接触的人容易把它想复杂。拆开看其实很朴素它就是个只会单向喊话的发射器每隔 100 毫秒左右播一帧几十字节的数据包手机端负责听。真正决定“你是谁、你在哪一格、离我多远”的全在载荷里那 26 个字节上。这一章先把格式和匹配规则讲透后面所有唤不醒、识别错的问题追根溯源基本都能回到这一层。2.1 一帧广播的字节结构iBeacon 的载荷固定是 26 字节字段排布非常死板前 16 字节是 UUID接着 2 字节 Major2 字节 Minor最后 1 字节是有符号的 TxPower1 米处的参考 RSSI。这四样东西外加广播本身的 RSSI就是手机端能拿到的全部原始信息。UUID 通常用来标识一个场所或一套业务Major 一般分到楼层、分区Minor 落到具体点位——这三层是“粗到细”的过滤关系不是随便塞的。字节偏移 长度 字段 说明 0 16 UUID 场所/租户级标识同一套业务保持一致 16 2 Major 分区/楼层编号大端序 18 2 Minor 点位编号大端序同一分区内唯一 24 1 TxPower 1 米处标定的 RSSI通常 -59 到 -65有符号 - - RSSI 手机侧测量值随距离与遮挡剧烈波动TxPower 这个字段很多人直接忽略但它决定了你能不能算距离。工程里一般用下面的公式做粗略换算n是环境衰减因子空旷展厅取 2.0人多货架多的场景取 2.5 到 3.0# 由 1 米参考 RSSI 与当前 RSSI 估算距离米 # tx_power 来自广播包第 24 字节rssi 由手机端测量得到 def estimate_distance(rssi: int, tx_power: int, n: float 2.0) - float: if rssi 0: return -1.0 # 无效测量调用方应丢弃 ratio (tx_power - rssi) / (10.0 * n) return round(10 ** ratio, 2)这段计算出来的值只能当参考。实测里一颗信标在开放空间和贴着金属展柜时的 RSSI 能差 15dB 以上换算成距离就是几倍的误差。所以项目里但凡要“精准到米”都得靠多点位加权和滤波单颗信标的距离值拿来排序还行拿来判定“用户就在展项正前方”一定会翻车。2.2 区域匹配UUID、Major、Minor 三级过滤iOS 侧的抽象叫CLBeaconRegion它把上面三层 ID 组合成了一个监听单元。关键在于你注册的粒度决定了回调的粒度。只给 UUID系统会告诉你“这一片有信标”给到 Major能收到楼层级事件给到 Minor才拿得到点位级。粒度越细系统在底层做的匹配越省电但灵活性也越低。// 点位级区域UUID 锁定场所Major 锁定楼层Minor 锁定展项 CLBeaconRegion *region [[CLBeaconRegion alloc] initWithUUIDString:E2C56DB5-DFFB-48D2-B060-D0F5A71096E0 major:1001 minor:2003 identifier:hall-a-f1-exhibit-03]; // 进入/离开都要回调这是唤醒的前提 region.notifyOnEntry YES; region.notifyOnExit YES; // 熄屏状态下也做一次状态判定能显著提升“走到跟前才亮屏”的命中率 region.notifyEntryStateOnDisplay YES; [self.locationManager startMonitoringForRegion:region];参数上有两个点容易设错。identifier是业务侧唯一的字符串回调里靠它区分是哪个区域重名会导致事件互相覆盖。notifyEntryStateOnDisplay设成 YES 之后每次用户点亮屏幕系统都会补一次状态判定代价是耗电略增但对“用户走到展项前抬眼看手机”这个典型动作特别有效——很多唤醒率上不去的项目就是差了这一个开关。2.3 工程里的信标注册表怎么组织信标不是写死在代码里的。现场施工时点位的 Major/Minor 是布线队贴上去的后期还会调整所以工程里通常有一份独立的配置文件当“信标注册表”客户端启动时加载服务端下发更新。这份 iBeacon.zip 里的做法是把注册表放在 assets 下字段包含 ID、名称、坐标、参考功率{ venueId: hall-a, beacons: [ { uuid: E2C56DB5-DFFB-48D2-B060-D0F5A71096E0, major: 1001, minor: 2003, name: 序厅主展项, floor: 1, x: 12.4, y: 8.1, txPower: -61 } ] }x/y是相对场馆平面图原点的坐标单位米用来在室内地图上画点。txPower存的是施工时实测标定值而不是理论值这一点很关键——同一批信标出厂参数一样装到不同材质旁边参考功率就变了现场每颗都测一遍写进表里后面算距离才不至于全员偏移。启动时把这份表加载成字典key 用major-minor拼回调里 O(1) 就能查到点位信息不用每次遍历。3. 后台唤醒链路从区域事件到冷启动第一行业务代码唤醒这件事的难点不在“收到回调”而在“App 已经被系统杀掉了怎么还能收到回调”。iOS 和 Android 两边的机制完全不同但目标一致让系统在检测到区域变化时替你拉起进程并且保证拉起来之后你还有机会把业务状态恢复出来。这一章按时间顺序拆这条链路。3.1 权限与时序Always 不是申请一下就完事iOS 这边区域监听要真正在后台生效定位权限必须是authorizedAlways。坑在于权限是分两步给的先requestWhenInUseAuthorization用户同意之后再requestAlwaysAuthorization系统还会在几天后弹一次“是否继续允许后台使用”。如果直接一步申请 Always大概率被拒或者降级。// 分两步申请先前台用户有了使用体感之后再升级到 Always func bootstrapLocation() { manager CLLocationManager() manager.delegate self manager.desiredAccuracy kCLLocationAccuracyHundredMeters // 允许系统在后台因定位事件唤醒进程 manager.allowsBackgroundLocationUpdates true manager.pausesLocationUpdatesAutomatically false switch manager.authorizationStatus { case .notDetermined: manager.requestWhenInUseAuthorization() case .authorizedWhenInUse: // 用户已经用过一段时间此时再升级通过率明显更高 manager.requestAlwaysAuthorization() default: break } }allowsBackgroundLocationUpdates必须配合后台模式里的location能力一起开只写代码不开 Capability 是不生效的而且被拒时不会有任何报错——这也是后面避坑章要重点说的一条。pausesLocationUpdatesAutomatically关掉是为了防止系统在用户静止一段时间后自动暂停定位更新导致区域事件漏掉。3.2 区域回调里该做什么、不该做什么区域事件回调只给你几秒钟的执行窗口系统随时可能把进程再挂起。所以这里的原则是只做记录和轻量触发重活全部丢给下一次前台启动或者本地通知。func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { guard let beaconRegion region as? CLBeaconRegion else { return } // 第一件事是落盘冷启动场景下这是唯一能证明“确实被唤醒过”的凭据 WakeStore.shared.recordEntry( identifier: beaconRegion.identifier, timestamp: Date(), source: .regionCallback ) // 只做轻量动作发一条本地通知或者打一个埋点 NotificationCenter.default.post(name: .beaconRegionEntered, object: nil, userInfo: [id: beaconRegion.identifier]) // 需要具体距离时才开 ranging用完立刻停别常驻 manager.startRangingBeacons(in: beaconRegion) }recordEntry这一步看着不起眼但它解决的是一个很实际的问题冷启动之后你怎么知道这次启动是用户主动点的还是被信标唤醒的没有这条落盘记录埋点数据里“唤醒成功率”这个指标根本算不出来线上排查也只能靠猜。3.3 冷启动恢复被杀死之后第一行业务代码进程被区域事件拉起时didFinishLaunchingWithOptions的launchOptions里会带一个定位相关的 key。这是整个链路里最容易被忽略的一环——很多人在这里直接走了正常的初始化流程结果CLLocationManager建在了错误的时机后续回调全丢。func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - Bool { setupBaseServices() // 带 location key 说明本次进程是被定位事件拉起来的不是用户点击 if launchOptions?[.location] ! nil { // 必须在这里同步重建 manager 并立刻恢复监听晚一步回调就丢了 locationService.bootstrapForBackgroundLaunch() WakeStore.shared.markColdLaunchByBeacon() } return true }bootstrapForBackgroundLaunch()里要做的事包括重建CLLocationManager、重新注册所有CLBeaconRegion、把 delegate 设好。注意这一步是同步的不能放在异步回调里否则系统结束这次的执行窗口你还没注册完就又被挂起了。实测下来这个顺序错了唤醒率会从 90% 出头掉到 50% 以下而且日志里看不出任何异常。3.4 Android 侧的等价实现Android 从 8.0 开始对后台扫描收得很紧单纯靠BroadcastReceiver监听蓝牙广播在息屏后基本失效。工程上的通用做法是用 AltBeacon 的RegionBootstrap配合前台服务让系统允许你在后台持续做低占空比扫描。// Application 里做引导进程存活即恢复区域监听 class BeaconApp : Application(), BootstrapNotifier { private lateinit var bootstrap: RegionBootstrap override fun onCreate() { super.onCreate() val manager BeaconManager.getInstanceForApplication(this) manager.beaconParsers.clear() // 按 iBeacon 布局解析m 是厂商数据前缀i 是标识字段p 是参考功率 manager.beaconParsers.add( BeaconParser().setBeaconLayout(m:2-30215,i:4-19,i:20-21,i:22-23,p:24-24) ) // 前台快扫后台拉长间隔兼顾灵敏度与耗电 manager.setForegroundScanPeriod(1100L) manager.setBackgroundScanPeriod(5000L) manager.setBackgroundBetweenScanPeriod(25000L) val region Region(hall-a, Identifier.parse(UUID), 1001, null) bootstrap RegionBootstrap(this, listOf(region)) } override fun didEnterRegion(region: Region) { // 与 iOS 对齐先落盘再触发业务保证两端记录可对账 WakeStore.recordEntry(region.uniqueId, System.currentTimeMillis(), region) } }setBackgroundBetweenScanPeriod(25000L)这个值直接影响唤醒延迟扫描间隔越长越省电但用户走进区域到被发现的耗时会拉长到几十秒。展馆这种“人走得慢、停留时间长”的场景 25 秒够用如果是通道式打卡得压到 10 秒以内代价是耗电明显上升。4. 蓝牙长连接GATT 通道怎么做到断开三秒内自愈唤醒只是把人叫起来真正干活靠的是后面那条 GATT 连接。BLE 连接和 TCP 不一样它没有“服务端主动通知你断了”这回事断开往往表现为一段时间没有任何回调然后超时。所以连接层的核心不是“连上”而是“知道什么时候该重连以及重连时的退避策略”。这一章讲状态机、心跳和并发管理。4.1 连接状态机五个状态覆盖所有异常把连接抽象成状态机是这套代码里最值钱的设计。所有回调只负责推进状态业务侧只订阅状态变化不直接操作蓝牙 API。这样即使底层库换了业务代码一行不用动。状态触发条件应做的事超时处理IDLE初始 / 主动断开清理资源等待调度无SCANNING需要连接某设备定向扫描匹配 MAC 或服务 UUID15s 无结果转 BACKOFFCONNECTING扫到目标设备connectGatt注册回调10s 未连上转 BACKOFFCONNECTED收到连接成功回调发现服务、开 notify、启心跳心跳三次无响应转 BACKOFFBACKOFF连接失败或断开按退避间隔等待后回到 SCANNING达到上限后告警状态迁移全部收敛在一个方法里任何回调都只调它// 所有蓝牙回调统一走这里避免状态被多处并发修改 Synchronized private fun transitionTo(next: State, reason: String) { if (current next) return log(state $current - $next, reason$reason) val prev current current next listeners.forEach { it.onStateChanged(prev, next, reason) } when (next) { State.CONNECTED - { retryDelay BASE_DELAY; startHeartbeat() } State.BACKOFF - scheduleReconnect() State.IDLE - stopHeartbeat() else - Unit } }Synchronized不是为了性能是因为 BLE 的回调线程在不同厂商的 ROM 上不统一有的在主线程有的在 binder 线程不加锁迟早会遇到状态被覆盖。retryDelay在连上时重置回基础值这一点很关键否则设备正常断开一次之后后面所有重连都从 30 秒起步体验会很差。4.2 心跳、退避重连与扫描的开关时机BLE 连接空闲超过 20 到 30 秒很多设备侧会主动断开省电。心跳的作用就是让这条链路看起来一直“有事干”。心跳包本身不需要业务含义写一个特征值就行重点是频率和超时判定。private val heartbeatInterval 15_000L private var missedHeartbeat 0 private fun startHeartbeat() { handler.postDelayed(heartbeatTask, heartbeatInterval) } private val heartbeatTask object : Runnable { override fun run() { val gatt activeGatt ?: return // 写一个空指令到保活特征写失败即视为链路已断 val ok gatt.writeCharacteristic(keepAliveChar, byteArrayOf(0x00), BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) missedHeartbeat if (ok) 0 else missedHeartbeat 1 if (missedHeartbeat 3) { transitionTo(State.BACKOFF, heartbeat timeout) return } handler.postDelayed(this, heartbeatInterval) } }15 秒一次、连续三次失败才判定断开这样单次写入失败比如刚好赶上系统忙不会误触发重连。反过来退避重连的间隔要按指数走500ms 起步翻倍到 30 秒封顶private var retryDelay 500L private const val MAX_DELAY 30_000L private fun scheduleReconnect() { handler.postDelayed({ // 重连前先确保扫描已停止否则扫描和连接抢射频资源 stopScanIfNeeded() transitionTo(State.SCANNING, retry after $retryDelay ms) }, retryDelay) retryDelay (retryDelay * 2).coerceAtMost(MAX_DELAY) }还有一个细节连接成功之后一定要停掉扫描。BLE 的扫描和连接共用射频一边扫一边连会让连接参数协商变差表现为延迟突然变大、偶尔丢包。这个坑很隐蔽日志里只能看到“连接质量不稳定”很难联想到是扫描没关。4.3 多设备并发连接池与优先级调度单设备连接好办多设备就麻烦了。Android 上同时维持 4 到 7 条 GATT 连接基本是上限超过之后新连接会排队甚至直接失败。项目里如果现场有多台设备比如一个展项由主控加若干从机组成必须做连接池。// 连接池按业务优先级排序超出容量时踢掉最久未活跃的 class GattPool(private val capacity: Int 5) { private val entries LinkedHashMapString, PoolEntry() Synchronized fun acquire(deviceId: String, priority: Int): Boolean { entries[deviceId]?.let { it.lastActive System.currentTimeMillis() return true } if (entries.size capacity) { // 踢掉优先级最低且最久未活跃的那条 val victim entries.values .filter { it.priority priority } .minByOrNull { it.lastActive } ?: return false disconnect(victim.deviceId) entries.remove(victim.deviceId) } entries[deviceId] PoolEntry(deviceId, priority, System.currentTimeMillis()) return true } Synchronized fun release(deviceId: String) { entries.remove(deviceId)?.let { disconnect(deviceId) } } }LinkedHashMap在这里不只是为了顺序遍历它还能在调试时直观看到连接的建立顺序。淘汰策略用“优先级低于当前请求 最久未活跃”双条件避免了高优先级请求把正在干活的低优先级连接踢掉导致业务中断。5. 避坑排查五个把唤醒率从 92% 打到 40% 的坑这条链路在我手上至少翻过三轮每次现象都长得不一样但根因就那么几类。下面五条按“现象 → 原因 → 解决”记下来遇到同类问题可以直接对照。5.1 现象前台测试全通一锁屏就再也收不到唤醒原因后台模式里的location能力没开或者开了但allowsBackgroundLocationUpdates没设。更隐蔽的一种是开了之后被系统判定为“滥用后台定位”权限被降级成authorizedWhenInUse而且不会主动通知你。解决启动时和回到前台时都检查一次authorizationStatus一旦发现被降级就走引导流程把用户引到设置页重新授权。同时把manager.authorizationStatus的变化做成埋点线上能第一时间发现大面积降级。5.2 现象didEnterRegion进来了但 ranging 一个 beacon 都拿不到原因区域监听和 ranging 是两套机制。监听靠系统底层的低功耗匹配ranging 需要 App 处于活跃状态并且蓝牙可用。冷启动场景下如果 ranging 是异步发起的进程可能已经又被挂起了。解决在didFinishLaunchingWithOptions的定位 key 分支里同步启动 ranging并且给它设一个 10 秒的观察窗口窗口内拿到的 beacon 立刻落盘拿不到就记一条“唤醒成功但未 ranging”的异常日志方便区分是系统问题还是设备问题。5.3 现象长连接维持十分钟左右集中掉线重连后很快又掉原因心跳间隔设得太长比如 60 秒设备侧的空闲超时更短或者心跳用了WRITE_TYPE_DEFAULT带响应写每次都要等 ACK设备忙的时候直接超时。解决心跳间隔压到设备空闲超时的一半以下写入类型改成WRITE_TYPE_NO_RESPONSE把“是否收到回应”的判断从写返回值改成订阅通知里的回包计数。改完这一条掉线率能降一个数量级。5.4 现象用户在同一展项前站着不动业务被反复触发好几次原因RSSI 抖动导致区域状态在“进入”和“离开”之间来回跳尤其是用户站在边界线上的时候。系统给的didExitRegion很敏感稍微衰减一点就判定离开。解决在业务层加迟滞。收到进入事件后启动一个 60 秒的冷却窗口窗口内再收到同一点位的进入事件直接丢弃同时要求连续 N 次 ranging 都指向同一 minor 才认定“真正进入”。冷却时间别设太长否则用户真的走了又回来会被误判。5.5 现象同一用户同一时间iOS 和 Android 的后台记录对不上原因两端扫描周期和系统调度策略不同。iOS 的区域监听是系统级常驻响应快但不可控Android 的扫描是按周期轮询的平均延迟天然比 iOS 高十几秒。解决不要拿两端的原始时间戳直接比。在数据侧加一个“有效窗口”比如进入事件在 90 秒内重复上报视为同一次然后再按用户维度聚合。对账脚本里把这个窗口参数化方便后续调整。6. 二次开发把信标坐标接进你自己的业务地图拿到这个工程之后最省事的改造路径不是动唤醒层而是接业务层。唤醒和连接这两块已经把脏活干完了剩下的是把“我收到了哪颗信标的信号、离它多远”翻译成“用户现在在哪个展项前面、该给他推什么内容”。我一般会在中间加一层“点位解析器”把信标 ID 映射成业务实体。# 点位解析把 (major, minor, rssi) 翻译成业务可用的展项 class ExhibitResolver: def __init__(self, registry_path: str): with open(registry_path, encodingutf-8) as f: data json.load(f) # key 用 major:minor 拼回调里直接查表 self.index { f{b[major]}:{b[minor]}: b for b in data[beacons] } def resolve(self, major: int, minor: int, rssi: int) - dict | None: item self.index.get(f{major}:{minor}) if not item: return None # 距离仅用于排序展示不参与“是否进入”的判定 dist estimate_distance(rssi, item[txPower]) return {exhibitId: item[name], floor: item[floor], x: item[x], y: item[y], distance: dist}改造时有几个经验值得说。第一判定“进入展项”不要依赖距离阈值用“最近信标 连续命中次数”更稳距离值只用来在 UI 上显示“约 2 米”。第二业务配置一定要能热更新现场施工调整点位是常态每次改都发版不现实。第三把唤醒事件和连接事件分开埋点出问题时才能快速定位是“没叫醒”还是“叫醒了但连不上”。改造点改动位置影响范围建议换业务实体信标注册表 JSON无代码改动优先走这条换判定策略点位解析器局部保留旧策略做 A/B换连接协议连接状态机全局状态机不动只换底层实现加现场调试页独立入口无必做能省大量现场沟通最后一条是我用血泪经验换来的现场调试页一定要在第一个版本就做进去能看到当前扫到的所有信标、各自 RSSI、当前连接状态和最近一次唤醒时间。没有这个页面现场施工队一句“它就是没反应”你远程根本没法判断是没电、没广播、还是 App 权限掉了。从那以后我每次接这类项目第一个 PR 一定是调试页功能可以慢慢补观测手段不能等。希望帮到你。本文还有配套的精品资源点击获取