diff --git a/docs/superpowers/specs/2026-05-20-frame-interval-config-design.md b/docs/superpowers/specs/2026-05-20-frame-interval-config-design.md new file mode 100644 index 0000000..b4c1954 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-frame-interval-config-design.md @@ -0,0 +1,174 @@ +# 设计稿:可配置蓝牙帧间隔 + +- **日期**:2026-05-20 +- **范围**:duooomi-ios-sdk · duooomi-android-sdk(权威上游 iOS,Android 1:1 跟随) +- **状态**:待实现 + +## 背景 + +`BleProtocolService.sendFrames` 在分包后逐帧 `writeWithoutResponse`,帧间停顿固定 35ms,防止 GATT TX 队列拥塞导致丢帧。 + +**当前实现两端不对齐:** + +| 端 | 位置 | 现状 | +|---|---|---| +| iOS | `Protocol/ProtocolConstants.swift:20` | `static let frameIntervalNanos: UInt64 = 35_000_000` —— 定义存在但**全仓无任何引用**(死代码) | +| iOS | `Services/BleProtocolService.swift:94` | 实际使用字面量 `0.035`(magic number) | +| Android | `protocol/ProtocolConstants.kt:23` | `const val FRAME_INTERVAL_MS = 35L` | +| Android | `services/BleProtocolService.kt:74` | `delay(FrameConstants.FRAME_INTERVAL_MS)` —— 正确引用 | + +集成方现在没有任何途径调节该值。 + +## 目标 + +1. 暴露帧间隔为 `DuooomiBleConfig` 的字段,默认 35,集成方可在 SDK 初始化时覆盖 +2. 修复 iOS 死代码 + 字面量两个不一致点,改为读 config +3. demo 提供 UI 控件,与现有 `apiKey` / `scanNamePrefix` 同套持久化机制 +4. 两端字段名、默认值、越界文案逐字一致 + +**非目标:** +- Android `BleClient.kt:201` 的 `delay(30)` —— GATT 忙的写重试退避,语义与帧间隔无关,本次不动 +- 运行时可变 setter(YAGNI) +- ANI / 连接 / 超时等其他参数的配置化 + +## 设计 + +### 1. API 形态 + +**iOS** `DuooomiBleConfig.swift`: +```swift +public init( + apiHost: URL = URL(string: "https://api.duooomi.com")!, + apiKey: String, + brand: String, + cdnHost: String = "https://cdn.bowong.cc/", + aniWidth: Int = 360, + aniHeight: Int = 360, + aniFps: String = "24", + scanNamePrefix: String, + frameIntervalMs: Int = 35 // ← 新增 +) + +public let frameIntervalMs: Int // ← 新增,存储原始值 +``` + +**Android** `DuooomiBleConfig.kt`: +```kotlin +data class DuooomiBleConfig( + val apiKey: String, + val brand: String, + val scanNamePrefix: String, + val apiHost: String = "https://api.duooomi.com", + val cdnHost: String = "https://cdn.bowong.cc/", + val aniWidth: Int = 360, + val aniHeight: Int = 360, + val aniFps: String = "24", + val frameIntervalMs: Int = 35 // ← 新增 +) +``` + +### 2. 校验策略 + +- 合法区间:**[0, 500] ms** +- 越界:`BleLog.w("frameIntervalMs= out of [0,500], will clamp at use site", "Config")`,**不抛错** +- 存储:原始值(便于诊断),使用点 `coerceIn(0, 500)` / `min(max(...))` +- 校验时机:Config 构造完成时(iOS 在 init 末尾、Android 在 `init {}` 块) + +警告字符串两端逐字一致(集成方过滤日志时跨端一致)。 + +### 3. 消费路径 + +`BleProtocolService` 改为构造期接收 `DuooomiBleConfig`(或仅 `frameIntervalMs` 一个 Int)。 + +| 端 | sendFrames 帧间 sleep 改动 | +|---|---| +| iOS | `0.035` → `Double(min(max(config.frameIntervalMs, 0), 500)) / 1000.0`(秒) | +| Android | `delay(FrameConstants.FRAME_INTERVAL_MS)` → `delay(config.frameIntervalMs.coerceIn(0, 500).toLong())` | + +### 4. 死代码清理 + +- iOS:删除 `Protocol/ProtocolConstants.swift` 中的 `frameIntervalNanos` 常量 +- Android:删除 `protocol/ProtocolConstants.kt` 中的 `FRAME_INTERVAL_MS` 常量 +- 删除前 grep 二次确认无其他引用(本次设计前 grep 已确认) + +### 5. Demo 改动 + +| 端 | 改动 | +|---|---| +| iOS `demo/Sources/DemoApp.swift` | 加 `configFrameIntervalMs` 状态字段 + 数字 TextField;与现有 apiKey/scanPrefix 一致地传给 `DuooomiBleConfig(...)`;沿用本仓 demo 既有的持久化方式,不新增机制 | +| Android `demo/.../DemoScreen.kt` + `MainActivity.kt` | 加 `ConfigField("frameIntervalMs", configFrameIntervalMs) { configFrameIntervalMs = it }`;`DemoConfig` data class 加字段;`SharedPreferences` 增加 `KEY_FRAME_INTERVAL_MS` | + +Demo 输入按文本 → 解析为 Int → 解析失败回退 35;空字符串当 35。 + +### 6. 跨端契约对齐(本次必须 1:1) + +| 项 | 值 | +|---|---| +| 字段名 | `frameIntervalMs`(两端) | +| 默认值 | `35` | +| 合法区间 | `[0, 500]` 闭区间 | +| 越界 warn 字符串 | `"frameIntervalMs= out of [0,500], will clamp at use site"` | +| 越界处置 | warn + 使用点 clamp,不抛错 | +| 配置时机 | 构造期一次性,无运行时 setter | + +### 7. 错误处理 + +无新增 `DuooomiBleError` case。出范围只是 warn,不影响 catch 分支。 + +### 8. 测试 + +**单元(可加):** +- Config 接受 0 / 35 / 500 不告警 +- Config 接受 -1 / 501 告警(若无日志捕获脚手架则跳过,**本次不补脚手架**) + +**手动冒烟:** +- demo 设置为 100,触发一次 `transferMedia`,观察传输耗时约为基线的 ~3 倍 +- demo 设置为 0,触发传输,验证无 crash(可能伴随设备端丢帧 —— 预期行为,集成方自负) + +**自动化缺口(诚实标注):** +- 两端均无 BLE timing 自动化测试基础设施,**本次不补** + +### 9. 兼容性 / 破坏性 + +- 默认值 35 与现状一致 → **非破坏性** +- 新增参数为 init 末位且有默认值,旧调用方代码不需改动 +- iOS 删除 `frameIntervalNanos` 常量:`public` 但 grep 确认零外部引用,理论上仍是破坏性。**风险等级:理论破坏 / 实际无影响**。如有顾虑可在版本说明里标注 + +## 实现顺序建议 + +1. iOS Config + BleProtocolService + 删除死代码 + demo +2. iOS 编译 + 手动冒烟 +3. Android Config + BleProtocolService + 删除常量 + demo +4. Android debug + release 编译 +5. 两端各一个 commit,提 main(沿用本仓既有 `feat(sdk):` 直接提 main 的工作流) +6. 推送 + +## 提交信息草稿 + +**iOS commit:** +``` +feat(sdk): 帧间隔 frameIntervalMs 配置化(默认 35ms,[0,500] clamp) + +DuooomiBleConfig 新增 frameIntervalMs,BleProtocolService 改读 config。 +顺手清死代码 frameIntervalNanos 常量、修 0.035 字面量。 +demo 加可调输入框,UserDefaults 持久化。 +``` + +**Android commit:** +``` +feat(sdk): 同步 iOS — 帧间隔 frameIntervalMs 配置化(默认 35ms) + +1:1 复刻 iOS:Config 加 frameIntervalMs,BleProtocolService 改读 config, +清掉 FRAME_INTERVAL_MS 常量。demo 加 ConfigField,SharedPreferences 持久化。 +警告文案与 iOS 逐字一致。 +``` + +## 决策记录 + +| 决策 | 选项 | 选了 | 原因 | +|---|---|---|---| +| 范围 | 仅帧间隔 / 帧间隔+写重试 | 仅帧间隔 | 30ms retry 是 Android 独有的 GATT busy 退避,语义不同;用户明确确认 | +| 越界处置 | 不校验 / clamp+warn / clamp+throw | clamp+warn | 用户明确选择;不堵塞集成方但留下诊断信号 | +| Config vs 运行时 setter | Config / setter / 双 | 仅 Config | YAGNI;用户没要求运行时可变 | +| 范围与覆盖 | 与现有常量共存 / 取代 | 取代 + 删常量 | 单一来源,顺手清死代码 | +| Spec 落地仓 | iOS / Android / 各一份 | iOS | iOS 是上游权威(项目 CLAUDE.md) |