一次 iBeacon + BLE 无感解锁方案的实现记录

这篇文章记录我最近做的一套"靠近车辆自动解锁"的 iOS 技术方案。核心思路并不复杂:

  • iBeacon 负责判断"用户是不是靠近车了"
  • BLE 负责真正的蓝牙连接、特征发现、指令发送
  • Coordinator 负责把两者串起来,并处理状态机、防抖、边缘场景和功耗问题

真正难的地方从来不是"扫描到设备",而是如何把这套能力做得稳定、低误触、低功耗、可上线。

一、为什么不能只用 BLE?

很多人第一反应是:既然最后是蓝牙解锁,那直接后台扫 BLE 不就行了?

实际做下来会发现,单靠 BLE 在 iOS 后台场景里并不好做:

  • 后台扫描受限,行为并不稳定
  • 持续扫蓝牙会带来功耗问题
  • 很难做到"恰好在用户靠近车辆时启动连接链路"

所以更合理的方案是:

  • 用 iBeacon 做一个低成本的"接近触发器"
  • 一旦用户进入目标区域,再启动 BLE 的精细连接流程

也就是说:

  • iBeacon 解决"什么时候值得尝试"
  • BLE 解决"连接谁、发什么指令"
  • Coordinator 解决"什么时候不该再试"

二、整体架构

我最后采用的是三层拆分:

  • IBeaconService
    • 封装 CoreLocation 的 region monitoring
    • 只负责进入/离开区域的感知
  • BluetoothService
    • 封装 CoreBluetooth
    • 只负责扫描、连接、发现特征、发送指令
  • BeaconBluetoothCoordinator
    • 编排层
    • 负责 iBeacon -> BLE 联动、状态机、防抖、风险兜底、埋点和功耗代理指标

整体关系如下:

flowchart LR A[iBeacon Region Monitoring] --> B[BeaconBluetoothCoordinator] B --> C[BluetoothService] C --> D[扫描设备] D --> E[连接 Peripheral] E --> F[发现 Service / Characteristic] F --> G[发送解锁指令] B --> H[VehicleAccessService] B --> I[UnlockTelemetryService] B --> J[LoginService]

这种拆法的好处是很明显的:

  • IBeaconService 和 BluetoothService 都保持纯粹
  • 业务策略不污染底层能力层
  • 后续要加"自动解锁策略""功耗策略""车辆授权校验",都放在协调器里即可

三、iBeacon 在这里到底负责什么?

iBeacon 在这套方案里不是身份凭证,也不是最终解锁依据。

它只负责一件事:

告诉系统:用户可能到车边了,值得把 App 拉起来并尝试后续流程。

典型链路是:

  1. App 注册 CLBeaconRegion
  2. 系统在底层持续监听 region
  3. 设备进入 region 后,系统可在后台唤醒 App
  4. App 收到回调后,再启动 BLE 连接流程

这里有一个很重要的限制:

  • 如果用户手动上滑杀掉 App,iOS 不会再通过 iBeacon 把它拉活

这是系统级限制,不是代码能绕过的。


四、BLE 在这里负责什么?

BLE 部分就比较标准:

  • 扫描周围设备
  • 发现目标 Peripheral
  • 建立连接
  • 发现 Service
  • 发现 Characteristic
  • 找到可写特征和通知特征
  • 发解锁指令
  • 监听设备返回

核心协议定义大概如下:

objective-c 复制代码
@protocol BluetoothService <NSObject>

@property (nonatomic, weak, nullable) id<BluetoothServiceDelegate> delegate;

- (void)bootstrapWithLaunchOptions:(nullable NSDictionary *)launchOptions;

- (void)configurePreferredServiceUUIDString:(nullable NSString *)serviceUUIDString
             writeCharacteristicUUIDString:(nullable NSString *)writeCharacteristicUUIDString
            notifyCharacteristicUUIDString:(nullable NSString *)notifyCharacteristicUUIDString;

- (void)startScan;
- (void)stopScan;
- (BOOL)isScanning;

- (void)connectPeripheralWithIdentifier:(NSUUID *)identifier;
- (void)disconnectCurrentPeripheral;
- (BOOL)isConnected;

- (void)sendCommandData:(NSData *)data;
- (void)sendHexCommand:(NSString *)hexCommand;

@end

