Files
duooomi-android-sdk/.wiki/tech-architecture.md

7.4 KiB
Raw Blame History

技术架构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          # 含 UpgradeOutcomesuccess / version_mismatch
└── utils/
    ├── BleLogger.kt
    └── BeijingTimeProvider.kt    # 三级 fallback 时间获取

OTA 三段式编排(核心架构)

bind 改瘦 + unbind 改胖 + fetchLatestFirmware 串三步,是与 expo / iOS HEAD 1:1 对齐的核心。不要回退到"bind 一键搞定全套"的旧设计

┌──────────────────────────────────────────────────────────────┐
│ 集成方调用流                                                  │
└──────────────────────────────────────────────────────────────┘

connect(deviceId)
  └─ BLE 连接 + 启动协议监听

bind(userId)                   ← 只做 BLE 硬绑 + getVersion
  ├─ sendAndWait(BIND_DEVICE)  payload = {type, userId, time}
  ├─ 写入 sdk.sn / sdk.isActivated
  └─ runCatching { getVersion() }  // 失败不阻断

fetchLatestFirmware(sn, currentVersion, userId)   ← 三步编排
  ├─ Step 1: tryReportPendingUpgrade(...)         (fire-and-forget)
  │           └─ 把本地 pending UpgradeRecord 报给服务端
  ├─ Step 2: shipmentService.bind(sn, userId)     软绑
  │           └─ 失败 → 自动硬解回滚 + 清 sn / isActivated
  └─ Step 3: 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 = ""
  └─ if cachedSn 非空 → shipmentService.unbind(...)(失败仅 warn

状态清理时机表

严格对齐 iOS HEAD。新增清理点前先确认 iOS 是否同步清。

状态字段 写入时机 清理时机
sn bind 成功BindingResponse.sn unbind 成功fetchLatestFirmware 软绑失败回滚
firmwareUpgrade fetchLatestFirmware 成功 从不主动清
isActivated bind 成功(true) / unbind 成功(false) / 软绑失败回滚(false) disconnect handler / 主动 disconnect
version getVersion / bind disconnect handler / 主动 disconnect
deviceInfo getDeviceInfo / setPlayMode 后自动刷新 disconnect handler / 主动 disconnect
connectedDevice connect 成功 disconnect handler / 主动 disconnect
transferProgress transferFile 进度回调 (持续写,无显式清)
upgradeRecords upsert / markReportedstore.onChange 推) (持久化,不清)

⚠️ 关键决策snfirmwareUpgrade 在 disconnect 时不清空。理由:

  • sn 是设备身份,断线重连同设备时还有效,避免每次重连后丢失 OTA 上下文
  • firmwareUpgrade 是上次拉取结果UI 可继续展示直到下次 fetchLatestFirmware 覆盖

之前 Android 这两个字段在 disconnect 时被清,与 iOS 不一致commit 0dbfab9 修正。

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-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<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 —— 真实秘钥,gitignored
  • docs/DemoSecrets.template.kt —— 模板,编译路径外(不放在 demo/src 否则 redeclare 冲突)

来源commit b439851 / 2dde274 / 0dbfab9 / 复刻对话 2026-05-08。