9.4 KiB
播放模式切换功能 — iOS SDK 同步方案
目标:把 expo-duooomi-app(commit ac1520f) 的播放模式切换功能完整同步到 duooomi-ios-sdk,保持协议层一致,让 SDK 调用方用 Swift 风格 API 完成"单播 ⇄ 循环播放"切换。
1. 参考实现(Expo 端)
提交 ac1520f 改动 5 个文件,核心机制:
| 层 | 文件 | 改动 |
|---|---|---|
| 协议类型 | ble/protocol/types.ts |
DeviceInfoZod 增 loop: 0|1|undefined;BindingRequestZod 增可选 loop |
| 服务层 | ble/services/DeviceInfoService.ts |
新增 setPlayMode(deviceId, userId, loop) —— 复用 BIND_DEVICE(0x0F) 命令字,带上 loop 字段 |
| 管理层 | ble/managers/bleManager.ts |
新增 setPlayMode(loop): Promise<BindingResponse> —— 复用 bindDevice 的 waitable 机制 |
| UI | app/device.tsx |
设备信息卡片中显示当前模式 + 切换按钮 + loading;切换后调 getDeviceInfo 刷新 |
关键协议事实:
- 复用
0x0F(BIND_DEVICE) 作命令字,请求 payload 中loop字段为可选 - 响应仍是
BindingResponse(含type/sn/success/contents) - 设备返回的
DeviceInfo中新增loop(0=单播 / 1=循环),旧固件可能没这字段 - 设备能区分"绑定" vs "切播放模式"是因为 payload 里有无
loop,SDK 必须严格按"无 loop 时省略字段(不能传 null)"编码 - 必须先
bind成功才能setPlayMode:固件设计上设置播放模式的前置条件是设备已和该userId完成绑定(success=1)。SDK 通过isActivated == true反映此状态;调用setPlayMode前必须确保已绑定,否则设备会回success=0
2. iOS SDK 当前状态
| Swift 对应位置 |
|---|
Sources/DuooomiBleSDK/Models/DeviceInfo.swift |
Sources/DuooomiBleSDK/Services/DeviceInfoService.swift(私有 BindPayload) |
Sources/DuooomiBleSDK/DuooomiBleSDK.swift(公开 bind/unbind) |
demo/Sources/WrapperTestView.swift(Device Commands 区) |
DuooomiBleSDK.sendAndWait 已支持基于 commandType.rawValue 的请求-响应配对,新增能力可直接复用。
3. 设计
3.1 类型与协议
新增枚举 Sources/DuooomiBleSDK/Models/PlayMode.swift:
public enum PlayMode: Int, Codable {
case single = 0 // 单播
case loop = 1 // 循环播放
}
理由:暴露给业务层的 API 用强类型枚举,比 raw Int / UInt8 更安全;rawValue 直接对接协议层的 0/1 整数。
修改 Models/DeviceInfo.swift:
- 增字段
public let loop: PlayMode?(可选,兼容旧固件) init(from:)容错:缺字段或值非 0/1 时返回nil,不抛错
3.2 协议层
修改 Services/DeviceInfoService.swift:
将私有 BindPayload 替换为可选 loop 字段,并自定义 encode(to:):
private struct BindPayload: Encodable {
let type: UInt8
let userId: String
let loop: UInt8? // nil 时编码省略
enum CodingKeys: String, CodingKey { case type, userId, loop }
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(type, forKey: .type)
try c.encode(userId, forKey: .userId)
if let loop = loop { try c.encode(loop, forKey: .loop) }
}
}
关键约束:默认 JSONEncoder 会把 nil 编成 "loop": null,与 Expo 端的 zod.optional() "省略" 行为不一致 —— 设备固件可能据 key 是否存在区分动作,必须用自定义 encode 严格省略 nil。bindDevice / unbindDevice 现有调用因 loop=nil 走相同路径,报文与改动前完全一致。
新增方法:
func setPlayMode(userId: String, loop: PlayMode) throws {
try protocolService.sendJSON(
type: .bindDevice,
payload: BindPayload(
type: CommandType.bindDevice.rawValue,
userId: userId,
loop: UInt8(loop.rawValue)
)
)
}
unbindDevice 用独立 UnbindPayload(已有 BindPayload 复用,可保留,或拆出对应 UnbindPayload 二选一 —— 当前选择继续复用 BindPayload(loop:nil),最小改动)。
3.3 SDK 公开 API
修改 DuooomiBleSDK.swift,在 bind/unbind 之间新增:
/// 切换播放模式(0=单播 / 1=循环播放)
/// 复用 BIND_DEVICE 命令通道;响应类型为 BindingResponse。
/// 成功后自动刷新一次 deviceInfo(与 expo 端行为对齐)。
public func setPlayMode(
_ mode: PlayMode,
userId: String,
completion: @escaping (Result<BindingResponse, Error>) -> Void
) {
guard ensureConnected(completion: completion) else { return }
guard isActivated else {
BleLog.w("setPlayMode called before bind", "Command")
completion(.failure(DuooomiBleError.bindingFailed("Device not bound; call bind(userId:) first")))
return
}
BleLog.d("Sending setPlayMode (mode=\(mode), userId=\(userId))", "Command")
sendAndWait(commandType: .bindDevice, completion: { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
completion(.failure(error))
case .success(let data):
do {
let resp = try self.decodeResponse(BindingResponse.self, from: data)
if resp.success != 1 {
BleLog.w("setPlayMode failed", "Command")
completion(.failure(DuooomiBleError.bindingFailed("Set play mode failed")))
return
}
BleLog.i("setPlayMode success: mode=\(mode)", "Command")
// 自动刷新 deviceInfo(含最新 loop 字段)
self.getDeviceInfo { _ in
completion(.success(resp))
}
} catch {
completion(.failure(error))
}
}
}, send: {
try self.deviceInfoService.setPlayMode(userId: userId, loop: mode)
})
}
重要约束 — 命令字冲突:bind / unbind / setPlayMode 三者不可并发执行(前两者已有此约束,因都用 sendAndWait 的 opId = "15" / "18";新方法用 "15",与 bind 互斥)。这是与 expo 端一致的协议级限制;后续如需并发,须在协议帧 / opId 维度引入区分维度,超出本次同步范围。在 API 文档注释中明确说明。
3.4 Demo 应用
修改 demo/Sources/WrapperTestView.swift,在 bind / unbind 按钮行下加一行:
HStack {
Button("Single (0)") { runSetPlayMode(.single) }
Button("Loop (1)") { runSetPlayMode(.loop) }
if let mode = viewModel.deviceInfo?.loop {
Text("now: \(mode == .loop ? "循环" : "单播")")
.font(.caption)
.foregroundColor(.secondary)
}
}
.buttonStyle(.bordered)
.disabled(viewModel.connectedDevice == nil || isBusy)
新增方法:
private func runSetPlayMode(_ mode: PlayMode) {
isBusy = true
log("→ setPlayMode(\(mode), userId=\(userId))")
viewModel.sdk.setPlayMode(mode, userId: userId) { [self] result in
DispatchQueue.main.async {
self.isBusy = false
switch result {
case .success(let resp):
self.log("setPlayMode ✓ sn=\(resp.sn)", level: .success)
case .failure(let error):
self.log("setPlayMode ✗ \(error.localizedDescription)", level: .error)
}
}
}
}
viewModel 需暴露 deviceInfo(如未暴露则增 @Published var deviceInfo: DeviceInfo?,在 didUpdateDeviceInfo 回调里更新 —— 已存在则跳过)。
4. 改动文件清单
| # | 文件 | 改动类型 |
|---|---|---|
| 1 | Sources/DuooomiBleSDK/Models/PlayMode.swift |
新建 |
| 2 | Sources/DuooomiBleSDK/Models/DeviceInfo.swift |
修改:增 loop: PlayMode? 字段及解码容错 |
| 3 | Sources/DuooomiBleSDK/Services/DeviceInfoService.swift |
修改:BindPayload 增 loop + 自定义 encode;新增 setPlayMode 方法 |
| 4 | Sources/DuooomiBleSDK/DuooomiBleSDK.swift |
修改:新增公开 setPlayMode(_:userId:completion:) |
| 5 | demo/Sources/WrapperTestView.swift |
修改:UI 按钮(仅 isActivated 时启用)+ runSetPlayMode |
| 6 | README.md |
修改:在 API 章节新增 setPlayMode 段落,显著标注前置条件 — 必须先 bind 成功 |
5. 验证
- 编译:
xcodebuild -workspace demo.xcworkspace -scheme demo -sdk iphonesimulator build - 协议字节比对:在真机上抓包/或加日志看发送的 JSON:
bind(userId)→{"type":15,"userId":"..."}(无 loop 字段)setPlayMode(.loop, userId)→{"type":15,"userId":"...","loop":1}unbind(userId)→{"type":18,"userId":"..."}(不受影响)
- 真机功能:连接设备 → bind → setPlayMode(.loop) → 设备模式确实切到循环;getDeviceInfo 返回的
loop字段为.loop - 回归:现有
bind/unbind行为不变(payload 不变)
6. 不在本次范围
- iOS 端不需要
userStore—— Expo 端从全局 store 取 userId,iOS SDK 设计上由调用方(demo/上层 App)传入userId,与bind/unbind一致 - 自动重连后的播放模式自动恢复(expo 端也无此能力)
- 多模式(>2)扩展 —— 当前协议只定义 0/1
来源:
- expo-duooomi-app commit
ac1520f(2026-04-20) - duooomi-ios-sdk @
f42944b(2026-05-06 当前 HEAD) - 对话日期:2026-05-06