简介:iBeacon.zip 是一份面向 iOS 开发者的轻量级 iBeacon 功能示例代码,聚焦后台唤醒 App 与蓝牙长连接两个常见难点。包内共 2 个文件,包含 1 个 .h 头文件与 1 个 .m 实现文件,压缩包约 3KB,体量虽小但结构直接,便于快速阅读与移植。已有 581 人学习,说明其对需要落地室内定位、近场推送或场景化唤醒的开发者具有一定参考价值。代码围绕 iBeacon 信号监听、单例管理、始终定位权限申请以及蓝牙连接状态维护展开,可帮助读者理解后台监听 iBeacon 时如何避免重复初始化、如何处理连接事件与异常,并兼顾电量消耗优化。适合已具备 iOS 基础、希望补齐 iBeacon 唤醒链路与长连接保活思路的中级开发者,也可作为项目中的最小实现骨架,对照排查权限配置与后台唤醒失效等问题。
1. 从 iBeacon.zip 里先看清一件事:广播唤醒和蓝牙长连接不是一条路
拿到 iBeacon.zip 的人,十有八九是冲着 iBeacon 唤醒 App 去的,但拆开资源包后第一件要建立的判断是:iBeacon 只是单向广播,能触发系统级区域事件,不能拿来维持蓝牙长连接。把它当成“低功耗唤醒入口”是对的,把它当成“长连接保活方案”就会翻车。这份资源包适合移动端 BLE 开发、门店近场触发、设备靠近后拉起业务流程的从业者,也适合刚接触 iBeacon 帧结构、想先跑通扫描和解析的新手。读下去先分清唤醒链路和 GATT 长连接两条线,再决定你的业务到底该用哪一条。
2. 拆 iBeacon 广播帧:UUID、major、minor 与 RSSI 的落地参数
iBeacon.zip 里最值得先跑的不是页面跳转,而是广播帧解析。很多唤醒失败、串台、误触发,根子都在这里:字段偏移读错、大小端没统一、RSSI 阈值拍脑袋。先把原始字节流打印出来,把 UUID、major、minor、txPower 四个字段对齐,后面的扫描过滤和业务路由才有稳定输入。
2.1 广播包到底带了哪些字段
iBeacon 广播通常挂在厂商自定义数据里,常见结构是:2 字节厂商前缀、2 字节类型标识、16 字节 UUID、2 字节 major、2 字节 minor、1 字节 txPower。RSSI 不在广播帧里,它是接收侧测出来的信号强度,所以解析函数要单独把 RSSI 传进来。txPower 是设备在 1 米处的校准值,用来做距离粗估,不是精确测距。
| 字段 | 长度 | 作用 | 常见坑 |
|---|---|---|---|
| 厂商前缀 | 2 字节 | 标识广播来源 | 有的系统回调会剥掉前缀,偏移差 2 字节 |
| 类型标识 | 2 字节 | 区分 iBeacon 帧 | 大小端读反后整包解析失败 |
| UUID | 16 字节 | 标识场景或租户 | 每次发版乱改,历史设备全失联 |
| major | 2 字节 | 区域或楼层 | 和 minor 混用,路由表越来越乱 |
| minor | 2 字节 | 点位或设备号 | 多设备同 minor 导致串台 |
| txPower | 1 字节 | 1 米校准值 | 直接当距离用,误差被放大 |
| RSSI | 接收侧测量 | 信号强弱 | 阈值设太宽,隔墙也触发 |
选型上我一般建议 UUID 稳定,major 做区域,minor 做点位,不要把业务状态塞进广播字段。广播是公开的,谁都能复制,真正决定能不能进页面的应该是本地缓存校验和业务白名单,而不是“收到某个 UUID 就打开页面”。
2.2 用一段解析代码把字节流摊开
下面这段 Kotlin 只做一件事:把厂商数据按固定偏移拆开,返回可读结构。平台相关扫描回调用伪代码占位,落到工程里替换成你的系统 BLE 扫描器即可。
data class IBeaconFrame( val uuid: String, val major: Int, val minor: Int, val txPower: Int, val rssi: Int ) fun parseIBeacon(manufacturerData: ByteArray, rssi: Int): IBeaconFrame? { // 最小长度:2 字节厂商前缀 + 2 字节类型 + 16 UUID + 2 major + 2 minor + 1 txPower if (manufacturerData.size < 25) return null val type = ((manufacturerData[2].toInt() and 0xFF) shl 8) or (manufacturerData[3].toInt() and 0xFF) if (type != 0x0215) return null val uuidBytes = manufacturerData.copyOfRange(4, 20) val uuid = bytesToUuid(uuidBytes) val major = ((manufacturerData[20].toInt() and 0xFF) shl 8) or (manufacturerData[21].toInt() and 0xFF) val minor = ((manufacturerData[22].toInt() and 0xFF) shl 8) or (manufacturerData[23].toInt() and 0xFF) val txPower = manufacturerData[24].toInt() return IBeaconFrame(uuid, major, minor, txPower, rssi) } fun bytesToUuid(bytes: ByteArray): String { // 仅做展示格式化,工程里统一用同一套大小端规则生成缓存键 val hex = bytes.joinToString("") { "%02x".format(it) } return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-" + "${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}" }这段代码的关键参数是偏移量。manufacturerData[0..1]是厂商前缀,[2..3]是类型,[4..19]是 UUID,后面依次是 major、minor、txPower。不同设备固件、不同系统回调可能把前缀剥掉,所以第一次接入时不要直接信偏移,先把manufacturerData打成十六进制日志,确认第 0 位到底是什么。
提示:先打印原始 manufacturerData 的十六进制,再对偏移,不要一上来改业务代码。
RSSI 是外部传入的接收值,不要试图从 25 字节里再读一个“RSSI 字段”。txPower 是有符号字节,实际使用时要按有符号整数解释,否则 -59 会被读成 197,距离估算直接失真。
2.3 资源包里的配置表怎么改
iBeacon.zip 里如果有配置表或常量文件,不要只改一个 UUID 就开跑。建议把广播参数、扫描参数、业务参数分开:广播侧管 UUID/major/minor/txPower,扫描侧管过滤条件和 RSSI 窗口,业务侧管页面路由和幂等时间。
| 参数 | 建议起点 | 调整依据 |
|---|---|---|
| UUID | 一个场景一个,长期稳定 | 换场景才换,不要按版本号换 |
| major | 楼层或区域编号 | 同一 UUID 下分区 |
| minor | 点位或设备编号 | 和物理标签一一对应 |
| txPower | 按设备实测 1 米值 | 不同固件差异大,别抄默认值 |
| RSSI 触发阈值 | -65 到 -75 之间试 | 场地空旷取宽,隔墙多取严 |
| 连续命中次数 | 2 到 3 次 | 过滤偶发漂移 |
| 去重窗口 | 10 秒到 30 秒 | 防止同一次靠近反复拉起 |
配置表改完后先做静态校验:UUID 是否是 32 位十六进制,major/minor 是否在 0 到 65535,txPower 是否按有符号字节处理,RSSI 阈值是否和现场实测日志对得上。这里最容易出现“代码没问题、参数互相打架”的情况,血泪经验是先把配置表当接口文档审一遍,再谈唤醒。
3. iBeacon 唤醒 App 的链路:从系统回调到业务冷启动
iBeacon 唤醒 App 不是“广播一到就弹页面”。系统给的是区域进入、区域离开、短时测距这类事件,进程可能被拉起,也可能只是给一个很短的处理窗口。资源包里如果只演示了前台扫描,搬到后台就会显得很玄学。要把这条链路拆成“常驻监听、事件落盘、冷启动路由、幂等校验”四段,任何一段偷懒都会出现“日志里有广播,页面没起来”。
3.1 唤醒不是“收到广播就拉起”,先分清三种入口
第一种是前台扫描,回调最及时,适合调试和精确判断;第二种是后台区域监听,功耗低,但触发粒度和系统调度策略相关;第三种是冷启动入口,进程可能从零开始,路由参数必须自己带全。三者不是替代关系,常见做法是后台用区域监听兜底,进入区域后再短时扫描确认 major/minor 和 RSSI。
这里要接受一个边界:iBeacon 唤醒 App 的可靠性受系统后台策略、权限状态、蓝牙开关、省电模式影响,不能承诺“每次靠近必拉”。工程上能做的是把不确定事件变成可观测状态:什么时候收到区域事件、什么时候完成冷启动、路由参数是否完整、是否被幂等拦截。把这些写进日志,比反复调扫描间隔更有用。
3.2 扫描与区域监听的代码骨架
下面的骨架把“监听、去重、路由”串起来,平台 API 用BleScanner占位,替换成你的系统扫描器即可。
class BeaconWakeManager( private val scanner: BleScanner, private val store: WakeStore, private val router: PageRouter ) { fun startRegionWatch(region: BeaconRegion) { // 常驻区域监听负责低功耗兜底,不要在这里做重业务 scanner.watchRegion(region, onEnter = { event -> val key = "${event.uuid}/${event.major}/${event.minor}" // 同一设备 10 秒内只处理一次,避免回调抖动造成重复拉起 if (!store.markOnce(key, 10_000L)) return@watchRegion // 冷启动只带最小参数,页面跳转交给主线程队列 router.openBeaconPage( uuid = event.uuid, major = event.major, minor = event.minor, rssi = event.rssi ) }) } }markOnce的去重窗口不要设得太短。门店场景里人站在门口,信号会在阈值附近抖动,1 秒去重等于没去重;10 到 30 秒是比较稳的起点。router.openBeaconPage也不要在 BLE 回调线程直接做页面跳转,先入主线程队列,避免冷启动阶段线程状态不完整。
冷启动入口还要再校验一次:
fun onColdStart(params: Map<String, String>) { val uuid = params["uuid"] ?: return val major = params["major"]?.toIntOrNull() ?: return val minor = params["minor"]?.toIntOrNull() ?: return val ts = params["ts"]?.toLongOrNull() ?: return // 超过 60 秒的冷启动参数不再消费,防止旧事件把页面拉错 if (System.currentTimeMillis() - ts > 60_000L) return // 只认本地缓存里出现过的广播,避免外部伪造参数直接打开页面 if (!WakeCache.contains(uuid, major, minor)) return router.openBeaconPage(uuid, major, minor, rssi = 0) }这段代码的重点不是页面长什么样,而是三个校验:时间戳、来源缓存、去重键。时间戳防旧事件,来源缓存防伪造参数,去重键防重复拉起。三者缺一个,线上就会出现“莫名其妙进错页”或者“同一次靠近进两次”的投诉。
3.3 冷启动参数校验与幂等
冷启动是 iBeacon 唤醒 App 最容易翻车的地方。进程被杀后,系统可能只给你一个很短的启动窗口,路由参数如果只放在内存里,进程一重建就丢了。我的做法是把事件先落盘,再启动页面:收到区域事件后写一条最小记录,包含 uuid、major、minor、rssi、时间戳、来源标记;冷启动时读最近一条未消费记录,消费后标记完成。
幂等要分两层:事件级幂等和页面级幂等。事件级用uuid/major/minor + 时间窗做键,防止同一广播重复入队;页面级用路由栈判断,如果当前已经在目标页,不再压栈。两层都做,才能避免“回调重试导致页面叠三层”的尴尬。
注意:不要把 iBeacon 广播当成可信身份。广播字段可以被复制,真正能决定业务权限的是服务端会话、本地缓存和业务白名单。
4. 蓝牙长连接:GATT 建链、MTU、心跳与断线重连
资源包里把“iBeacon 唤醒”和“蓝牙长连接”放在一起,容易让人误以为唤醒后自然就有长连接。实际是两条链路:iBeacon 负责“靠近事件”,GATT 负责“连上设备读写数据”。唤醒成功后要不要建长连接,取决于业务是否需要持续通信;如果只是展示页面,完全没必要建链。要建链,就得把连接状态、心跳、超时、重连退避当成一个状态机来写。
4.1 为什么 iBeacon 不能当长连接用
iBeacon 是广播者单向发帧,接收方不回应,不存在连接句柄、MTU、通知使能这些概念。它能做的是让接收端知道“附近有谁”,不能保证“对方在线”。长连接需要中心设备发起连接,双方维护连接参数,外设要支持可读写特征和通知。把 iBeacon 当长连接,最直接的现象是:广播一直有,但一发业务指令就失败。
选型上,如果业务是“靠近后打开页面、拉一次配置”,iBeacon 加一次短连接足够;如果是“靠近后持续收传感器数据”,才需要 GATT 长连接。别为了省事把短连接硬撑成长连接,心跳和重连的复杂度会反噬。
4.2 建链与发现服务的代码骨架
下面用 Kotlin 写一个连接状态机骨架,传输层用BleTransport占位。关键点是:连接超时、通知回调刷新活跃时间、空闲断开、心跳发送都收在一个状态机里。
enum class LinkState { Idle, Connecting, Connected, Backoff } class GattLink( private val transport: BleTransport, private val clock: () -> Long ) { private var state: LinkState = LinkState.Idle private var lastRxAt = 0L fun connect(deviceId: String) { if (state == LinkState.Connecting || state == LinkState.Connected) return state = LinkState.Connecting transport.connect( deviceId = deviceId, timeoutMs = 8_000, onReady = { state = LinkState.Connected lastRxAt = clock() // 建链后先使能通知,再发首帧业务请求 transport.enableNotify() }, onFail = { state = LinkState.Backoff } ) } fun onNotify(payload: ByteArray) { lastRxAt = clock() // BLE 回调线程只做入队,解析和落库放到工作线程 transport.deliver(payload) } fun tick() { if (state != LinkState.Connected) return val idleMs = clock() - lastRxAt if (idleMs > 30_000L) { transport.disconnect() state = LinkState.Backoff } else { transport.sendHeartbeat() } } }timeoutMs设 8 秒是保守值,现场干扰大时可以放到 10 秒,但不要无限等。lastRxAt只在收到通知时刷新,不能只在发送心跳时刷新,否则对方已经掉线,本地还认为活着。enableNotify要在业务首帧之前完成,很多“连上了但收不到数据”都是通知没使能。
4.3 心跳参数怎么定:1s、5s、30s 的差别
心跳不是越勤越稳。1 秒心跳对功耗和射频资源都不友好,多设备并发时容易把手机拖进重连风暴;30 秒心跳又太慢,断线发现延迟高。我的起点是 10 到 15 秒应用层心跳,配合 30 秒空闲断开,连接超时 8 秒。具体还要看外设的休眠策略和通知频率。
| 参数 | 保守起点 | 适用场景 | 风险 |
|---|---|---|---|
| 连接超时 | 8 秒 | 普通近场设备 | 设太短,弱信号下频繁失败 |
| 应用层心跳 | 10 到 15 秒 | 需要持续收数据 | 设 1 秒,功耗和并发压力大 |
| 空闲断开 | 30 秒 | 低频业务 | 设太长,占用连接槽 |
| MTU 请求 | 185 或 247 | 批量传数据 | 不协商就发长包会截断 |
| 重连退避 | 1/2/4/8 秒,封顶 30 秒 | 断线恢复 | 不退避会形成重连风暴 |
MTU 不要默认 23 就直接发长包。先协商,再按协商结果切分包;对方不支持大 MTU 时,业务层要能降级到小包。心跳内容也别发空包,带一个递增序号和设备状态,日志里才能看出是“心跳没发出去”还是“发了没回”。
4.4 重连退避与状态机
重连最怕两件事:断线立刻重连、多个设备同时重连。前者会把瞬时干扰放大成持续失败,后者会把手机蓝牙栈拖垮。我一般用指数退避加随机抖动,并且同一时间只允许一个连接任务在跑。
fun nextDelayMs(attempt: Int): Long { // 1s、2s、4s、8s、16s,封顶 30s val base = minOf(30_000L, 1_000L shl minOf(attempt, 4)) val jitter = (0..300).random().toLong() return base + jitter }attempt从 0 开始,成功连接后清零。抖动范围 0 到 300 毫秒就够,不要把退避抖得太散。状态机里Backoff只负责计时,不负责执行业务;计时到期后回到Connecting,失败再进Backoff。这套结构看起来笨,但排查时非常清楚:日志里能看到每次状态迁移和延迟值。
5. 避坑与排查:唤醒失败、长连接掉线的 5 个现场
iBeacon 唤醒 App 和蓝牙长连接放在同一个业务里,故障现象会互相伪装:看起来是唤醒没生效,实际是长连接把扫描资源占了;看起来是长连接不稳,实际是广播过滤太宽导致重复建链。下面五个现场是资源包落地时最常遇到的,按“现象、原因、解决”拆开记。
5.1 现象一:锁屏后扫描回调消失
现象是前台日志正常,锁屏后十几分钟没有任何广播回调,解锁后突然补一批。原因通常不是广播停了,而是后台扫描被系统调度限制,或者扫描窗口开得太长被降频。解决方向是把“常驻高频扫描”改成“低频区域监听加进入后短扫”,同时检查蓝牙、定位相关权限是否在后台仍有效。
具体做法:区域监听只负责发现“进入”,进入后开 3 到 5 秒短扫确认 major/minor 和 RSSI,确认完立即停扫。短扫结果落盘,冷启动读缓存。这样功耗和触发率都更可控。别用“一直扫”对抗系统策略,那是拿功耗换玄学。
5.2 现象二:能收到广播却拉不起页面
现象是日志里已经解析出 uuid/major/minor,但页面没起来,或者起来后参数为空。原因多半是冷启动路由参数没落盘,进程重建后内存对象丢了;也可能是去重键把同一次事件误杀了。解决是把事件先写本地缓存,再启动页面,冷启动时从缓存读最近一条未消费记录,消费后标记完成。
还要检查路由参数是否经过序列化。很多实现把对象直接塞进启动参数,进程一换就丢字段。最稳妥的是只传uuid/major/minor/ts/source五个短字段,页面自己再查详情。参数越短,冷启动越稳。
5.3 现象三:长连接 30 秒必掉
现象是每次连上大约 30 秒断开,重连又能连上。原因常见有三类:应用层心跳间隔大于外设休眠超时;通知没有使能,本地收不到任何包;连接间隔和从机功耗策略不匹配。解决是先看断开侧返回的状态码,再把心跳降到外设超时以内,确认 notify 已开,最后才调连接参数。
排查顺序不要反:先确认“有没有收到通知”,再确认“心跳有没有发出去”,最后才怀疑射频。日志里至少记三行:连接成功时间、最后一次收到通知时间、断开时间。三行一对,就能判断是空闲超时还是链路异常。
5.4 现象四:多设备同 UUID 串台
现象是同一区域多台设备使用相同 UUID,页面总是打开错误点位。原因是过滤条件只写了 UUID,没有校验 major/minor,或者 RSSI 窗口太宽,隔墙设备也被选中。解决是把过滤键改成 uuid/major/minor 三元组,再加最近设备和 RSSI 阈值双重判断。
现场调试时把候选设备按 RSSI 排序,取最近的一个,同时要求连续 2 到 3 次命中才触发。不要一收到就路由,信号漂移会让你在点位之间来回跳。配置表里 major/minor 必须和物理标签一一对应,别图省事批量复制。
5.5 现象五:重连风暴把手机拖垮
现象是设备断开后立刻重连,多个设备同时断,日志里全是连接失败,手机发热、扫描也变慢。原因是断线回调里直接调connect,没有退避和并发控制。解决是加指数退避加随机抖动,同一时间只允许一个连接任务,失败次数超过阈值后暂停一段时间。
状态机里把“连接中”当成互斥锁,重复调用直接返回。退避期间不要停止区域监听,否则唤醒链路也断了。重连失败不是靠更勤的重试解决,而是靠更清楚的失败分类:超时、鉴权失败、服务发现失败、通知使能失败,各自处理策略不同。
6. 进阶验证:用日志和状态表把 iBeacon 链路跑成可观测
最后一章落到验证。iBeacon 唤醒 App 和蓝牙长连接都不是“跑通一次就算完”的功能,现场信号、系统调度、设备固件都会变。我的习惯是每次上线前跑三张表:广播表、唤醒表、连接表。广播表记原始 hex、uuid、major、minor、rssi、txPower;唤醒表记区域事件时间、冷启动时间、路由参数、是否被幂等拦截;连接表记建链耗时、MTU、通知使能时间、最后收包时间、断开原因。
6.1 三张表验证唤醒与连接
| 表 | 必填字段 | 用来回答 |
|---|---|---|
| 广播表 | 原始 hex、uuid、major、minor、rssi、txPower | 帧有没有解析错 |
| 唤醒表 | 事件时间、冷启动耗时、路由参数、去重结果 | 为什么没拉起或拉错 |
| 连接表 | 建链耗时、MTU、notify 状态、最后收包、断开码 | 为什么掉线、为什么收不到 |
三张表不用上复杂系统,先落本地日志也行。关键字段必须能按uuid/major/minor串起来,否则排查时只能靠猜。
6.2 一段可复用的诊断脚本
下面这段 bash 只做一件事:从日志里统计广播命中、唤醒成功、连接断开三类事件,先看比例,再看单条。
#!/usr/bin/env bash # 用法:./beacon_diag.sh beacon.log LOG="${1:?请传入日志文件}" echo "广播帧数量:" grep -c "BEACON_FRAME" "$LOG" echo "唤醒事件数量:" grep -c "WAKE_EVENT" "$LOG" echo "冷启动路由成功:" grep -c "COLD_START_ROUTE_OK" "$LOG" echo "连接断开原因分布:" grep "GATT_DISCONNECT" "$LOG" | sed 's/.*reason=//' | sort | uniq -c | sort -nr echo "最近 5 条唤醒记录:" grep "WAKE_EVENT" "$LOG" | tail -n 5脚本参数只有一个日志路径。先看广播帧和唤醒事件的比例,如果广播很多、唤醒很少,优先查后台策略和区域监听;再看冷启动路由成功率,低就查参数落盘和幂等;最后看断开原因分布,集中在一个原因就好定位。
6.3 我每次上线前强制走一遍的检查
我会先关掉前台页面,锁屏走一遍靠近流程,确认广播表有帧、唤醒表有事件、冷启动参数完整;再开长连接,连续收包 5 分钟,看连接表里最后收包时间是否持续刷新;最后模拟一次断线,确认退避延迟和重连次数在预期内。这三步不走,后面现场出问题就只能当黑匣子猜。从那以后我每次改 iBeacon 配置或 GATT 参数,都强制把这三张表跑一遍,不给自己留后悔药。希望帮到你。
本文还有配套的精品资源,点击获取