# 技术架构(duooomi-android-sdk) ## 总体分层 ``` sdk/src/main/kotlin/com/duooomi/ble/ ├── DuooomiBleSDK.kt # 公开 API 入口(state + 业务编排) ├── DuooomiBleConfig.kt # 不可变配置 ├── core/ # BleClient / BleTypes / 错误枚举 ├── protocol/ # 帧格式 / UUID / ProtocolManager ├── services/ # 协议层 RPC + 持久化 + 共享 HTTP │ ├── BleProtocolService.kt # 协议层(帧组包/拆包,与平台无关) │ ├── DeviceInfoService.kt # 设备命令(getDeviceInfo / bind / setPlayMode 等) │ ├── FileTransferService.kt # 文件传输 │ ├── AniConverter.kt # ANI 转换(HTTP) │ ├── ShipmentSnDeviceService.kt # 软绑/软解 HTTP │ ├── FirmwareUpgradeService.kt # checkUpgrade / report-success / report-failure │ ├── UpgradeRecordStore.kt # SharedPreferences 持久化升级记录 │ └── HttpHelper.kt # 共享 POST 工具(统一 header / unwrap / 错误剥离) ├── models/ │ ├── DeviceInfo.kt # 含可选 PlayMode? loop │ ├── PlayMode.kt # SINGLE=0 / LOOP=1 │ ├── BindingResponse.kt │ ├── UnbindResponse.kt │ ├── DeleteFileResponse.kt │ ├── PrepareTransferResponse.kt │ ├── VersionInfo.kt │ ├── FirmwareUpgradeCheckResult.kt # 含 TargetFirmware (id/version/fileUrl/forceUpgrade...) │ └── UpgradeRecord.kt # 含 UpgradeOutcome(success / version_mismatch) └── utils/ ├── BleLogger.kt └── BeijingTimeProvider.kt # 三级 fallback 时间获取 ``` ## OTA 编排(核心架构 · 2026-05-18 重构) `bind` 内聚(硬绑+getVersion+软绑+updateMetadata)+ `fetchLatestFirmware` 瘦身(对账+checkUpgrade)+ `unbind` 改胖(硬解+软解),与 iOS HEAD 1:1 对齐。 ``` ┌──────────────────────────────────────────────────────────────┐ │ 集成方调用流 │ └──────────────────────────────────────────────────────────────┘ connect(deviceId) └─ BLE 连接 + 启动协议监听 bind(userId) ← 完整绑定(4 步内部编排,对集成方原子) ├─ Step 1: verifyBrand ├─ Step 2: sendAndWait(BIND_DEVICE) BLE 硬绑 0x0F │ └─ 写 sdk.sn / sdk.isActivated ├─ Step 3: runCatching { getVersion() } // 失败不阻断 │ └─ 写 sdk.version / sdk.versionLog ├─ Step 4: shipmentService.bind(sn, userId) 软绑 │ └─ 失败 → 自动硬解回滚 + 清 sn/isActivated/versionLog → 整体失败抛 SoftBindFailed └─ Step 5: scope.launch { updateMetadata(sn, versionLog) } (fire-and-forget) └─ 仅当 versionLog 非 null 且非空对象;失败仅 warn fetchLatestFirmware(sn, currentVersion, userId) ← 两步编排 ├─ Step 1: tryReportPendingUpgrade(...) (fire-and-forget) │ └─ 把本地 pending UpgradeRecord 报给服务端 └─ Step 2: firmwareUpgradeService.checkUpgrade(sn, currentVersion) └─ 写 sdk.firmwareUpgrade upgradeFirmware(sn, fileUrl, firmwareId, fromVersion, targetVersion) ├─ transferFile(OTA_PACKAGE) └─ upgradeRecordStore.upsert(record) // 落记录等下次对账 unbind(userId) ← 硬解 + 软解(容错) ├─ 缓存 cachedSn = sdk.sn ├─ sendAndWait(UNBIND_DEVICE) ├─ 写 sdk.isActivated = false / sdk.sn = "" / sdk.versionLog = null └─ if cachedSn 非空 → shipmentService.unbind(...)(失败仅 warn) ``` ## 状态清理时机表 **严格对齐 iOS HEAD**。新增清理点前先确认 iOS 是否同步清。 | 状态字段 | 写入时机 | 清理时机 | |---|---|---| | `sn` | bind 硬绑成功(BindingResponse.sn) | unbind 成功;**bind 软绑失败回滚** | | `firmwareUpgrade` | fetchLatestFirmware 成功 | (**从不主动清**) | | `isActivated` | bind 硬绑成功(true) / unbind 成功(false) / **bind 软绑失败回滚(false)** | disconnect handler / 主动 disconnect | | `version` | getVersion / bind | disconnect handler / 主动 disconnect | | `versionLog` | getVersion 成功(log 非 null) | unbind 成功 / **bind 软绑失败回滚** / disconnect handler / 主动 disconnect | | `deviceInfo` | getDeviceInfo / setPlayMode 后自动刷新 | disconnect handler / 主动 disconnect | | `connectedDevice` | connect 成功 | disconnect handler / 主动 disconnect | | `transferProgress` | transferFile 进度回调 | (持续写,无显式清) | | `upgradeRecords` | upsert / markReported(store.onChange 推) | (持久化,不清) | ⚠️ **关键决策**:`sn` 和 `firmwareUpgrade` 在 disconnect 时**不清空**。理由: - `sn` 是设备身份,断线重连同设备时还有效,避免每次重连后丢失 OTA 上下文 - `firmwareUpgrade` 是上次拉取结果,UI 可继续展示直到下次 fetchLatestFirmware 覆盖 之前 Android 这两个字段在 disconnect 时被清,与 iOS 不一致;commit `0dbfab9` 修正。 ⚠️ **2026-05-18 重构**:把软绑从 `fetchLatestFirmware` 搬到 `bind` 内部。理由:软绑是"绑定"语义的一部分,不该让"检查更新"承担。同步新增 `versionLog` 状态 + `updateMetadata` 上报。详见 `product-decisions.md`。 ## sn 级 in-flight 互斥锁(UpgradeRecordStore) `tryReportPendingUpgrade` 是 fire-and-forget。如果 demo 短时间内连续两次调 `fetchLatestFirmware`(用户狂点),会出现两个协程并行处理同一 sn 的 pending records 重复 markReported。 **方案**:sn 级互斥(不是全局锁)。 ```kotlin fun tryAcquire(sn: String): Boolean = synchronized(lock) { if (inFlight.contains(sn)) return false // 后到的协程直接放弃 inFlight.add(sn); true } fun release(sn: String) = synchronized(lock) { inFlight.remove(sn) } ``` 调用方约定: ```kotlin if (!upgradeRecordStore.tryAcquire(sn)) return scope.launch { try { /* 上报逻辑 */ } finally { upgradeRecordStore.release(sn) } } ``` > 不同 sn 的并发上报互不干扰;这点比单全局锁更细粒度,符合多设备场景。 ## BeijingTimeProvider 三级 fallback bind / unbind / setPlayMode payload 必带 `time` 字段(北京时间 ISO 8601)。 ``` fetch(): if 5s 缓存命中 → return Mutex.withLock: 1. GET https://quan.suning.com/getSysTime.do (timeout=3s) → JSON body { sysTime2 / sysTime1 } → 解析 ISO 8601 2. response Date header (RFC 1123) → 转北京时间 3. fallback: System.currentTimeMillis() + 8h offset 写缓存 + 返回 ``` `Mutex.withLock` 起单飞作用 —— 多协程同时 fetch 只有第一个真请求,其余等锁释放后命中缓存。 ## HTTP 约定 所有 RPC 走 `HttpHelper.post()`: - 必带 `x-api-key` + `x-owner` header(来自 `DuooomiBleConfig`) - 响应 `{ success: true, data: {...} }` 包装 → unwrap 后取 `data` 字段(unwrap=true,默认) - 错误:剥离 `API error:` 前缀 + 尝试从 JSON 抽 `message` 字段 - 抛 `HttpHelper.HttpError(code, message)` —— Service 层负责转成 `DuooomiBleError.SoftBindFailed` / `TransferFailed` ## Demo 架构 ``` MainActivity ├─ 持有 mutableStateOf(支持 reload) ├─ SharedPreferences 持久化 owner / apiKey / scanPrefix └─ 提供 onReload 回调 → 旧 SDK fire-and-forget disconnect + 新建实例 DemoScreen(sdk, config, onReload) └─ 通过 sdk.xxx.collectAsState() 订阅 SDK StateFlow reload 时 sdk 引用变化 → collectAsState 重新订阅新实例 ``` ⚠️ **DemoSecrets 文件**: - `demo/src/main/kotlin/com/duooomi/ble/demo/DemoSecrets.kt` —— 真实秘钥,**gitignored** - `docs/DemoSecrets.template.kt` —— 模板,**编译路径外**(不放在 demo/src 否则 redeclare 冲突) 来源:commit `b439851` / `2dde274` / `0dbfab9` / 复刻对话 2026-05-08。