发送指令时,我保留了两种方式:

  • 直接发 NSData
  • 发十六进制字符串,由模块内部转成 NSData

这样业务层会轻很多。

例如:

objective-c 复制代码
- (void)sendHexCommand:(NSString *)hexCommand {
    NSData *data = [self dataFromHexString:hexCommand];
    if (!data) {
        [self emitStatusMessage:@"发送失败,HEX 指令格式非法。"];
        return;
    }

    [self sendCommandData:data];
}

五、真正的难点:状态机

如果只写出"进入 region 就扫描蓝牙",这个功能看起来已经通了。

但一上真机,很快就会暴露问题:

  • beacon 信号会抖动
  • 用户站在车边时,可能频繁触发 enter / exit
  • 如果每次都重新扫蓝牙、重新连接,会疯狂耗电
  • 两台车停得近时,可能同时扫到多个候选设备
  • 已连接状态下重复触发,会发生无意义重连

所以必须引入状态机。

我在协调器里定义了这几个状态:

objective-c 复制代码
typedef NS_ENUM(NSUInteger, BeaconBluetoothFlowState) {
    BeaconBluetoothFlowStateIdle,
    BeaconBluetoothFlowStateBeaconCandidate,
    BeaconBluetoothFlowStateScanning,
    BeaconBluetoothFlowStateConnecting,
    BeaconBluetoothFlowStateConnected,
    BeaconBluetoothFlowStateUnlocking,
    BeaconBluetoothFlowStateCooldown,
};

含义分别是:

  • Idle
    • 空闲态,等待下一次触发
  • BeaconCandidate
    • 收到 beacon 进入事件,但先不立刻连,等待防抖确认
  • Scanning
    • 正在扫描 BLE
  • Connecting
    • 正在连接设备
  • Connected
    • 已连接,保持 GATT 通道
  • Unlocking
    • 正在发送解锁指令
  • Cooldown
    • 冷却态,短时间内不再重复触发

六、防抖怎么做?

我没有采用"收到 enter 就立即连"的激进做法,而是加了三层节流:

1. 进入防抖

收到 didEnterRegion 或 didDetermineState == Inside 后:

  • 先进入 BeaconCandidate
  • 延迟 2s
  • 如果 2 秒后用户仍然被认为在区域内,再开始 BLE 扫描

核心代码:

objective-c 复制代码
- (void)armInsideDebounceWithStatus:(NSString *)status {
    self.beaconInside = YES;
    [self.telemetryService incrementMetric:@"beacon_trigger_count" delta:1];

    if (![self hasAutoUnlockEligibility]) {
        [self enterCooldownWithReason:@"蓝牙: 当前账号不满足自动解锁条件"];
        return;
    }

    if (self.flowState == BeaconBluetoothFlowStateConnecting ||
        self.flowState == BeaconBluetoothFlowStateConnected ||
        self.flowState == BeaconBluetoothFlowStateUnlocking) {
        [self.telemetryService incrementMetric:@"beacon_ignored_while_busy_count" delta:1];
        [self transitionToState:self.flowState bluetoothStatus:@"蓝牙: 已有活动连接,忽略重复触发"];
        return;
    }

    if (self.flowState == BeaconBluetoothFlowStateCooldown) {
        [self.telemetryService incrementMetric:@"beacon_trigger_during_cooldown_count" delta:1];
        return;
    }

    [self transitionToState:BeaconBluetoothFlowStateBeaconCandidate bluetoothStatus:status];

    dispatch_block_t block = dispatch_block_create(0, ^{
        if (!self.beaconInside || self.flowState != BeaconBluetoothFlowStateBeaconCandidate) {
            return;
        }

        self.devices = @[];
        [self.bluetoothService startScan];
        [self transitionToState:BeaconBluetoothFlowStateScanning bluetoothStatus:@"蓝牙: 防抖完成,开始扫描车辆设备"];
        [self scheduleScanTimeout];
    });

    self.insideDebounceBlock = block;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)),
                   dispatch_get_main_queue(),
                   block);
}

2. 退出宽限

收到 didExitRegion 后:

  • 不立刻断蓝牙
  • 给一个 10s 宽限
  • 如果这 10 秒内又重新进入区域,就取消退出流程

这样可以显著减少站在车边时因为 beacon 浮动导致的"断开又重连"。

3. 扫描超时 + 冷却期

