iBeacon.zip链路拆解:广播帧、围栏唤醒与GATT长连接
简介:这份压缩包面向需要实现 iBeacon 唤醒 App 与蓝牙长连接的 iOS 开发者,聚焦后台监听信标、维持连接和权限处理等常见难点。包内共 2 个文件,由 1 个 Objective-C 头文件与 1 个实现文件组成,压缩包约 3KB,头文件用于声明单例接口与回调,实现文件承载信号监听、连接管理和唤醒逻辑,体量轻便,便于快速对照阅读。已有 581 人学习,说明该主题在实际开发中有一定参考需求。代码围绕 iBeacon 信号监听、数据解析、按规则唤醒以及蓝牙连接事件处理展开,并涉及申请“始终使用位置”权限的必要性;读者可据此理解后台监控与长连接的基本组织方式,参考异常处理和降低耗电的编写思路,再结合自身项目调整监控范围与唤醒策略。
1. 从“App 被杀后还能收到围栏回调”说起:iBeacon.zip 到底补的是哪段链路
iBeacon 这套东西最容易被误解成“蓝牙定位”,真正落地时先要跑通的是 iBeacon 唤醒 App 和蓝牙长连接这两条链路:前者决定用户走到某个区域时,系统愿不愿意把 App 叫醒;后者决定叫醒之后,手机能不能稳定连上设备拿到数据。iBeacon.zip 我按模块拆过一遍,核心不是界面,而是把广播帧、扫描解析、围栏回调、GATT 连接保活串成一个可替换的 Demo。它适合做近场触发、展陈导览、设备靠近唤醒、资产标签联动的 Android/iOS 开发者;如果你要找的是一套完整室内定位算法,这份包不是那个方向。
2. 广播帧与扫描过滤:把 UUID、Major、Minor 打成可复现的坐标
iBeacon 的“坐标”不是经纬度,而是 UUID、Major、Minor 这三个标识。广播端把这三个值塞进 Manufacturer Data,扫描端再按同样的格式拆出来。很多 Demo 跑不起来,不是代码逻辑有多复杂,而是广播帧里少了一个字节、大小端写反,或者扫描过滤条件写得太窄。先把帧格式对齐,后面唤醒和长连接才有讨论基础。
2.1 广播端:Manufacturer Data 里每个字节都不能拍脑袋
iBeacon 广播帧通常挂在厂商自定义数据里,公司 ID 用0x004C,后面跟0x02、0x15、16 字节 UUID、2 字节 Major、2 字节 Minor、1 字节 TxPower。这里最容易翻车的是 Major/Minor 的大小端:协议里按大端写,但 Android 的整数默认是小端,手写字节数组时要把高位先放进去。TxPower 也不是“发射功率档位”,它表示 1 米处的校准 RSSI,通常取负值,后面算距离时要用。
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | Type | 1 | 固定0x02 |
| 1 | Length | 1 | 固定0x15,即 21 字节 |
| 2 | UUID | 16 | 按大端写入 |
| 18 | Major | 2 | 按大端写入 |
| 20 | Minor | 2 | 按大端写入 |
| 22 | TxPower | 1 | 有符号字节,1 米校准 RSSI |
下面这段 Kotlin 只做一件事:把业务参数组装成 iBeacon 的 Manufacturer Data。uuid用标准 UUID,major、minor限制在 0 到 65535,txPower用Byte传入,避免在调用处反复做有符号转换。
import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseSettings import android.bluetooth.le.BluetoothLeAdvertiser import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID fun buildIBeaconPayload( uuid: UUID, major: Int, minor: Int, txPower: Byte ): ByteArray { require(major in 0..0xFFFF) { "major out of range" } require(minor in 0..0xFFFF) { "minor out of range" } val data = ByteArray(23) data[0] = 0x02 data[1] = 0x15 // UUID 按大端写入,两个 long 拼成 16 字节 val bb = ByteBuffer.wrap(data, 2, 16).order(ByteOrder.BIG_ENDIAN) bb.putLong(uuid.mostSignificantBits) bb.putLong(uuid.leastSignificantBits) // Major/Minor 必须大端,否则扫描端拆出来会变成另一个值 data[18] = (major shr 8).toByte() data[19] = major.toByte() data[20] = (minor shr 8).toByte() data[21] = minor.toByte() data[22] = txPower return data } fun startIBeaconAdvertising( advertiser: BluetoothLeAdvertiser, payload: ByteArray ) { val settings = AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) .setConnectable(false) .build() val data = AdvertiseData.Builder() .addManufacturerData(0x004C, payload) .setIncludeDeviceName(false) .build() advertiser.startAdvertising(settings, data, object : android.bluetooth.le.AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { // 广播已启动,后续用扫描端核对 UUID/Major/Minor 是否一致 } override fun onStartFailure(errorCode: Int) { // 常见原因:广播实例未拿到、数据超长、蓝牙未开启 } }) }ADVERTISE_MODE_LOW_LATENCY会提高广播频率,联调阶段方便扫描端快速看到包;量产时要按功耗预算换到平衡模式。setConnectable(false)很关键:标准 iBeacon 广播本身通常不是可连接广播,手机扫到它不等于能直接建立 GATT 连接。要做蓝牙长连接,广播端得另开可连接广播并注册 GATT 服务,或者让设备在触发后切换广播内容。把这两件事混在一起,后面一定会在connectGatt那里卡住。
注意:
0x004C在代码里是整数,落到空中包时实际字节是0x4C 0x00。如果你手写字节数组,顺序写反,iOS 和 Android 的扫描端都可能直接过滤掉。
2.2 扫描端:先做去重和 RSSI 平滑,再谈距离
扫描端第一件事不是算距离,而是确认能稳定拿到同一组 UUID/Major/Minor。Android 的ScanFilter可以按厂商 ID 和前两个字节过滤,减少回调噪音;拿到ScanRecord后再解析完整帧。注意getManufacturerSpecificData返回的数据不包含公司 ID,所以解析时从0x02 0x15开始读。
import android.bluetooth.BluetoothAdapter import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.UUID data class BeaconHit( val uuid: UUID, val major: Int, val minor: Int, val txPower: Int, val rssi: Int, val address: String ) fun startBeaconScan(adapter: BluetoothAdapter, callback: (BeaconHit) -> Unit) { val scanner = adapter.bluetoothLeScanner ?: return // 只过滤厂商 ID 和 iBeacon 前缀,UUID 留给业务层判断 val filter = ScanFilter.Builder() .setManufacturerData( 0x004C, byteArrayOf(0x02, 0x15), byteArrayOf(0xFF.toByte(), 0xFF.toByte()) ) .build() val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setReportDelay(0L) .build() scanner.startScan(listOf(filter), settings, object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { parseIBeacon(result)?.let(callback) } override fun onBatchScanResults(results: MutableList<ScanResult>) { results.forEach { result -> parseIBeacon(result)?.let(callback) } } }) } fun parseIBeacon(result: ScanResult): BeaconHit? { val md = result.scanRecord?.getManufacturerSpecificData(0x004C) ?: return null if (md.size < 23 || md[0] != 0x02.toByte() || md[1] != 0x15.toByte()) return null val bb = ByteBuffer.wrap(md, 2, 16).order(ByteOrder.BIG_ENDIAN) val uuid = UUID(bb.long, bb.long) val major = ((md[18].toInt() and 0xFF) shl 8) or (md[19].toInt() and 0xFF) val minor = ((md[20].toInt() and 0xFF) shl 8) or (md[21].toInt() and 0xFF) val txPower = md[22].toInt() // 有符号,通常是负数 return BeaconHit(uuid, major, minor, txPower, result.rssi, result.device.address) } fun smoothRssi(old: Double?, rssi: Int, alpha: Double = 0.3): Double { return old?.let { alpha * rssi + (1 - alpha) * it } ?: rssi.toDouble() }扫描过滤只做“粗筛”,真正判断是不是目标 Beacon 要放到parseIBeacon之后。alpha是 RSSI 平滑系数,取值越小越稳但滞后越大,室内联调常用 0.2 到 0.4。txPower用来估算距离时,公式是d = 10^((txPower - rssi) / (10 * n)),n在室内常见取 2.0 到 3.0;但 RSSI 抖动很大,这个距离只能做分区参考,不要拿它当厘米级测距。
3. iBeacon 唤醒 App:围栏回调、冷启动与后台权限的硬边界
“扫到”与“唤醒”是两件事。前台扫描能实时看到 Beacon,不代表 App 在后台或被杀后还能被叫醒。iOS 靠 CoreLocation 的 Region Monitoring,Android 靠后台扫描的PendingIntent或系统级广播,两边的系统限制完全不同。把唤醒理解成一个状态机,比理解成“蓝牙回调”更准确。
3.1 iOS:Region Monitoring 是“状态机”,不是实时扫描器
iOS 的startMonitoring(for:)注册的是区域状态变化,系统在进入、离开、屏幕点亮时给回调。它不保证秒级延迟,也不保证你能在回调里做长时间连接。startRangingBeacons(in:)才是实时测距,但更适合前台。冷启动场景要补一次requestState(for:),否则 App 被系统回收后重新拉起,可能只拿到进入事件却没有当前状态。
import CoreLocation final class BeaconWakeManager: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private let uuid = UUID(uuidString: "E2C56DB5-DFFB-48D2-B060-D0F5A71096E0")! func start() { manager.delegate = self manager.requestAlwaysAuthorization() let region = CLBeaconRegion( proximityUUID: uuid, major: 100, minor: 1, identifier: "demo-beacon-region" ) region.notifyOnEntry = true region.notifyOnExit = true region.notifyEntryStateOnDisplay = true manager.startMonitoring(for: region) manager.requestState(for: region) } func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { guard region is CLBeaconRegion else { return } // 只做轻量落盘或本地通知,别在这里直接开长连接 UserDefaults.standard.set(Date(), forKey: "lastBeaconEnterAt") } func locationManager( _ manager: CLLocationManager, didDetermineState state: CLRegionState, for region: CLRegion ) { if state == .inside { // 冷启动补状态:App 被拉起时先判断当前是否已在区域内 } } }requestAlwaysAuthorization()是后台围栏的前提,notifyEntryStateOnDisplay让屏幕点亮时补一次状态判断。这里不要把didEnterRegion当成“用户刚刚走到 Beacon 旁边”的精确事件,它可能延迟,也可能在用户手动强制退出 App 后不再按预期回调。产品逻辑要留后悔药:进入区域只做唤醒和路由,真正连接放到前台或用户确认后。
3.2 Android:PendingIntent 能接住广播,但不等于一定能拉起页面
Android 8 以后后台扫描被收紧,普通startScan(callback)在后台很容易被限流。常见做法是用PendingIntent形式启动扫描,让系统在扫到目标时发广播,App 进程被回收后也有机会被拉起。但“有机会”不是“一定”,不同厂商 ROM 的省电策略、自启动权限、蓝牙扫描权限都会影响结果。
import android.app.PendingIntent import android.bluetooth.BluetoothManager import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build import android.os.SystemClock private const val ACTION_BEACON_FOUND = "demo.action.BEACON_FOUND" fun startBackgroundBeaconScan(context: Context) { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val scanner = manager.adapter.bluetoothLeScanner ?: return val filter = ScanFilter.Builder() .setManufacturerData( 0x004C, byteArrayOf(0x02, 0x15), byteArrayOf(0xFF.toByte(), 0xFF.toByte()) ) .build() val settings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) .build() val intent = Intent(context, BeaconWakeReceiver::class.java).apply { action = ACTION_BEACON_FOUND } val pending = PendingIntent.getBroadcast( context, 1001, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) scanner.startScan(listOf(filter), settings, pending) } class BeaconWakeReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val result: ScanResult? = if (Build.VERSION.SDK_INT >= 33) { intent.getParcelableExtra( BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT, ScanResult::class.java ) } else { @Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT) } val record = result?.scanRecord ?: return val key = "${result.device.address}:${result.rssi / 12}" // 接收器里只做去重和入队,不在这里做 GATT 连接 if (WakeDeduper.markIfNew(key, 30_000L)) { BeaconWakeService.enqueue(context, result.device.address) } } } object WakeDeduper { private val lastSeen = mutableMapOf<String, Long>() fun markIfNew(key: String, windowMs: Long): Boolean { val now = SystemClock.elapsedRealtime() val last = lastSeen[key] ?: 0L if (now - last < windowMs) return false lastSeen[key] = now return true } }SCAN_MODE_LOW_POWER适合后台长时间挂扫描,代价是回调延迟变大;联调阶段可以换LOW_LATENCY,但别长期这么跑。PendingIntent用显式Intent指向自己的接收器,避免被其他应用截获。Android 12 以后要处理BLUETOOTH_SCAN、BLUETOOTH_CONNECT权限,老版本还要位置权限;权限没给全时,扫描可能直接没有回调,日志里也不一定有明显报错。
提示:接收器被唤醒不等于页面一定能启动。Android 对后台启动前台服务、后台弹 Activity 都有限制,实际产品里更稳的是发本地通知,让用户点进来再建立连接。
4. 蓝牙长连接:从连上到稳住,GATT 参数和重连状态机要一起调
iBeacon 广播负责“发现”,GATT 连接负责“通信”。标准 iBeacon 广播通常不可连接,所以长连接一定要有一个可连接的 GATT 设备侧。手机端连上以后,别急着拉 MTU、提高连接优先级,先把服务发现、特征值读写、断线重连这条链路跑顺。很多“连上就断”的问题,不是手机蓝牙坏了,而是连接参数和重连策略互相打架。
4.1 连接参数:MTU、Connection Priority 和超时的取舍
Android 作为中心设备可以请求连接优先级,但外设不一定接受。CONNECTION_PRIORITY_HIGH会缩短连接间隔,吞吐上来,功耗也上来;BALANCED更适合大多数传感器场景;LOW_POWER适合低频上报。MTU 也不是越大越好,requestMtu(185)只是请求,最终以onMtuChanged回调为准。服务发现完成前不要并发读写特征值,否则容易拿到空服务列表。
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothProfile import android.content.Context fun connectGatt(context: Context, device: BluetoothDevice) { val callback = object : BluetoothGattCallback() { override fun onConnectionStateChange( gatt: BluetoothGatt, status: Int, newState: Int ) { if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED ) { // 先拿均衡优先级,服务发现后再按业务调整 gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED) gatt.discoverServices() } else { gatt.close() ReconnectPolicy.schedule(device.address, status) } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { // 185 是常见请求值,实际结果看 onMtuChanged gatt.requestMtu(185) } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { // 协商失败就按默认 23 字节走,别阻塞后续读写 } } // autoConnect=false 表示立即连接;后台重连可评估 true,但首连延迟更大 device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE) }connectGatt的autoConnect参数要按场景选:立即连接用false,系统会在设备出现时尽快尝试;后台长期等待用true更省电,但连接时延不可控。requestConnectionPriority是中心设备的请求,外设可以拒绝。requestMtu(185)之后一定要等回调,不能在下一行直接按 185 字节发数据。连接状态回调里出现status=133很常见,先close()再走退避重连,比原地反复connectGatt稳。
4.2 重连状态机:退避、上限和“假在线”清理
长连接最怕两件事:断线后立刻疯狂重连,把蓝牙栈打挂;以及连接状态还在,但链路已经假死。重连策略要有退避和抖动,别所有设备同一秒重试。连接成功后稳定一段时间再清零重试计数,否则一次抖动就把计数打满。
import android.os.Handler import android.os.Looper import java.util.concurrent.ConcurrentHashMap import kotlin.random.Random object ReconnectPolicy { private val delays = longArrayOf(1_000, 3_000, 8_000, 15_000, 30_000) private val attempts = ConcurrentHashMap<String, Int>() private val handler = Handler(Looper.getMainLooper()) fun schedule(address: String, status: Int) { val attempt = attempts.getOrDefault(address, 0) if (attempt >= delays.size) { // 超过上限先停,等用户手动触发或蓝牙状态变化 return } attempts[address] = attempt + 1 val jitter = Random.nextLong(0, 500) val delay = delays[attempt] + jitter handler.postDelayed({ // 重连前再检查一次蓝牙开关和地址合法性 BleCentral.connect(address) }, delay) } fun reset(address: String) { attempts.remove(address) } } object BleCentral { fun connect(address: String) { // 这里放实际 connectGatt 调用,重连前先 close 旧 Gatt } }退避数组从 1 秒爬到 30 秒,上限到了就停,不要无限重试。抖动值避免多台设备同时重连造成拥塞。reset要在连接稳定后调用,常见做法是连接成功并完成一次业务读写后清零。假在线靠心跳或定时读特征值发现,连续两次超时就主动disconnect(),让状态机回到重连分支。
5. 避坑与排查:iBeacon 唤醒和长连接最容易翻车的五个现场
这类问题最玄学的地方在于:前台什么都正常,锁屏、杀进程、换一台手机就全变样。下面五个现场是我拆这类包时最常遇到的,按“现象 → 原因 → 解决”记,排查时别一上来就怀疑代码。
5.1 锁屏后收不到进入区域回调
现象:App 在前台能扫到 Beacon,锁屏后等几分钟也没有进入区域日志。原因通常是用了前台startRangingBeacons或普通扫描回调,没注册 Region Monitoring;iOS 用户手动强杀 App、Android 省电策略限制后台,也会让回调不稳定。解决:iOS 改用startMonitoring(for:)并申请Always权限,冷启动补requestState;Android 用PendingIntent扫描并引导用户关闭电池优化。测试时先排除系统限制,再看代码。
5.2 Android 扫描回调时有时无
现象:同一台手机,亮屏时回调密集,锁屏后隔很久才来一次,甚至完全不来。原因是 Android 8 以后后台扫描有限流,Android 12 以后权限拆分成BLUETOOTH_SCAN和BLUETOOTH_CONNECT,部分厂商 ROM 还会额外杀后台。解决:后台场景用PendingIntent形式扫描,扫描模式降到LOW_POWER,不要频繁stopScan/startScan;把应用加入电池优化白名单;权限申请按版本分支处理,缺权限时在设置页给明确入口。
adb shell dumpsys bluetooth_manager | grep -i "scan" | head -n 30 adb logcat | grep -iE "BluetoothLeScanner|ScanCallback|Permission"这两条命令一个看系统扫描状态,一个看权限和扫描回调。排查时先确认系统层有没有在扫,再确认应用层有没有收到回调,别把权限问题当成广播帧问题。
5.3 连接成功后半分钟必断
现象:onConnectionStateChange显示已连接,服务也发现了,但大约 30 秒后断开,重连又能连上。原因常见有三类:外设固件有连接超时,长时间无 GATT 操作就主动断;手机侧连接参数太激进,外设不接受;App 在后台被冻结,心跳没发出去。解决:连接成功后按业务周期做一次轻量读或写,别让链路长期空闲;连接优先级先用BALANCED,稳定后再评估HIGH;断线回调里先close()再退避重连,避免旧 Gatt 实例堆积。
5.4 同一 UUID 下多个 Beacon 串号
现象:展厅里放了多个 Beacon,App 一会儿显示 A 点,一会儿显示 B 点,距离值跳得厉害。原因只按 UUID 做判断,没有把 Major/Minor 一起做唯一键;RSSI 抖动时又没做迟滞,导致相邻设备来回切。解决:用UUID + Major + Minor作为业务主键,RSSI 做 EMA 平滑,切换区域加迟滞窗口。比如新设备 RSSI 要连续强于当前设备 6 dB 才切换,别用单次采样做决定。
fun beaconKey(uuid: String, major: Int, minor: Int): String { return "$uuid:$major:$minor" } fun shouldSwitch(currentRssi: Int, candidateRssi: Int, thresholdDb: Int = 6): Boolean { return candidateRssi - currentRssi >= thresholdDb }thresholdDb不是越大越好,太大切换迟钝,太小又容易抖。室内展陈场景常见从 4 到 8 dB 之间试,配合 2 到 3 秒的观察窗口。
5.5 冷启动重复打开页面
现象:用户走到 Beacon 附近,App 被拉起一次,点通知又进一次,桌面图标再进一次,结果同一个页面叠了三层。原因是didEnterRegion、didDetermineState、通知点击、启动页路由都各自打开页面,没有做幂等。解决:所有入口收敛到一个路由方法,用singleTop或路由守卫按 key 去重;冷启动先判断当前栈顶,再决定是替换还是压栈。
import android.os.SystemClock object RouteGuard { private var lastKey: String? = null private var lastAt: Long = 0L fun allow(key: String, windowMs: Long = 5_000L): Boolean { val now = SystemClock.elapsedRealtime() if (key == lastKey && now - lastAt < windowMs) return false lastKey = key lastAt = now return true } }去重窗口别设太长,5 秒左右够覆盖冷启动的重复回调;页面路由的 key 建议带上 Beacon 的三元组和入口来源,排查时能一眼看出是谁拉起的。
6. 把 iBeacon.zip 跑成可验证的 Demo:广播校准、日志核对与断线压测
拿到包后别急着改业务,先按“广播端校准 → 扫描端核对 → 唤醒链路 → 长连接压测”跑一遍。广播端把 TxPower 在 1 米处实测几次,取平均值写回配置;扫描端用日志确认 UUID、Major、Minor 和 RSSI 是否稳定;唤醒链路分别测前台、后台、锁屏、冷启动四种状态;长连接至少挂 30 分钟,中间手动关一次蓝牙开关,看退避重连有没有把旧 Gatt 清掉。
adb logcat -c adb logcat | grep -E "BeaconWake|Gatt|onConnectionStateChange|didEnterRegion|RSSI"这条命令只做一件事:把广播、唤醒、连接三段的日志拉到同一个时间轴上。看到didEnterRegion后没有Gatt日志,问题在唤醒后的服务启动;看到onConnectionStateChange反复出现status=133,先查旧 Gatt 有没有close(),再查外设是不是限制了连接数。压测时我会把扫描间隔、连接参数、重连上限做成一张表,每改一项只跑一个变量,不然日志会变成黑匣子。
| 验证项 | 观察点 | 通过标准 |
|---|---|---|
| 广播校准 | 1 米处 RSSI 与 TxPower 偏差 | 多次采样偏差控制在 6 dB 内 |
| 扫描去重 | 同一 Beacon 1 秒内回调次数 | 业务层去重后不重复触发 |
| 唤醒链路 | 锁屏、后台、冷启动 | 至少一种系统路径能稳定回调 |
| 长连接 | 30 分钟断线次数 | 无主动断线,重连有退避 |
| 假在线 | 心跳连续超时 | 两次超时内主动断开重连 |
从那以后我每次拆这类 iBeacon 资源包,都先强制走一遍“广播帧核对 + 锁屏唤醒 + 半小时长连接”这三步,再谈业务页面。希望帮到你。
本文还有配套的精品资源,点击获取