feat(sdk): 帧间隔 frameIntervalMs 配置化(默认 35ms,[0,500] clamp)

DuooomiBleConfig 新增 frameIntervalMs,BleProtocolService 构造期注入。
顺手清死代码 FrameConstants.frameIntervalNanos、修 sendFrames 0.035 字面量
(统一走 config)。demo 加可调输入框 + UserDefaults 持久化。
越界 warn 不抛错,使用点 min/max 兜底。

设计稿:docs/superpowers/specs/2026-05-20-frame-interval-config-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
km2023
2026-05-20 14:02:04 +08:00
parent b967471532
commit 09088121b5
6 changed files with 35 additions and 11 deletions

View File

@@ -18,6 +18,9 @@ public struct DuooomiBleConfig {
public let aniFps: String public let aniFps: String
/// 广****广 /// 广****广
public let scanNamePrefix: String public let scanNamePrefix: String
/// `[0, 500]` warn使 clamp
/// / 35
public let frameIntervalMs: Int
public init( public init(
apiHost: URL = URL(string: "https://api.duooomi.com")!, apiHost: URL = URL(string: "https://api.duooomi.com")!,
@@ -27,7 +30,8 @@ public struct DuooomiBleConfig {
aniWidth: Int = 360, aniWidth: Int = 360,
aniHeight: Int = 360, aniHeight: Int = 360,
aniFps: String = "24", aniFps: String = "24",
scanNamePrefix: String scanNamePrefix: String,
frameIntervalMs: Int = 35
) { ) {
self.apiHost = apiHost self.apiHost = apiHost
self.apiKey = apiKey self.apiKey = apiKey
@@ -37,5 +41,9 @@ public struct DuooomiBleConfig {
self.aniHeight = aniHeight self.aniHeight = aniHeight
self.aniFps = aniFps self.aniFps = aniFps
self.scanNamePrefix = scanNamePrefix self.scanNamePrefix = scanNamePrefix
if frameIntervalMs < 0 || frameIntervalMs > 500 {
BleLog.w("frameIntervalMs=\(frameIntervalMs) out of [0,500], will clamp at use site", "Config")
}
self.frameIntervalMs = frameIntervalMs
} }
} }

View File

@@ -110,7 +110,7 @@ public final class DuooomiBleSDK: NSObject {
public init(config: DuooomiBleConfig) { public init(config: DuooomiBleConfig) {
self.config = config self.config = config
bleClient = BleClient() bleClient = BleClient()
protocolService = BleProtocolService(client: bleClient) protocolService = BleProtocolService(client: bleClient, frameIntervalMs: config.frameIntervalMs)
timeProvider = BeijingTimeProvider() timeProvider = BeijingTimeProvider()
deviceInfoService = DeviceInfoService(protocolService: protocolService, timeProvider: timeProvider) deviceInfoService = DeviceInfoService(protocolService: protocolService, timeProvider: timeProvider)
fileTransferService = FileTransferService(protocolService: protocolService) fileTransferService = FileTransferService(protocolService: protocolService)

View File

@@ -16,8 +16,6 @@ public enum FrameConstants {
public static let maxDataSize = 496 public static let maxDataSize = 496
public static let headerSize = 8 public static let headerSize = 8
public static let footerSize = 1 public static let footerSize = 1
/// Inter-frame delay in nanoseconds (35ms)
public static let frameIntervalNanos: UInt64 = 35_000_000
public static let screenSize = 360 public static let screenSize = 360
} }

View File

@@ -10,6 +10,7 @@ final class BleProtocolService: BleClientDelegate {
weak var delegate: BleProtocolServiceDelegate? weak var delegate: BleProtocolServiceDelegate?
private let client: BleClient private let client: BleClient
private let frameIntervalMs: Int
private var isListening = false private var isListening = false
/// ///
@@ -20,9 +21,10 @@ final class BleProtocolService: BleClientDelegate {
private var fragments: [String: FragmentSession] = [:] private var fragments: [String: FragmentSession] = [:]
init(client: BleClient) { init(client: BleClient, frameIntervalMs: Int) {
self.client = client self.client = client
BleLog.i("Protocol service created", "Protocol") self.frameIntervalMs = frameIntervalMs
BleLog.i("Protocol service created (frameIntervalMs=\(frameIntervalMs))", "Protocol")
} }
// MARK: - Start/Stop Listening // MARK: - Start/Stop Listening
@@ -90,8 +92,9 @@ final class BleProtocolService: BleClientDelegate {
onProgress?(Double(index + 1) / Double(total)) onProgress?(Double(index + 1) / Double(total))
if index + 1 < total { if index + 1 < total {
// Inter-frame delay 35ms // Inter-frame delay from config (clamped to [0,500]ms)
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.035) { let delaySec = Double(min(max(self.frameIntervalMs, 0), 500)) / 1000.0
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delaySec) {
sendFrame(at: index + 1) sendFrame(at: index + 1)
} }
} else { } else {

View File

@@ -29,6 +29,9 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
@Published var configScanPrefix: String { @Published var configScanPrefix: String {
didSet { defaults.set(configScanPrefix, forKey: Self.keyScanPrefix) } didSet { defaults.set(configScanPrefix, forKey: Self.keyScanPrefix) }
} }
@Published var configFrameIntervalMs: String {
didSet { defaults.set(configFrameIntervalMs, forKey: Self.keyFrameIntervalMs) }
}
// MARK: - SDK State (driven by delegate) // MARK: - SDK State (driven by delegate)
@@ -50,26 +53,31 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
private static let keyBrand = "demo.config.brand" private static let keyBrand = "demo.config.brand"
private static let keyApiKey = "demo.config.apiKey" private static let keyApiKey = "demo.config.apiKey"
private static let keyScanPrefix = "demo.config.scanPrefix" private static let keyScanPrefix = "demo.config.scanPrefix"
private static let keyFrameIntervalMs = "demo.config.frameIntervalMs"
/// demo UserDefaults 使 DemoSecrets /// demo UserDefaults 使 DemoSecrets
private static let defaultBrand = DemoSecrets.brand private static let defaultBrand = DemoSecrets.brand
private static let defaultApiKey = DemoSecrets.apiKey private static let defaultApiKey = DemoSecrets.apiKey
private static let defaultScanPrefix = DemoSecrets.scanNamePrefix private static let defaultScanPrefix = DemoSecrets.scanNamePrefix
private static let defaultFrameIntervalMs = "35"
override init() { override init() {
let d = UserDefaults.standard let d = UserDefaults.standard
let brand = d.string(forKey: Self.keyBrand) ?? Self.defaultBrand let brand = d.string(forKey: Self.keyBrand) ?? Self.defaultBrand
let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey let apiKey = d.string(forKey: Self.keyApiKey) ?? Self.defaultApiKey
let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix let scanPrefix = d.string(forKey: Self.keyScanPrefix) ?? Self.defaultScanPrefix
let frameIntervalMs = d.string(forKey: Self.keyFrameIntervalMs) ?? Self.defaultFrameIntervalMs
self.configBrand = brand self.configBrand = brand
self.configApiKey = apiKey self.configApiKey = apiKey
self.configScanPrefix = scanPrefix self.configScanPrefix = scanPrefix
self.configFrameIntervalMs = frameIntervalMs
self.sdk = DuooomiBleSDK(config: .init( self.sdk = DuooomiBleSDK(config: .init(
apiKey: apiKey, apiKey: apiKey,
brand: brand, brand: brand,
scanNamePrefix: scanPrefix scanNamePrefix: scanPrefix,
frameIntervalMs: Int(frameIntervalMs) ?? 35
)) ))
super.init() super.init()
@@ -83,7 +91,8 @@ final class DemoViewModel: NSObject, ObservableObject, DuooomiBleSDKDelegate {
let new = DuooomiBleSDK(config: .init( let new = DuooomiBleSDK(config: .init(
apiKey: configApiKey, apiKey: configApiKey,
brand: configBrand, brand: configBrand,
scanNamePrefix: configScanPrefix scanNamePrefix: configScanPrefix,
frameIntervalMs: Int(configFrameIntervalMs) ?? 35
)) ))
new.delegate = self new.delegate = self
self.sdk = new self.sdk = new

View File

@@ -88,9 +88,15 @@ struct WrapperTestView: View {
.autocapitalization(.none) .autocapitalization(.none)
.disableAutocorrection(true) .disableAutocorrection(true)
} }
HStack {
Text("frameMs").frame(width: 90, alignment: .leading)
TextField("35", text: $viewModel.configFrameIntervalMs)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
}
Button("Reload SDK") { Button("Reload SDK") {
viewModel.reloadSDK() viewModel.reloadSDK()
log("SDK reloaded with brand=\(viewModel.configBrand), prefix=\(viewModel.configScanPrefix)", level: .success) log("SDK reloaded with brand=\(viewModel.configBrand), prefix=\(viewModel.configScanPrefix), frameMs=\(viewModel.configFrameIntervalMs)", level: .success)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }