本地数据库与持久化
本文档说明 iOS-JL_Health 项目中设备列表、用户配置、表盘状态等数据的本地持久化机制,涵盖 SQLite 设备数据库(JLDeviceSqliteManager)、UserDefaults 轻量存储、plist 资源配置读取与表盘本地缓存,以及它们在上层 ViewModel 中的完整读写流程。
Purpose and Scope
本页面聚焦于 App 侧的本地数据持久化能力,回答以下问题:
- 设备列表数据如何写入/读取本地 SQLite 数据库(通过 SDK 提供的
JLDeviceSqliteManager单例); UserDefaults在测试工具中如何保存 WiFi 配置等轻量键值数据;- plist 资源文件如何被解码为 Swift 字典;
- 表盘(Dial)的当前选中状态与已下载列表如何通过
BridgeHelper.dialCache()在本地维护; - 上层
ViewModel如何编排「本地读取 → 服务器同步 → 本地回写 → UI 刷新」的完整数据流。
以下相关主题不在本页面范围内,请参见对应页面:
- 蓝牙设备连接与会话管理:由 SDK(
BridgeHelper、JLDeviceSqliteManager的实现)负责,本页面仅覆盖仓库侧的使用方式; - 网络请求与服务器同步:见网络层相关页面(
DeviceHttp、DialMarketHttp); - 表盘市场业务:见「表盘市场」相关页面。
Overview
iOS-JL_Health 是一个健康类 App,核心业务围绕蓝牙设备的绑定、连接与管理展开。设备列表是该业务的关键状态:用户可能在不同时间绑定多台设备(手表/手环),App 必须保证设备列表在冷启动后立即可用,即使此时网络不可用。
为此,项目采用了「本地优先(Local-First)」的持久化策略:
- SQLite 设备数据库:SDK 提供
JLDeviceSqliteManager(单例),将用户设备列表持久化到 SQLite,按用户身份标识(userIdentify)隔离数据; - UserDefaults:测试工具
SDKTestHelper使用系统级键值存储保存 WiFi SSID/密码等测试参数; - plist 资源:通过
PropertyListDecoder将打包进 Bundle 的CodeExample.plist解码为配置字典; - 表盘缓存:
BridgeHelper.dialCache()维护当前表盘名称与本地已下载表盘列表(对应手表文件系统中的表盘文件)。
设计意图:把「设备列表」等关键数据放在本地 SQLite,使 App 启动即可渲染历史设备,再异步与服务器对账(queryServerDevices),既保证了离线可用性,又保证了多端一致性。UserDefaults 与 plist 则服务于「低价值、小体积」的配置类数据,避免为它们引入数据库开销。
Architecture
下图展示了本地持久化在 App 中的分层结构与数据流向:
flowchart TD
subgraph sg_UI["UI 层"]
VC["DialHistoryViewController / 设备列表页"]
end
subgraph sg_VM["ViewModel 层"]
SubVM["DeviceSubViewModel"]
DialVM["DialHistoryViewModel"]
end
subgraph sg_Persist["持久化抽象层"]
SqliteMgr["JLDeviceSqliteManager (SDK 单例)"]
DialCache["BridgeHelper.dialCache() (SDK)"]
UD["UserDefaults"]
PlistLoader["PropertyListDecoder"]
end
subgraph sg_Storage["存储介质"]
DB[(SQLite 数据库)]
FS[(文件系统 / 表盘缓存)]
UDD[(UserDefaults 域)]
PlistFile["CodeExample.plist (Bundle)"]
end
VC -->|"设备列表回调"| SubVM
VC -->|"表盘历史/当前表盘"| DialVM
SubVM -->|"checkout / update"| SqliteMgr
SubVM -->|"服务器对账后回写"| SqliteMgr
DialVM -->|"currentWatchName / getWatchList"| DialCache
SqliteMgr --> DB
DialCache --> FS
UD --> UDD
PlistLoader --> PlistFile
UD -->|"测试工具 SettingInfo"| PlistLoader
各组件职责:
| 组件 | 职责 | 数据 |
|---|---|---|
JLDeviceSqliteManager(SDK) | 设备列表的 SQLite 读写,按用户身份隔离 | UserDeviceModel 列表 |
BridgeHelper.dialCache()(SDK) | 表盘本地状态缓存:当前表盘名、已下载表盘列表 | 表盘名称、文件路径 |
UserDefaults | 轻量键值持久化 | WiFi SSID / 密码(测试工具) |
PropertyListDecoder | 解码 Bundle 内 plist 资源 | [String: String] 配置字典 |
DeviceSubViewModel | 编排设备列表的本地读取与服务器同步 | 内存 deviceList + 数据库 |
其中 JLDeviceSqliteManager、BridgeHelper 的具体实现位于 JL SDK 库中(本仓库不包含其源码,仅包含调用方代码),因此本文对它们的方法签名与行为描述均基于仓库内真实调用点推导,并以「仓库侧证据」标注。
主要实现分析
SQLite 设备数据库:JLDeviceSqliteManager
JLDeviceSqliteManager 是 SDK 暴露的设备数据库管理器,仓库侧通过 JLDeviceSqliteManager.share() 获取单例。从调用点可以归纳出它的核心 API:
checkout(by: userIdentify, completion: ([UserDeviceModel]) -> Void):按用户身份标识查询设备列表,结果通过回调返回;update(_ model: UserDeviceModel):插入或更新一条设备记录(以 MAC 等主键为准);update(_ model: UserDeviceModel, time: String):更新设备记录并携带服务器侧更新时间戳,用于后续对账。
关键设计点是按用户隔离:所有查询都携带 userIdentify(来自 User_Http.shareInstance().userPfInfo.identify),保证不同账号的设备列表互不串扰;同时 DeviceSubViewModel 在 init 中先请求用户配置信息(requestGetUserConfigInfo)拿到 identify 后再查询数据库,说明数据库主键/分区依赖用户身份就绪。
func queryDbDevices(){
JLDeviceSqliteManager.share().checkout(by: userIdentify) { [weak self] list in
guard let self = self else {return}
self.deviceList = list
if Thread.current.isMainThread {
self.updateListCallBack?(self.deviceList)
} else {
DispatchQueue.main.async {
self.updateListCallBack?(self.deviceList)
}
}
}
}
Source: DeviceSubViewModel.swift
这段代码体现了两个意图:其一,数据库查询结果跨线程安全地回投到主线程(若回调已处于主线程则直接回调,否则 DispatchQueue.main.async),避免 UI 更新线程冲突;其二,queryDbDevices() 是本地数据源与 UI 的桥接点,每次服务器同步完成后都会再次调用它来刷新列表。
设备列表的「本地优先 + 服务器对账」数据流
DeviceSubViewModel 是整个设备持久化流程的编排者。其初始化逻辑如下:
override init() {
super.init()
userIdentify = User_Http.shareInstance().userPfInfo.identify
addNote()
if userIdentify.count == 0 {
User_Http.shareInstance().requestGetUserConfigInfo { [weak self] user in
guard let self = self else {return}
self.userIdentify = User_Http.shareInstance().userPfInfo.identify
DispatchQueue.main.async {
self.queryDbDevices()
self.queryServerDevices()
}
}
return
} else {
queryDbDevices()
}
queryServerDevices()
}
Source: DeviceSubViewModel.swift
设计要点:
- 身份未就绪时串行等待:
userIdentify为空说明用户配置尚未从服务器拉取,此时必须先完成requestGetUserConfigInfo才能查询数据库——这是「按用户隔离」的前置约束; - 本地先行,网络后补:
queryDbDevices()(读 SQLite)总是先于queryServerDevices()(读服务器)执行,保证 UI 能立刻展示历史设备; - 服务器结果回写本地:
queryServerDevices将服务器返回的每条设备通过update(_:time:)写回数据库,使本地库成为服务器数据的镜像,供下次冷启动使用:
private func queryServerDevices(){
DeviceHttp.checkList { resp in
guard let list = resp else {
AlertViewOnWindows.getFirstWindow()?.makeToast(LanguageCls.localizableTxt("网络有点问题"),position: .center)
return
}
JLLogManager.logLevel(.DEBUG, content: "DeviceHttp.checkList \(list.count)")
for item in list {
JLDeviceSqliteManager.share().update(item.beUdm(), time: item.updateTime)
item.logProperties()
}
self.queryDbDevices()
}
}
Source: DeviceSubViewModel.swift
这里 item.beUdm() 将服务器响应模型(DeviceHttp 的列表元素)转换为数据库模型 UserDeviceModel,updateTime 用于记录服务器侧时间戳——同步完成后再次调用 queryDbDevices() 使内存 deviceList 与数据库、服务器三方一致。
设备变更事件的本地落库
App 通过 JL_Tools.add("UI_JL_DEVICE_CHANGE", ...) 等通知监听设备状态变化。当用户绑定新设备(通知类型为 2,即「已连接的新设备」)或删除设备时,DeviceSubViewModel 会构建/更新 UserDeviceModel 并立即写库:
@objc private func updateDeviceList(_ note:Notification){
guard let type = note.object as? NSNumber else {return}
// 2 为已连接的新设备
if type.int32Value == 2 {
guard let (pid,vid) = DialBaseViewModel.shared.getPidVid(),let devModel = BridgeHelper.getCurrentCmdManager()?.outputDeviceModel() ,let currentEntity = BridgeHelper.getCurrentEntity() else { return }
let model = UserDeviceModel()
// ...(构建模型字段,如 mac、uuidStr、产品信息等)
JLDeviceSqliteManager.share().update(model)
self.deviceList.removeAll(where: {$0.mac == model.mac})
// ...(插入并回调 UI)
}
}
Source: DeviceSubViewModel.swift
同一模式也出现在删除与解绑流程中:服务器确认解绑(或绑定失败)后,DeviceSubViewModel 都会调用 JLDeviceSqliteManager.share().update(model) 更新本地记录,并配合 deviceList.removeAll(where:) 维护内存列表。这说明本地数据库是「事件驱动更新」的——每次设备状态变更都同步落库,避免崩溃/杀进程导致状态丢失。
UserDefaults 轻量持久化(测试工具)
SDKTestHelper 的 SettingInfo 工具类使用系统 UserDefaults 保存测试所需的 WiFi 参数,这是典型的「低价值、小体积」配置存储场景:
class func saveSSID(_ ssid: String) {
UserDefaults.standard.set(ssid, forKey: "ssid")
UserDefaults.standard.synchronize()
}
Source: SettingInfo.swift
class func getSSID() -> String? {
return UserDefaults.standard.string(forKey: "ssid")
}
Source: SettingInfo.swift
savePassword/getPassword 采用相同模式。这里显式调用 synchronize() 属于历史兼容写法(现代 iOS 无需手动同步,系统会自动落盘),但其意图是确保测试工具在进程退出前参数不丢失。
plist 资源配置读取
SourceHelper 展示了 plist 资源的读取模式:通过 R.swift 生成的文件资源引用 R.file.codeExamplePlist.url() 获取 Bundle 内 URL,再用 PropertyListDecoder 解码为 [String: String]:
var codeDict: [String: String] {
if let url = R.file.codeExamplePlist.url(),
let data = try? Data(contentsOf: url) {
let decoder = PropertyListDecoder()
if let plistData = try? decoder.decode([String: String].self, from: data) {
return plistData
}
}
return [:]
}
Source: SourceHelper.swift
这段代码体现了「只读资源配置」与「可写数据」的区分:plist 作为随包分发的静态资源(例如测试用例示例),使用只读解码;而运行时产生的状态(表盘选择、设备列表)则写入 SQLite 或缓存。
表盘本地缓存:BridgeHelper.dialCache()
表盘(Dial)相关的本地状态由 BridgeHelper.dialCache() 维护,DialHistoryViewModel 通过 KVO 观察其 currentWatch 属性实现 UI 联动:
observer = BridgeHelper.dialCache().observe(\.currentWatch, changeHandler: { obj, value in
self.updateCurrentWatch()
})
JL_Tools.add(JL_WATCH_FACE_LIST, action: #selector(updateCurrentWatch), own: self)
Source: DialHistoryViewModel.swift
func setCurrentDial(_ model:DialPayHistoryModel){
let currentDial = BridgeHelper.dialCache().currentWatchName()
let dialList = BridgeHelper.dialCache().getWatchList() as? [String] ?? []
// ... 若本地已有同名表盘,直接切换;否则下载后写入文件系统
if dialList.contains(model.name.uppercased()) {
manager.cmdWatchFlashPath("/" + model.name.uppercased(), flag: .setDial) { flag, size, path, describe in
DispatchQueue.main.async {
if flag == 0 {
BridgeHelper.dialCache().setCurrrentWatchName(model.name.uppercased())
}
}
}
}
// else 分支:DialMarketHttp 下载 watch 数据,DialManager.addFile 写入手表文件系统
}
Source: DialHistoryViewModel.swift
表盘缓存是「文件系统 + 内存缓存」的混合持久化:getWatchList() 返回本地已存在表盘名的集合(对应手表 Flash 中已刷入的表盘文件),setCurrrentWatchName 记录当前生效的表盘;只有切换命令成功(flag == 0)才更新缓存,保证缓存状态与设备实际状态一致。
核心流程
设备列表的完整生命周期(冷启动 → 同步 → 变更落库)
sequenceDiagram
participant App as App (DeviceSubViewModel)
participant Net as User_Http / DeviceHttp
participant DB as JLDeviceSqliteManager (SQLite)
participant UI as 设备列表 UI
Note over App: 冷启动
App->>App: init() 读取 userPfInfo.identify
alt identify 为空
App->>Net: requestGetUserConfigInfo()
Net-->>App: identify
end
App->>DB: checkout(by: identify)
DB-->>App: 本地设备列表 [UserDeviceModel]
App->>UI: updateListCallBack (主线程)
App->>Net: queryServerDevices() → DeviceHttp.checkList
Net-->>App: 服务器设备列表
loop 每条设备
App->>DB: update(beUdm(), time: updateTime)
end
App->>DB: checkout(by: identify) 重新查询
DB-->>App: 最新列表
App->>UI: 刷新列表
Note over App,DB: 运行期事件
Net-->>App: UI_JL_DEVICE_CHANGE / 解绑回调
App->>DB: update(model) 事件驱动落库
流程要点:
- 身份就绪是数据库访问的前置条件:数据库按
identify分区,身份未知时先请求用户配置; - 读本地 → 显示 → 同步 → 回写:本地数据先行渲染保证启动速度,服务器数据随后对账;
- 事件驱动落库:绑定/解绑/设备变更通过通知或回调触发
update(model),保证数据库始终反映最新状态; - 主线程安全:数据库回调统一切换回主线程后才驱动 UI。
表盘切换的本地状态流
sequenceDiagram
participant U as 用户
participant VM as DialHistoryViewModel
participant C as BridgeHelper.dialCache()
participant M as mFlashManager (设备)
U->>VM: 选择表盘 setCurrentDial(model)
VM->>C: currentWatchName() / getWatchList()
C-->>VM: 当前表盘 / 本地表盘列表
alt 本地已存在该表盘
VM->>M: cmdWatchFlashPath(setDial)
M-->>VM: flag == 0 (成功)
VM->>C: setCurrrentWatchName(name)
else 本地不存在
VM->>VM: DialMarketHttp 下载表盘数据
VM->>VM: DialManager.addFile 写入设备
VM->>VM: updateStatus 回调进度
end
Note over VM,C: 成功后才更新缓存,保持缓存与设备一致
数据模型与持久化映射
UserDeviceModel 与 SQLite 的映射
UserDeviceModel 是设备列表在内存与数据库之间的统一模型:
- 服务器响应模型通过
beUdm()转换为UserDeviceModel后写入数据库; - 数据库查询结果(
checkout回调)直接以[UserDeviceModel]返回给 ViewModel; mac字段作为内存列表去重主键(deviceList.removeAll(where: {$0.mac == model.mac})),推测也是数据库的唯一键;- 每条记录伴随
updateTime(服务器侧时间戳),支撑同步对账。
erDiagram
USER_DEVICE {
string mac PK "设备唯一标识"
string user_identify "按用户分区"
string uuid_str "UUID"
string update_time "服务器时间戳"
string explain "设备说明"
}
USER_CONFIG {
string identify PK "用户身份标识"
}
USER_DEVICE }o--|| USER_CONFIG : "按 identify 隔离"
说明:
UserDeviceModel的完整字段定义位于 JL SDK 库中(本仓库不含其源码),上表仅列出从仓库调用点可确证的字段(mac、uuidStr、updateTime、explain、userID),其余字段请以 SDK 头文件为准。
键值存储映射(UserDefaults)
| Key | 类型 | 读写方 | 用途 |
|---|---|---|---|
ssid | String | SettingInfo.saveSSID/getSSID | 测试 WiFi 名称 |
password | String | SettingInfo.savePassword/getPassword | 测试 WiFi 密码 |
currentWatch | (KVO 可观察) | BridgeHelper.dialCache | 当前生效表盘 |
静态资源映射(plist)
| 资源 | 解码目标 | 使用方 | 用途 |
|---|---|---|---|
CodeExample.plist | [String: String] | SourceHelper.codeDict | 测试代码示例配置 |
API Reference(仓库侧调用点归纳)
以下签名均基于仓库内真实调用点归纳。
JLDeviceSqliteManager与BridgeHelper.dialCache()的完整定义位于 JL SDK 库中,此处仅列出仓库侧可确证的使用契约。
JLDeviceSqliteManager.share() -> JLDeviceSqliteManager
获取设备数据库管理器的全局单例。仓库中所有数据库读写都通过该单例进行,保证数据库连接与事务的全局唯一性。
调用示例(见 DeviceSubViewModel.swift):
JLDeviceSqliteManager.share().checkout(by: userIdentify) { list in ... }
checkout(by: String, completion: ([UserDeviceModel]) -> Void)
按用户身份标识查询设备列表。
by(String):用户身份标识identify,来自User_Http.shareInstance().userPfInfo.identify;completion:查询结果回调,参数为设备模型数组。回调可能不在主线程,UI 更新前需自行切换线程。
update(_ model: UserDeviceModel)
插入或更新一条设备记录(以 mac 等唯一键为准),用于绑定新设备、解绑、设备信息变更等事件驱动场景。
update(_ model: UserDeviceModel, time: String)
更新设备记录并记录服务器侧时间戳 updateTime,用于服务器列表同步回写(见 queryServerDevices)。
BridgeHelper.dialCache() 相关方法
| 方法 | 行为 |
|---|---|
currentWatchName() -> String | 返回当前生效的表盘名称(与设备实际状态一致) |
getWatchList() -> [Any]? | 返回本地已下载/已刷入的表盘名称列表 |
setCurrrentWatchName(_ name: String) | 记录当前表盘名称,仅在设备切换命令成功(flag == 0)后调用 |
currentWatch(属性) | 可通过 KVO 观察,DialHistoryViewModel 用它驱动 UI 刷新 |
SettingInfo(测试工具)
| 方法 | 说明 |
|---|---|
saveSSID(_ ssid: String) | 写入 UserDefaults 的 ssid 键 |
getSSID() -> String? | 读取 ssid 键 |
savePassword(_ password: String) | 写入 password 键 |
getPassword() -> String? | 读取 password 键 |
配置选项
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
ssid(UserDefaults) | String | 无 | 测试 WiFi 名称,由 SettingInfo 管理 |
password(UserDefaults) | String | 无 | 测试 WiFi 密码 |
CodeExample.plist | 资源文件 | 随包分发 | SourceHelper 只读解码的静态配置 |
userIdentify(内存) | String | "" | 数据库查询分区键,从 userPfInfo.identify 获取;为空时先请求用户配置 |
故障模式、边界情况与并发
身份标识未就绪
init 中 userIdentify 为空(用户配置尚未拉取)时,直接查询数据库会得到错误分区甚至空结果。DeviceSubViewModel 的处理是:先调用 requestGetUserConfigInfo 阻塞式获取 identify,成功后才在 DispatchQueue.main.async 中执行数据库查询与服务器同步。若该请求失败,设备列表将保持为空,reconnectLast 等依赖列表的功能也会退化(见 DeviceSubViewModel.swift)。
网络失败与本地回退
queryServerDevices 中 DeviceHttp.checkList 返回 nil 时(网络问题),仅提示 Toast 并放弃同步,不清理本地数据库。这是刻意的设计:本地 SQLite 作为离线兜底,网络失败时用户仍能看到上次同步的设备列表,且 reconnectLast 仍可基于本地首条设备发起连接。
服务器绑定冲突
当服务器返回「设备已被其他人绑定」时,代码路径会先 update(model) 更新本地状态再提示警告(见 DeviceSubViewModel.swift),说明绑定失败也会以「更新本地记录」的方式落库,避免本地与服务器状态长期不一致。
线程与并发
- 数据库回调线程不定:
checkout的回调可能在后台线程执行,queryDbDevices显式判断Thread.current.isMainThread后统一切回主线程再触发updateListCallBack(L42-L48); - UI 主线程约束:表盘切换、下载进度等 UI 更新均包裹在
DispatchQueue.main.async中; - 通知驱动更新:
UI_JL_DEVICE_CHANGE、JL_BATTERY、UI_DELETE_DEVICE_MODEL、kJL_BLE_M_ON等多个通知可能并发触发设备列表更新,DeviceSubViewModel通过deviceList单一数据源 + 回调去重来收敛竞态(内存列表以mac去重)。
边界情况:表盘缓存与设备状态一致性
setCurrentDial 先比较 model.name.uppercased() 与 currentDial,相同则直接返回(幂等保护);只有设备端切换成功(flag == 0)才更新缓存 setCurrrentWatchName,避免缓存先行导致 UI 与设备实际表盘不一致。若下载失败(data == nil),提示「下载失败」且不触碰缓存。
性能与运维注意
- 本地优先降低启动延迟:冷启动直接读 SQLite 渲染设备列表,避免首屏等待网络;服务器同步异步进行,不阻塞 UI;
- 全量回写策略:
queryServerDevices对服务器列表逐条update(_:time:),属于「读改写」模式——设备量级不大(个位数到几十台)时开销可忽略,若未来设备量增大可考虑批量事务; - UserDefaults.synchronize():
SettingInfo中的显式同步属于历史 API,现代系统会自动落盘;测试工具场景下无性能影响; - plist 只读缓存:
SourceHelper.codeDict每次访问都重新解码文件,若频繁调用可考虑缓存解码结果(当前为测试工具,频率低,无优化必要)。
扩展点
- 新增设备字段持久化:扩展
UserDeviceModel(SDK 侧)并沿用update(model)即可,仓库侧无需改动调用点; - 多账号切换:
checkout(by:)已按identify分区,切换账号只需更换userIdentify后重新queryDbDevices(); - 表盘缓存联动:
DialHistoryViewModel通过 KVO 观察dialCache().currentWatch,新增表盘状态维度时可在dialCache上增加可观察属性并沿用同一观察模式; - 测试参数扩展:
SettingInfo中每新增一个键值对只需复制save/get模式,无表结构迁移成本。
测试情况
code/JL_Health/JieliJianKang/UnitTester/DataTester.swift存在数据层测试器(本页证据收集预算内未读取其内容,建议查阅以了解数据库/数据模型的单元测试用例);SDKTestHelper工程中的SettingInfo、SourceHelper为测试工具代码,其UserDefaults与 plist 读取逻辑可被测试工程直接复用;- 设备列表的持久化行为主要通过
DeviceSubViewModel与 SDK 的集成验证(真机绑定/解绑后重启 App 检查列表)。
Related Links
- DeviceSubViewModel.swift(设备列表持久化编排)
- DialHistoryViewModel.swift(表盘缓存使用)
- SettingInfo.swift(UserDefaults 测试工具)
- SourceHelper.swift(plist 资源读取)
- DataTester.swift(数据层测试器)
- 相关页面:表盘市场(Dial Market)、网络层与服务器同步、蓝牙连接管理(SDK 侧)