1:1 复刻 iOS commit 4bc8fdb。 - bind(userId) 内聚为硬绑 + getVersion + 软绑 + updateMetadata 一次原子调用, 软绑失败自动硬解绑回滚 + 清状态;集成方无需感知双层绑定 - fetchLatestFirmware 简化为对账上报 + checkUpgrade 两步,不再重复软绑 - VersionInfo 增加 log: JSONObject?,fromJson 用 optJSONObject 解析; SDK 暴露只读 versionLog: StateFlow<JSONObject?>,断连/解绑/软绑回滚时清空 - ShipmentSnDeviceService 新增 updateMetadata(sn, data: JSONObject), HttpHelper.post 已支持 JSONObject 作为 body 子节点透传 - 老固件不返回 log → versionLog == null → 自动跳过 updateMetadata,前向兼容 - 同步更新 .wiki/tech-architecture.md(状态清理表 + OTA 编排图) + product-decisions.md(新增决策条目)+ README.md(versionLog 状态) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.2 KiB
技术架构(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 级互斥(不是全局锁)。
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) }
调用方约定:
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-ownerheader(来自DuooomiBleConfig) - 响应
{ success: true, data: {...} }包装 → unwrap 后取data字段(unwrap=true,默认) - 错误:剥离
API error:前缀 + 尝试从 JSON 抽message字段 - 抛
HttpHelper.HttpError(code, message)—— Service 层负责转成DuooomiBleError.SoftBindFailed/TransferFailed
Demo 架构
MainActivity
├─ 持有 mutableStateOf<DuooomiBleSDK>(支持 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—— 真实秘钥,gitignoreddocs/DemoSecrets.template.kt—— 模板,编译路径外(不放在 demo/src 否则 redeclare 冲突)
来源:commit b439851 / 2dde274 / 0dbfab9 / 复刻对话 2026-05-08。