设备连接与数据同步
本文档介绍 iOS-JL_Health 中智能手表(Watch)设备的 BLE 连接管理与健康数据同步机制:从设备搜索、连接、状态变更通知,到健康数据(心率/步数/血氧/睡眠等)从服务器增量拉取并写入本地 SQLite 的完整链路。
Purpose and Scope
本页覆盖「设备连接与数据同步」这条完整能力链路:
- 设备连接:基于
JL_BLEKit框架的 BLE 扫描、过滤、连接、重连与状态变更通知,核心实现位于JL_RunSDK; - 设备准备:连接成功后进入设备准备(preparation)流程,涉及
JLPreparation与SyncDataManager; - 数据同步:
UserDataSync负责从云端按表增量拉取健康数据并写入本地 SQLite(JLSqliteHeartRate、JLSqliteStep、JLSqliteOxyhemoglobinSaturation等)。
以下主题属于相邻能力,由各自页面说明,不在本页展开:OTA 固件升级(kUI_JL_DEVICE_OTA)、AI 云服务(AI云服务/ 目录)、表盘/UI 管理(DialUICache、JLDialInfoExtentedModel)、HTTP 账户体系(JLWatchHttp、BasicHttp)。
Overview
本 App 的硬件生态是杰理(Jieli)智能穿戴设备。所有设备交互都基于 JL_BLEKit 蓝牙 SDK,而 JL_RunSDK 是 App 侧对 SDK 的一层单例封装,承担:
- 设备发现:初始化
JL_BLEMultiple(多设备管理实例),开启设备过滤(BLE_FILTER_ENABLE)、配对(BLE_PAIR_ENABLE),并限定只搜索 Watch 类型设备; - 连接管理:以
isConnecting标志位 +linkedUuidArr数组防止重复连接;通过connectEntity:Result:与JL_EntityM_StatusPaired状态判断连接成功; - 状态广播:以自定义通知(如
kUI_JL_DEVICE_CHANGE)把连接状态变化广播给各页面(通过JL_Tools add:Action:Own:注册); - 健康数据同步:连接成功后,
SyncDataManager触发设备数据同步,同时UserDataSync从服务器按tb_heart_rate、tb_step、tb_oxyhemoglobin_saturation、tb_sleep等表增量拉取历史数据,去重/合并后写入本地 SQLite,供健康图表页面使用。
Architecture
flowchart TD
subgraph sg_App["App 层 (JieliJianKang)"]
AppDelegate["AppDelegate<br/>(持有 bleSDK)"]
JL_RunSDK["JL_RunSDK<br/>(单例: 连接管理)"]
JLPrep["JLPreparation<br/>(设备准备流程)"]
SyncMgr["SyncDataManager<br/>(设备数据同步)"]
UserSync["UserDataSync<br/>(服务器数据同步)"]
end
subgraph sg_SDK["JL_BLEKit 蓝牙 SDK"]
BleMultiple["JL_BLEMultiple<br/>(BLE_FILTER_ENABLE / BLE_PAIR_ENABLE)"]
Entity["JL_EntityM<br/>(设备实体/状态)"]
end
subgraph sg_Cloud["云端"]
HealthAPI["健康数据 HTTP API<br/>(jwt-token 鉴权)"]
end
subgraph sg_Local["本地存储"]
Sqlite["JLSqlite* 系列<br/>(tb_heart_rate / tb_step / ...)"]
UserDefaults["NSUserDefaults<br/>(同步时间戳)"]
end
AppDelegate --> JL_RunSDK
JL_RunSDK --> BleMultiple
BleMultiple --> Entity
JL_RunSDK -->|"状态通知 kUI_JL_DEVICE_CHANGE"| JLPrep
JLPrep --> SyncMgr
UserSync -->|"AFHTTPSessionManager + jwt-token"| HealthAPI
UserSync -->|"增量写入"| Sqlite
UserSync -->|"记录最后同步时间"| UserDefaults
SyncMgr --> Sqlite
架构说明:
- JL_RunSDK 是连接子系统的唯一入口(
sharedMe单例)。它包装JL_BLEMultiple,所有 BLE 操作(扫描、连接、断开)都经由它,避免多个页面直接操作 SDK 造成状态竞争。 - JL_BLEMultiple / JL_EntityM 属于
JL_BLEKit框架,负责底层 CoreBluetooth 逻辑;JL_EntityM_StatusPaired是连接成功的判定状态。 - JLPreparation 在设备连接成功后执行"准备"流程(如初始化
SyncDataManager),isPreparateOK标记准备是否完成。 - UserDataSync 与设备无关,负责云端↔本地双向增量同步;
SyncDataManager负责设备↔本地同步。两者通过 SQLite 汇合,保证页面读到的数据一致。 - NSUserDefaults 保存每个数据表的上次同步时间戳(如
LastHealthHeartRateUpeateTime),用于增量拉取与节流判断。
设备连接实现详解
单例与 SDK 初始化
JL_RunSDK 使用 dispatch_once 实现全局单例,在 init 中完成 BLE SDK 初始化。关键配置如下:
static JL_RunSDK *SDK = nil;
+(id)sharedMe{
static dispatch_once_t predicate;
dispatch_once(&predicate, ^{
SDK = [[self alloc] init];
});
return SDK;
}
- (instancetype)init{
self = [super init];
if (self) {
isConnecting = NO;
preparationArr = [NSMutableArray new];
linkedUuidArr = [NSMutableArray new];
connectMaxCount = 20;
connectCount = 0;
/*--- 初始化JL_SDK ---*/
self.mBleMultiple = [[JL_BLEMultiple alloc] init];
self.mBleMultiple.BLE_FILTER_ENABLE = YES;
self.mBleMultiple.BLE_PAIR_ENABLE = YES;
self.mBleMultiple.BLE_TIMEOUT = 10;
/*--- 选择设备类型搜索 ---*/
self.mBleMultiple.bleDeviceTypeArr = @[@(JL_DeviceTypeWatch)];//只选Watch
self.mDialUICache = [[DialUICache alloc] init];
self.dialInfoExtentedModel = [[JLDialInfoExtentedModel alloc] init];
[self addNote];
}
return self;
}
Source: JL_RunSDK.m
设计意图:
- 单例模式保证 App 全局只有一套 BLE 连接状态机,页面之间共享同一份
JL_EntityM,避免多实例重复连接同一设备; BLE_FILTER_ENABLE = YES只接受杰理协议的设备广播;BLE_PAIR_ENABLE = YES开启配对流程;BLE_TIMEOUT = 10(秒)控制连接超时;bleDeviceTypeArr = @[@(JL_DeviceTypeWatch)]将扫描范围限定为 Watch 类型,过滤掉耳机、音箱等其他杰理设备;connectMaxCount = 20是连接重试上限计数(配合connectTimer使用,见下文)。
主动连接:connectDevice
-(void)connectDevice:(JL_EntityM*)entityM callBack:(void (^)(BOOL))callBack{
if (isConnecting) {
kJLLog(JLLOG_WARN, @"isConnecting,please wait");
callBack(NO);
return;
}
if ([linkedUuidArr containsObject:entityM.mUUID]) {
kJLLog(JLLOG_WARN, @"isLinked,please don't connect again! %@",entityM.mUUID);
callBack(YES);
return;
}
[self startTimer];
kJLLog(JLLOG_INFO, @"connectDevice:%@,item:%@", entityM.mUUID,entityM.mItem);
isConnecting = YES;
[self.mBleMultiple connectEntity:entityM Result:^(JL_EntityM_Status status) {
if (status == JL_EntityM_StatusPaired) {
[self setMBleEntityM:entityM];
callBack(YES);
}else{
callBack(NO);
}
self->isConnecting = NO;
[self stopTimer];
}];
}
Source: JL_RunSDK.m
这段代码体现了三层防护:
- 重入保护:
isConnecting为 YES 时直接拒绝新连接请求,保证同一时刻只有一个连接流程在跑; - 去重保护:
linkedUuidArr记录已连接设备的 UUID,重复连接同一设备直接回调YES(幂等); - 超时兜底:
startTimer启动连接计时器,防止 SDK 回调永远不触发导致isConnecting卡死;成功/失败回调中都会stopTimer并复位标志位。
连接成功的判定是 JL_EntityM_StatusPaired——即设备完成配对,此时通过 setMBleEntityM: 保存当前设备实体。此外还提供 connectDeviceMac:callBack: 通过 MAC 地址连接(同文件第 116 行起),用于已知设备的快速重连。
连接状态广播
连接状态的变化通过自定义通知广播给所有关注页面:
NSString *kUI_JL_DEVICE_CHANGE = @"UI_JL_DEVICE_CHANGE";
NSString *kUI_JL_DEVICE_PREPARING = @"UI_JL_DEVICE_PREPARING";
NSString *kUI_JL_DEVICE_OTA = @"UI_JL_DEVICE_OTA";
NSString *kUI_JL_BLE_SCAN_OPEN = @"UI_JL_BLE_SCAN_OPEN";
NSString *kUI_JL_BLE_SCAN_CLOSE = @"UI_JL_BLE_SCAN_CLOSE";
NSString *kUI_RECONNECT_TO_DEVICE = @"UI_RECONNECT_TO_DEVICE";
Source: JL_RunSDK.m
通知的注册与监听通过 JL_Tools 工具类完成(基于 NSNotificationCenter 的封装)。AppDelegate 中可以看到典型的注册方式:
[JL_Tools add:kJL_BLE_M_EDR_CHANGE Action:@selector(noteEdrChange:) Own:self];
Source: AppDelegate.m
设计意图:连接状态(扫描开启/关闭、设备变化、准备中、OTA 中、需要重连)是跨页面共享的系统级事件,用通知而非回调链可以解耦——无论当前处于搜索页、首页还是设置页,都能感知设备状态变化并刷新 UI。
Core Flow:连接 → 准备 → 同步
sequenceDiagram
participant UI as 搜索/首页 UI
participant SDK as JL_RunSDK
participant BLE as JL_BLEMultiple (BLEKit)
participant Prep as JLPreparation
participant SM as SyncDataManager
participant US as UserDataSync
participant Cloud as 健康数据 API
participant DB as JLSqlite* (SQLite)
UI->>SDK: sharedMe (单例初始化)
SDK->>BLE: 初始化(过滤/配对/超时10s, 仅Watch)
UI->>SDK: connectDevice:callBack:
SDK->>SDK: isConnecting 检查 + linkedUuidArr 去重
SDK->>BLE: connectEntity:Result:
BLE-->>SDK: JL_EntityM_StatusPaired
SDK->>SDK: setMBleEntityM: + 回调 YES + 复位 isConnecting
SDK-->>UI: 通知 kUI_JL_DEVICE_CHANGE / kUI_JL_DEVICE_PREPARING
UI->>Prep: 进入准备流程
Prep->>SM: [SyncDataManager share] 启动设备数据同步
SM-->>DB: 读取/写入设备健康数据
US->>Cloud: 校验登录态 + 10小时节流
Cloud-->>US: 增量健康数据 (按表)
US->>DB: 解析为图表模型并 s_sync_update:
US->>US: 更新 NSUserDefaults 同步时间戳
流程要点:
- 连接阶段:
JL_RunSDK是唯一入口,内部通过isConnecting/linkedUuidArr双重防护后调用JL_BLEMultiple connectEntity:Result:;回调JL_EntityM_StatusPaired才算连接成功,随后广播kUI_JL_DEVICE_CHANGE并记录 UUID。 - 准备阶段:
JLPreparation在连接成功后执行准备流程,其中关键一步是[SyncDataManager share]——它负责设备侧健康数据的同步(见 JLPreparation.m),并用isPreparateOK标记流程是否完成。 - 云同步阶段:
UserDataSync updateHealthAndSportDataFile在后台低优先级队列中按表(心率/步数/血氧/睡眠等)增量拉取,先查本地最后一条数据的startDate作为拉取起点,再合并写入 SQLite,最后更新时间戳。 - 持久化阶段:所有健康数据最终落在
JLSqlite*系列类(如JLSqliteHeartRate、JLSqliteStep、JLSqliteOxyhemoglobinSaturation)对应的表中,健康图表页面直接读取本地库,避免频繁请求网络。
健康数据同步实现详解(UserDataSync)
同步时间戳与节流
#define LastHealthHeartRateUpeateTime @"LastHealthHeartRateUpeateTime"///上一次心率健康数据下载时间
#define LastHealthStepCountUpdateTime @"LastHealthStepCountUpdateTime"///上一次步数健康数据下载时间
#define LastHealthSpoDataUpdateTime @"LastHealthSpoDataUpdateTime"///上一次血氧健康数据下载时间
#define LastHealthSleepDataUpdateTime @"LastHealthSleepDataUpdateTime"///上一次睡眠健康数据下载时间
#define LastHealthWeightDataUpdateTime @"LastHealthWeightDataUpdateTime"///上一次体重健康数据下载时间
#define LastSportUpdateTime @"LastSportUpdateTime"///上一次运动数据下载时间
#define LastAllDataUpdateTime @"LastAllDataUpdateTime"///上一次所有数据下载时间
Source: UserDataSync.m
每种数据类型都有独立的"上次下载时间"键,并且 LastAllDataUpdateTime 作为全局节流闸门——updateHealthAndSportDataFile 在 10 小时内(36000 秒)不会重复拉取:
+ (void)updateHealthAndSportDataFile {
if ([self notLoggedIn]) {
return;
}
NSDate *lastAllDataUpdateTime = [[NSUserDefaults standardUserDefaults] objectForKey:[self getUserLastUpeateTimeKeyWithKey:LastAllDataUpdateTime]];
NSTimeInterval lastAllDataUpdateTimeInterval = [lastAllDataUpdateTime timeIntervalSince1970];
NSTimeInterval currentTimeInterval = [[NSDate date] timeIntervalSince1970];
if ((currentTimeInterval - lastAllDataUpdateTimeInterval) < 36000) {
return; // 10小时内不再从服务器拉取
}
[[NSUserDefaults standardUserDefaults] setObject:[NSDate date] forKey:[self getUserLastUpeateTimeKeyWithKey:LastAllDataUpdateTime]];
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
// 同步服务器用户健康数据
[JLSqliteHeartRate s_checkoutTheLastDataWithResult:^(JLWearSyncHealthHeartRateChart * _Nonnull chart) {
...
[UserDataSync requestHealthDataBetween:startDate End:[NSDate date] ByTableName:tb_heart_rate result:^(NSArray<UserDataHealth *> * _Nonnull array) {
...
for (UserDataHealth *data in array) {
JLWearSyncHealthHeartRateChart *chart = [[JLWearSyncHealthHeartRateChart alloc] initChart:data.data];
[JLSqliteHeartRate s_sync_update:chart];
}
...
}];
}];
...
});
}
Source: UserDataSync.m
设计意图与关键机制:
- 节流(10 小时):健康数据是低频变化数据,10 小时的全局闸门大幅减少服务器压力与流量消耗;每个表还有独立的
LastHealth*UpdateTime用于更细粒度的增量判断。 - 增量拉取:以本地 SQLite 中该表最后一条数据的
startDate为起点(无数据则从 1970 开始),End为当前时间,实现"只拉缺失区间"。 - 低优先级后台队列:整个同步放在
DISPATCH_QUEUE_PRIORITY_LOW全局队列,避免阻塞 UI 线程;同步完成后 UI 通过数据库通知/刷新。 - 合并写入:服务器数据先包装为对应图表模型(
JLWearSyncHealthHeartRateChart、JL_Chart_MoveSteps、JL_Chart_OxyhemoglobinSaturation等),再调用s_sync_update:/s_update:与本地数据合并去重。
鉴权与请求管理
+(AFHTTPSessionManager *)reqMgr{
NSString *token = [JL_Tools getUserByKey:kUI_ACCESS_TOKEN];
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
[AFJSONRequestSerializer serializer].cachePolicy = NSURLRequestReturnCacheDataElseLoad;
[manager setRequestSerializer:[AFJSONRequestSerializer serializer]];
[manager.requestSerializer setValue:token forHTTPHeaderField:@"jwt-token"];
return manager;
}
Source: UserDataSync.m
所有云同步请求共用 reqMgr:从 NSUserDefaults(键 kUI_ACCESS_TOKEN)取出登录 token 放入 jwt-token 请求头;采用 AFNetworking 的 AFHTTPSessionManager,JSON 序列化并启用"有缓存则读缓存"策略,进一步降低重复请求成本。未登录时(notLoggedIn)直接返回,不做任何同步。
Configuration Options
连接与同步子系统的可配置项(均在代码中直接设置或通过 NSUserDefaults 持久化):
| 选项 | 类型 | 默认值 | 说明 | 位置 |
|---|---|---|---|---|
BLE_FILTER_ENABLE | BOOL | YES | 是否启用杰理协议设备过滤,只连接合法杰理设备 | JL_RunSDK.m L74 |
BLE_PAIR_ENABLE | BOOL | YES | 是否启用配对流程 | JL_RunSDK.m L75 |
BLE_TIMEOUT | NSTimeInterval | 10(秒) | BLE 连接超时时间 | JL_RunSDK.m L76 |
bleDeviceTypeArr | NSArray | @[@(JL_DeviceTypeWatch)] | 可搜索的设备类型集合,当前仅 Watch | JL_RunSDK.m L80 |
connectMaxCount | int | 20 | 连接重试最大计数(配合 connectTimer) | JL_RunSDK.m L70 |
LastAllDataUpdateTime | NSDate (UserDefaults) | nil | 全局云同步节流时间戳,间隔 < 36000s 跳过拉取 | UserDataSync.m L30/L53-L60 |
LastHealth*UpdateTime | NSDate (UserDefaults) | nil | 各健康类型(心率/步数/血氧/睡眠/体重/运动)上次下载时间 | UserDataSync.m L23-L29 |
tb_heart_rate 等表名 | NSString | 固定表名 | 服务器健康数据表名(心率/步数/血氧/睡眠) | UserDataSync.m L71-L122 |
sport / sport_download | NSString (路径) | 文档目录子路径 | 运动数据文件上传/下载目录 | UserDataSync.m L32-L33 |
kUI_ACCESS_TOKEN | NSString (UserDefaults) | nil | 登录 token,写入 jwt-token 请求头 | UserDataSync.m L38 |
API Reference
+(id)sharedMe
获取 JL_RunSDK 全局单例(dispatch_once 保证只初始化一次)。
Returns: JL_RunSDK 实例。
-(void)connectDevice:(JL_EntityM*)entityM callBack:(void (^)(BOOL))callBack
主动连接指定 BLE 设备。
Parameters:
entityM(JL_EntityM*):目标设备实体(来自扫描结果);callBack(void (^)(BOOL)):连接结果回调,YES表示已配对成功。
行为: 若 isConnecting 为 YES 立即回调 NO;若 UUID 已在 linkedUuidArr 中直接回调 YES(幂等);否则启动连接计时器并调用 mBleMultiple connectEntity:Result:,配对成功后调用 setMBleEntityM: 并复位 isConnecting/停止计时器。
-(void)connectDeviceMac:(NSString*)mac callBack:(void (^)(BOOL))callBack
通过 MAC 地址连接已知设备(用于快速重连场景),语义与 connectDevice: 一致。
+ (void)updateHealthAndSportDataFile
同步云端健康与运动数据到本地 SQLite。
前置条件: 已登录(否则直接返回);距上次全局同步超过 36000 秒(10 小时节流)。
行为: 在低优先级后台队列中,分别查询本地各表最后一条数据的 startDate 作为起点,调用 requestHealthDataBetween:End:ByTableName:result: 拉取增量,解析为图表模型后经 s_sync_update:/s_update: 合并写入 SQLite,最后更新各表时间戳。
+(AFHTTPSessionManager *)reqMgr
构建携带 jwt-token 请求头的 AFNetworking 会话管理器(JSON 序列化 + 缓存优先策略)。
Failure Modes, Edge Cases & Concurrency
并发与重入保护
isConnecting互斥标志:同一时刻只允许一个连接流程。UI 上快速连续点击"连接"时,后到请求直接回调NO并打JLLOG_WARN日志,防止JL_BLEMultiple进入不一致状态。linkedUuidArr幂等去重:对已连接设备重复发起连接会直接返回YES,避免蓝牙层重复 pair 造成设备端异常。- 连接计时器兜底:
connectTimer+connectCount/connectMaxCount(20) 用于处理 SDK 回调丢失的极端情况,防止isConnecting永久卡死导致后续连接全部被拒。
边界条件
- 未登录:
UserDataSync notLoggedIn时云同步直接跳过,本地 SQLite 仍可正常读写,App 保持离线可用。 - 首次使用无本地数据:
chart.xxxList.firstObject.startDate == nil时起点回退到1970-01-01(dateWithTimeIntervalSince1970:0),保证全量拉取不漏数据。 - 同步节流窗口:10 小时内重复调用
updateHealthAndSportDataFile会被静默跳过(日志被注释掉,线上无噪音),时间戳在进入节流判断前就已更新,避免并发调用穿透节流。
数据一致性
- 云端数据合并采用"查本地最后一条 → 拉取其后区间 →
s_sync_update:合并"策略,天然去重且不会覆盖本地较新的设备数据; - 各表独立时间戳(心率/步数/血氧/睡眠/体重/运动)允许部分表同步失败后其余表仍能正常推进,互不阻塞;
- 同步全部在
DISPATCH_QUEUE_PRIORITY_LOW后台队列执行,配合__weak self防止循环引用,UI 线程不受影响。
失败处理
- 连接失败:回调
NO,isConnecting复位,计时器停止,UI 依据kUI_JL_DEVICE_CHANGE通知刷新状态; - token 失效:
jwt-token请求返回 401 时由上层 HTTP 层(JLWatchHttp/BasicHttp)统一处理,本子系统只负责携带 token; - 设备中途断开:通过
kUI_RECONNECT_TO_DEVICE通知触发重连路径(connectDeviceMac:快速重连)。
Extension Points
- 通知机制:
JL_RunSDK通过JL_Tools add:Action:Own:注册/广播自定义通知(kUI_JL_DEVICE_CHANGE、kUI_JL_DEVICE_PREPARING、kUI_JL_BLE_SCAN_OPEN/CLOSE等)。新增页面只需监听对应通知即可感知连接状态,无需改动连接核心。 - 设备类型扩展:
bleDeviceTypeArr当前仅含JL_DeviceTypeWatch;若产品线增加新设备类型(如手环),只需向该数组追加枚举值。 - 新增健康数据类型:仿照心率/步数/血氧的模板——在
UserDataSync中增加LastHealthXxxUpdateTime键 +requestHealthDataBetween:...ByTableName:分支 + 对应的JLSqlite*落库类即可接入同一条增量同步链路。 - 底层 SDK 替换:所有 BLE 操作都收敛在
JL_RunSDK对JL_BLEMultiple的封装之后,上层页面不直接触碰JL_BLEKit,SDK 升级或替换时影响面可控。
Tests
仓库中未发现针对 JL_RunSDK / UserDataSync 的独立单元测试用例(测试目录以 UI 页面验证为主)。连接与同步逻辑的正确性主要依赖:kJLLog 运行日志(连接警告、同步流程日志)、真机 BLE 联调、以及 NSUserDefaults 时间戳的持久化行为验证。建议后续补充:连接状态机单元测试(isConnecting/linkedUuidArr 分支覆盖)与增量拉取区间计算的纯函数测试。
Related Links
- JL_RunSDK.m(连接管理核心)
- UserDataSync.m(云健康数据同步)
- JLPreparation.m(设备准备流程)
- AppDelegate.m(SDK 初始化与通知注册)
- 相邻能力页:OTA 固件升级(
kUI_JL_DEVICE_OTA)、AI 云服务、表盘/UI 管理(DialUICache)、账户体系(JLWatchHttp)