扫描不是无限扫,而是:

  • 扫描 8 秒
  • 如果没找到合适设备,进入 15 秒冷却
  • 冷却期内忽略重复自动触发

这能有效控制功耗。


七、如何处理多车重叠?

如果停车场里两台车离得很近,只靠 beacon 很可能是不够的。

我这里的策略是:

  • beacon 只做粗触发
  • BLE 扫描后,再结合候选设备做授权过滤
  • 如果同时出现多个可连接设备,直接拒绝自动连接

核心逻辑:

objective-c 复制代码
- (NSUInteger)connectableCountInDevices {
    NSUInteger count = 0;
    for (NSDictionary *item in self.devices) {
        if ([item[@"isConnectable"] isEqualToString:@"YES"]) {
            count += 1;
        }
    }
    return count;
}

在自动连接前判断:

objective-c 复制代码
NSUInteger c = [self connectableCountInDevices];
if (c > 1) {
    [self trackEvent:@"ble_auto_connect_rejected"
          attributes:@{ @"reason": @"multiple_connectable" }];
    [self enterCooldownWithReason:@"蓝牙: 检测到多台可连接车辆,拒绝自动连接"];
    return;
}

宁可保守一点,也不要误连、误解锁。


八、用户权限怎么做兜底?

另一个边缘问题是:

不是车主但靠近车怎么办?

我的处理方式是把"是否靠近"和"是否允许解锁"分开:

  • iBeacon 只负责触发
  • VehicleAccessService 负责授权过滤
  • Coordinator 在自动连接前调用授权判断

协议如下:

objective-c 复制代码
@protocol VehicleAccessService <NSObject>

- (nullable NSDictionary<NSString *, NSString *> *)preferredAuthorizedDeviceFromCandidates:(NSArray<NSDictionary<NSString *, NSString *> *> *)candidates
                                                                           rejectionReason:(NSString * _Nullable * _Nullable)rejectionReason;

@end

一个简化版实现如下:

objective-c 复制代码
- (NSDictionary<NSString *,NSString *> *)preferredAuthorizedDeviceFromCandidates:(NSArray<NSDictionary<NSString *,NSString *> *> *)candidates
                                                                 rejectionReason:(NSString * _Nullable __autoreleasing *)rejectionReason {
    NSMutableArray<NSDictionary<NSString *, NSString *> *> *connectable = [NSMutableArray array];
    for (NSDictionary<NSString *, NSString *> *item in candidates) {
        if ([item[@"isConnectable"] isEqualToString:@"YES"]) {
            [connectable addObject:item];
        }
    }

    if (connectable.count == 0) {
        if (rejectionReason) {
            *rejectionReason = @"尚未发现可连接车辆";
        }
        return nil;
    }

    if (connectable.count > 1) {
        if (rejectionReason) {
            *rejectionReason = @"检测到多台候选车辆,已禁止自动解锁";
        }
        return nil;
    }

    NSDictionary<NSString *, NSString *> *device = connectable.firstObject;
    NSArray<NSString *> *authorizedIdentifiers = [[NSUserDefaults standardUserDefaults] arrayForKey:@"authorized_vehicle_identifiers"];

    if (authorizedIdentifiers.count == 0) {
        return device;
    }

    if (![authorizedIdentifiers containsObject:device[@"identifier"] ?: @""]) {
        if (rejectionReason) {
            *rejectionReason = @"当前账号对该车辆无解锁权限";
        }
        return nil;
    }

    return device;
}

真正上线时,这里当然应该接服务端签名令牌、车辆绑定关系,而不是只靠本地白名单。


九、Coordinator 才是这套方案的核心

这套方案能跑起来,关键就在 BeaconBluetoothCoordinatorImpl。

它的职责不是"把各种逻辑都堆进去",而是做编排:

  • 接收 iBeacon 事件
  • 决定是否进入防抖态
  • 决定是否启动扫描
  • 决定是否进入连接态
  • 决定何时进入冷却态
  • 接入权限判断与埋点

例如状态迁移:

objective-c 复制代码
- (void)transitionToState:(BeaconBluetoothFlowState)state bluetoothStatus:(NSString *)status {
    BeaconBluetoothFlowState previous = self.flowState;
    [self flushPowerMetricsForStateTransitionFrom:previous to:state];
    self.flowState = state;
    self.bluetoothStatus = status;
    [self trackEvent:@"flow_state_changed"
          attributes:@{
              @"from": @(previous),
              @"to": @(state),
              @"status": status ?: @""
          }];
    [self postStateDidChange];
}

