这篇文章记录我最近做的一套"靠近车辆自动解锁"的 iOS 技术方案。核心思路并不复杂:
iBeacon负责判断"用户是不是靠近车了"BLE负责真正的蓝牙连接、特征发现、指令发送Coordinator负责把两者串起来,并处理状态机、防抖、边缘场景和功耗问题真正难的地方从来不是"扫描到设备",而是如何把这套能力做得稳定、低误触、低功耗、可上线。
一、为什么不能只用 BLE?
很多人第一反应是:既然最后是蓝牙解锁,那直接后台扫 BLE 不就行了?
实际做下来会发现,单靠 BLE 在 iOS 后台场景里并不好做:
- 后台扫描受限,行为并不稳定
- 持续扫蓝牙会带来功耗问题
- 很难做到"恰好在用户靠近车辆时启动连接链路"
所以更合理的方案是:
- 用
iBeacon做一个低成本的"接近触发器" - 一旦用户进入目标区域,再启动 BLE 的精细连接流程
也就是说:
iBeacon解决"什么时候值得尝试"BLE解决"连接谁、发什么指令"Coordinator解决"什么时候不该再试"
二、整体架构
我最后采用的是三层拆分:
IBeaconService- 封装
CoreLocation的 region monitoring - 只负责进入/离开区域的感知
- 封装
BluetoothService- 封装
CoreBluetooth - 只负责扫描、连接、发现特征、发送指令
- 封装
BeaconBluetoothCoordinator- 编排层
- 负责
iBeacon -> BLE联动、状态机、防抖、风险兜底、埋点和功耗代理指标
整体关系如下:
这种拆法的好处是很明显的:
IBeaconService和BluetoothService都保持纯粹- 业务策略不污染底层能力层
- 后续要加"自动解锁策略""功耗策略""车辆授权校验",都放在协调器里即可
三、iBeacon 在这里到底负责什么?
iBeacon 在这套方案里不是身份凭证,也不是最终解锁依据。
它只负责一件事:
告诉系统:用户可能到车边了,值得把 App 拉起来并尝试后续流程。
典型链路是:
- App 注册
CLBeaconRegion - 系统在底层持续监听 region
- 设备进入 region 后,系统可在后台唤醒 App
- 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_countscan_session_countscan_duration_totalconnect_attempt_countconnect_success_countconnect_latency_totalconnected_duration_totalunlock_attempt_countcooldown_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,扫到设备、连上、发指令并不难。
真正难的是把它做成一个能在真实停车场、真实手机、真实用户行为里稳定运行的能力。
而这也是我这次做这套方案时,最有价值的收获。