而自动连接部分则是:

objective-c 复制代码
- (void)bluetoothService:(id<BluetoothService>)service
    didDiscoverDeviceInfo:(NSDictionary<NSString *,NSString *> *)deviceInfo {

    NSMutableArray *m = [self.devices mutableCopy];
    NSUInteger i = NSNotFound;
    for (NSUInteger idx = 0; idx < m.count; idx++) {
        if ([m[idx][@"identifier"] isEqualToString:deviceInfo[@"identifier"]]) {
            i = idx;
            break;
        }
    }

    if (i != NSNotFound) {
        [m replaceObjectAtIndex:i withObject:deviceInfo];
    } else {
        [m addObject:deviceInfo];
    }

    self.devices = m.copy;
    [self postStateDidChange];

    if (self.flowState != BeaconBluetoothFlowStateScanning || !self.beaconInside) {
        return;
    }

    NSUInteger c = [self connectableCountInDevices];
    if (c > 1) {
        [self enterCooldownWithReason:@"蓝牙: 检测到多台可连接车辆,拒绝自动连接"];
        return;
    }

    NSDictionary<NSString *, NSString *> *authorizedDevice = [self preferredAuthorizedDeviceFromDevices];
    if (!authorizedDevice) {
        return;
    }

    NSString *identifier = authorizedDevice[@"identifier"];
    if (identifier.length == 0) {
        return;
    }

    [self cancelPendingBlocks];
    [self.bluetoothService connectPeripheralWithIdentifier:[[NSUUID alloc] initWithUUIDString:identifier]];
    [self transitionToState:BeaconBluetoothFlowStateConnecting
            bluetoothStatus:[NSString stringWithFormat:@"蓝牙: 自动连接 %@",
                             authorizedDevice[@"name"] ?: @"车辆设备"]];
}

十、功耗问题怎么监控?

做这类功能,不能只关心"通不通",还要关心"费不费电"。

iOS 没有直接给你"这个功能今天耗了多少 % 电量"的精确 API,所以更现实的做法是记录一组功耗代理指标。

我这边定义了一个埋点服务:

objective-c 复制代码
@protocol UnlockTelemetryService <NSObject>

- (void)trackEvent:(NSString *)event attributes:(nullable NSDictionary<NSString *, id> *)attributes;
- (void)incrementMetric:(NSString *)metricKey delta:(double)delta;
- (void)accumulateDurationMetric:(NSString *)metricKey seconds:(NSTimeInterval)seconds;
- (NSDictionary<NSString *, id> *)currentPowerMetricsSnapshot;

@end

在状态机里自动统计这些指标:

  • beacon_trigger_count
  • scan_session_count
  • scan_duration_total
  • connect_attempt_count
  • connect_success_count
  • connect_latency_total
  • connected_duration_total
  • unlock_attempt_count
  • cooldown_count

例如状态切换时统计扫描时长、连接时长:

objective-c 复制代码
- (void)flushPowerMetricsForStateTransitionFrom:(BeaconBluetoothFlowState)fromState
                                             to:(BeaconBluetoothFlowState)toState {
    NSDate *now = [NSDate date];

    if (toState == BeaconBluetoothFlowStateScanning &&
        fromState != BeaconBluetoothFlowStateScanning) {
        self.scanStartedAt = now;
        [self.telemetryService incrementMetric:@"scan_session_count" delta:1];
    }

    if (fromState == BeaconBluetoothFlowStateScanning &&
        toState != BeaconBluetoothFlowStateScanning &&
        self.scanStartedAt) {
        [self.telemetryService accumulateDurationMetric:@"scan_duration_total"
                                                seconds:[now timeIntervalSinceDate:self.scanStartedAt]];
        self.scanStartedAt = nil;
    }

    if (toState == BeaconBluetoothFlowStateConnecting &&
        fromState != BeaconBluetoothFlowStateConnecting) {
        self.connectStartedAt = now;
        [self.telemetryService incrementMetric:@"connect_attempt_count" delta:1];
    }

    if (toState == BeaconBluetoothFlowStateConnected &&
        fromState != BeaconBluetoothFlowStateConnected) {
        [self.telemetryService incrementMetric:@"connect_success_count" delta:1];
        if (self.connectStartedAt) {
            [self.telemetryService accumulateDurationMetric:@"connect_latency_total"
                                                    seconds:[now timeIntervalSinceDate:self.connectStartedAt]];
            self.connectStartedAt = nil;
        }
        self.connectedAt = now;
    }

    if (fromState == BeaconBluetoothFlowStateConnected &&
        toState != BeaconBluetoothFlowStateConnected &&
        self.connectedAt) {
        [self.telemetryService accumulateDurationMetric:@"connected_duration_total"
                                                seconds:[now timeIntervalSinceDate:self.connectedAt]];
        self.connectedAt = nil;
    }
}

这套指标虽然不能直接等价为"耗电百分比",但足够回答这些问题:

  • 用户一天扫了多久蓝牙?
  • 是不是连接失败导致频繁重试?
  • GATT 通道是不是保持太久?
  • beacon 抖动是否导致过多无效触发?

十一、这套方案还有哪些边界?

这套设计已经能覆盖一个比较完整的 Demo 到工程化原型,但它仍然有明确边界:

1. 用户手动杀 App

  • iOS 不会再通过 iBeacon 拉活
  • 这是系统设计,不是代码问题

2. 手机没电或系统关机

  • App 侧无能为力
  • 产品上必须保留机械钥匙、NFC 或其他备用开锁方式

3. 权限模型仍然是简化版

  • 当前 Demo 用的是本地白名单
  • 真正上线应该接账号、车辆绑定、服务端授权令牌

4. 功耗监控还是本地聚合

  • 当前指标存在 NSUserDefaults
  • 后续应该做按天聚合、上传策略和线上报表

十二、我的一些经验总结

最后总结几个我在这个方案里最有感触的点:

1. 不要让 iBeacon 和 BLE 直接互相依赖

如果 IBeaconService 里直接写蓝牙逻辑,后面一定会膨胀。

更稳的方式一定是:

  • 能力层保持纯粹
  • 编排层承接复杂策略

2. 状态机不是锦上添花,是必需品

只要涉及:

  • 后台触发
  • 无线信号波动
  • 自动连接
  • 自动解锁

状态机就不是"优化项",而是基本盘。

3. 防抖比"快速响应"更重要

无感体验的本质不是"越快越好",而是"稳定地在正确时机动作"。

4. 安全策略一定要保守

多车重叠、权限不确定、信号模糊时,宁可不自动解锁,也不要误解锁。

5. 功耗一定要有数据闭环

很多功能开发时"感觉没问题",一上线就被用户反馈"掉电快"。

没有指标,你永远不知道问题出在哪。


十三、结语

回过头看,这套方案的关键并不是某个 API 多高级,而是职责划分是否清晰:

  • iBeacon 做触发
  • BLE 做连接和指令
  • Coordinator 做编排
  • 状态机 做稳定性控制
  • 授权服务 做安全兜底
  • 埋点与功耗指标 做上线后的闭环治理

如果只是做一个 Demo,扫到设备、连上、发指令并不难。

真正难的是把它做成一个能在真实停车场、真实手机、真实用户行为里稳定运行的能力。

而这也是我这次做这套方案时,最有价值的收获。

相关推荐
小兔子3 小时前
Python 的 GIL 与 free-threading:3.13 之后「去 GIL」走到哪一步了
前端
IT_陈寒3 小时前
SpringBoot自动配置差点让我加班到凌晨
前端·人工智能·后端
guslegend4 小时前
脚手架原理与本地调试:从 bin 软链接到 npm link
前端·npm·node.js·脚手架·前端工程化
海码事务所4 小时前
Google Play 新个人开发者账号上架指南:12 人连续 14 天封闭测试怎么做?
前端
田威AI4 小时前
图片内文字翻译的规格:输入输出、保真、自动化、时间与费用
前端·计算机视觉
不可能片场5 小时前
命令行中文变问号 我用环境变量救了场
前端·electron
lerhxx5 小时前
AI 应用中的上下文管理 —— 从"上下文窗口"到"分层压缩"的工程实践
前端·javascript
骑着蜗牛撵大象3275 小时前
多 Agent 分治协作:MapReduce 模式下的并行扇出与结果归并
前端
用户55318297325 小时前
Flutter iOS 热更新深入 Dart VM:读懂函数入口、解释执行与调用桥接
前端