feat: 初始化 iOS 远程控制端项目

This commit is contained in:
2026-07-29 10:34:02 +08:00
parent 11173eb04c
commit 1e37aa64cc
20 changed files with 2610 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
import Foundation
import AVFoundation
import CoreMedia
/// “自编码”串流模式解码器:
/// 从 video_channel DataChannel 接收被控端 MediaCodec 编码的 H.264 裸流,
/// 重组分片 -> 解析单元 -> 转换为 AVCC 格式 -> 通过 AVSampleBufferDisplayLayer 硬解渲染。
///
/// 二进制协议(与被控端 SelfCodecEncoder 保持一致,均为大端序):
/// 分片: MAGIC(0xAB,1B) + seq(int32) + total(int16) + idx(int16) + len(int32) + payload
/// 单元: type(1B: 1=CONFIG 2=FRAME) + pts(uint32,ms) + isKey(1B) + len(int32) + H.264 Annex-B 数据
final class SelfCodecDecoder {
// ---- 必须与被控端 SelfCodecEncoder 保持一致 ----
private static let magic: UInt8 = 0xAB
private static let unitTypeConfig: UInt8 = 1
private static let unitTypeFrame: UInt8 = 2
/// 渲染图层(由 SelfCodecDisplayView 提供)
weak var displayLayer: AVSampleBufferDisplayLayer?
/// 分辨率变化回调(主线程)
var onResolutionUpdate: ((Int, Int) -> Void)?
private(set) var videoWidth = 0
private(set) var videoHeight = 0
private let queue = DispatchQueue(label: "com.ttstd.selfcodec.decoder")
private var enabled = false
// 分片重组
private final class ChunkBuffer {
var total = 0
var received = 0
var chunks: [Data?] = []
}
private var assembling: [Int32: ChunkBuffer] = [:]
private var lastSeq: Int32 = -1
// H.264 参数集与格式
private var sps: Data?
private var pps: Data?
private var formatDescription: CMVideoFormatDescription?
// MARK: - 开关
func setEnabled(_ on: Bool) {
queue.async {
self.enabled = on
if !on {
self.resetLocked()
}
}
}
func release() {
queue.async {
self.enabled = false
self.resetLocked()
}
}
private func resetLocked() {
assembling.removeAll()
lastSeq = -1
sps = nil
pps = nil
formatDescription = nil
videoWidth = 0
videoHeight = 0
displayLayer?.flushAndRemoveImage()
}
// MARK: - 入口:DataChannel 二进制消息
func onBinaryMessage(_ raw: Data) {
queue.async {
self.handleChunk(raw)
}
}
private func handleChunk(_ raw: Data) {
guard enabled, raw.count >= 13 else { return }
var r = BigEndianReader(data: raw)
guard r.readByte() == Self.magic else { return }
guard let seq = r.readInt32() else { return }
// 过期检查:同一单元的所有分片 seq 相同,只丢弃明显更旧的单元
if lastSeq != -1 && seq < lastSeq && (lastSeq - seq) < 1000 {
return
}
guard let total = r.readInt16(), let idx = r.readInt16(),
let len = r.readInt32(), let chunk = r.readData(Int(len)),
total > 0 else { return }
let cb: ChunkBuffer
if let existing = assembling[seq] {
cb = existing
} else {
cb = ChunkBuffer()
cb.total = Int(total)
cb.chunks = Array(repeating: nil, count: Int(total))
assembling[seq] = cb
if assembling.count > 24, let minKey = assembling.keys.min() {
assembling.removeValue(forKey: minKey)
}
}
let i = Int(idx)
if i >= 0 && i < cb.total && cb.chunks[i] == nil {
cb.chunks[i] = chunk
cb.received += 1
}
if cb.received == cb.total {
assembling.removeValue(forKey: seq)
if seq > lastSeq { lastSeq = seq }
var unit = Data()
for c in cb.chunks where c != nil { unit.append(c!) }
handleUnit(unit)
}
}
// MARK: - 单元处理
private func handleUnit(_ unit: Data) {
var r = BigEndianReader(data: unit)
guard let type = r.readByte(),
let ptsRaw = r.readInt32(),
let isKey = r.readByte(),
let len = r.readInt32(),
let data = r.readData(Int(len)) else { return }
let ptsMs = Int64(UInt32(bitPattern: ptsRaw))
if type == Self.unitTypeConfig {
handleConfig(data)
} else if type == Self.unitTypeFrame {
handleFrame(data, ptsMs: ptsMs, isKey: isKey != 0)
}
}
/// CONFIG 单元:MediaCodec 的 csd-0(SPS)/csd-1(PPS),Annex-B 格式
private func handleConfig(_ data: Data) {
for nal in Self.annexBNalUnits(in: data) {
guard let first = nal.first else { continue }
switch first & 0x1F {
case 7: if sps != nal { sps = nal; formatDescription = nil }
case 8: if pps != nal { pps = nal; formatDescription = nil }
default: break
}
}
rebuildFormatDescriptionIfNeeded()
}
private func rebuildFormatDescriptionIfNeeded() {
guard formatDescription == nil, let sps, let pps else { return }
var desc: CMVideoFormatDescription?
let status = sps.withUnsafeBytes { spsPtr -> OSStatus in
pps.withUnsafeBytes { ppsPtr -> OSStatus in
let paramSets: [UnsafePointer<UInt8>] = [
spsPtr.bindMemory(to: UInt8.self).baseAddress!,
ppsPtr.bindMemory(to: UInt8.self).baseAddress!
]
let sizes: [Int] = [sps.count, pps.count]
return CMVideoFormatDescriptionCreateFromH264ParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: 2,
parameterSetPointers: paramSets,
parameterSetSizes: sizes,
nalUnitHeaderLength: 4,
formatDescriptionOut: &desc)
}
}
guard status == noErr, let desc else {
NSLog("[SelfCodec] create format description failed: \(status)")
return
}
formatDescription = desc
displayLayer?.flush()
let dims = CMVideoFormatDescriptionGetDimensions(desc)
let w = Int(dims.width), h = Int(dims.height)
if w != videoWidth || h != videoHeight {
videoWidth = w
videoHeight = h
DispatchQueue.main.async {
self.onResolutionUpdate?(w, h)
}
}
}
/// FRAME 单元:Annex-B -> AVCC(4 字节大端长度前缀),封装 CMSampleBuffer 后送显
private func handleFrame(_ data: Data, ptsMs: Int64, isKey: Bool) {
// 帧内可能携带 SPS/PPS(部分编码器关键帧前重发参数集)
var vclData = Data()
for nal in Self.annexBNalUnits(in: data) {
guard let first = nal.first else { continue }
let nalType = first & 0x1F
if nalType == 7 {
if sps != nal { sps = nal; formatDescription = nil }
continue
}
if nalType == 8 {
if pps != nal { pps = nal; formatDescription = nil }
continue
}
var lenBE = UInt32(nal.count).bigEndian
withUnsafeBytes(of: &lenBE) { vclData.append(contentsOf: $0) }
vclData.append(nal)
}
rebuildFormatDescriptionIfNeeded()
guard enabled, !vclData.isEmpty, let formatDescription, let layer = displayLayer else { return }
// 构造 CMBlockBuffer
var blockBuffer: CMBlockBuffer?
var status = CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault,
memoryBlock: nil,
blockLength: vclData.count,
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil,
offsetToData: 0,
dataLength: vclData.count,
flags: 0,
blockBufferOut: &blockBuffer)
guard status == kCMBlockBufferNoErr, let blockBuffer else { return }
status = vclData.withUnsafeBytes { ptr in
CMBlockBufferReplaceDataBytes(
with: ptr.baseAddress!,
blockBuffer: blockBuffer,
offsetIntoDestination: 0,
dataLength: vclData.count)
}
guard status == kCMBlockBufferNoErr else { return }
// 构造 CMSampleBuffer(立即显示,不依赖时间轴)
var sampleBuffer: CMSampleBuffer?
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: CMTime(value: ptsMs, timescale: 1000),
decodeTimeStamp: .invalid)
var sampleSize = vclData.count
status = CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault,
dataBuffer: blockBuffer,
formatDescription: formatDescription,
sampleCount: 1,
sampleTimingEntryCount: 1,
sampleTimingArray: &timing,
sampleSizeEntryCount: 1,
sampleSizeArray: &sampleSize,
sampleBufferOut: &sampleBuffer)
guard status == noErr, let sampleBuffer else { return }
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true) as? [CFMutableDictionary],
let dict = attachments.first {
CFDictionarySetValue(
dict,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
if !isKey {
CFDictionarySetValue(
dict,
Unmanaged.passUnretained(kCMSampleAttachmentKey_NotSync).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque())
}
}
if layer.status == .failed || layer.requiresFlushToResumeDecoding {
layer.flush()
}
layer.enqueue(sampleBuffer)
}
// MARK: - Annex-B 拆分
/// 拆分 Annex-B 码流(00 00 01 / 00 00 00 01 起始码)为 NAL 单元数组(不含起始码)
static func annexBNalUnits(in data: Data) -> [Data] {
var result: [Data] = []
let bytes = [UInt8](data)
let count = bytes.count
var starts: [Int] = []
var i = 0
while i + 2 < count {
if bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 1 {
starts.append(i + 3)
i += 3
} else {
i += 1
}
}
guard !starts.isEmpty else { return data.isEmpty ? [] : [data] }
for (idx, start) in starts.enumerated() {
var end = count
if idx + 1 < starts.count {
end = starts[idx + 1] - 3
// 兼容 4 字节起始码(00 00 00 01)
if end > start && bytes[end - 1] == 0 { end -= 1 }
}
if end > start {
result.append(Data(bytes[start..<end]))
}
}
return result
}
}
// MARK: - 大端序读取器
private struct BigEndianReader {
let data: Data
private var offset: Int
init(data: Data) {
self.data = data
self.offset = data.startIndex
}
mutating func readByte() -> UInt8? {
guard offset < data.endIndex else { return nil }
defer { offset += 1 }
return data[offset]
}
mutating func readInt16() -> Int16? {
guard offset + 2 <= data.endIndex else { return nil }
let v = (UInt16(data[offset]) << 8) | UInt16(data[offset + 1])
offset += 2
return Int16(bitPattern: v)
}
mutating func readInt32() -> Int32? {
guard offset + 4 <= data.endIndex else { return nil }
var v: UInt32 = 0
for i in 0..<4 { v = (v << 8) | UInt32(data[offset + i]) }
offset += 4
return Int32(bitPattern: v)
}
mutating func readData(_ length: Int) -> Data? {
guard length >= 0, offset + length <= data.endIndex else { return nil }
defer { offset += length }
return data.subdata(in: offset..<(offset + length))
}
}

View File

@@ -0,0 +1,321 @@
import Foundation
import WebRTC
protocol WebRTCClientDelegate: AnyObject {
/// ICE 连接建立(可以开始远程控制)
func webRTCClientDidConnect()
/// 连接断开(远端断开或网络故障)
func webRTCClientDidDisconnect()
/// 连接失败
func webRTCClient(didFail error: String)
/// 被控端上报当前串流模式(0=WebRTC 1=自编码)
func webRTCClient(didReportStreamMode mode: Int)
/// 被控端上报当前采集分辨率
func webRTCClient(didReportResolution width: Int, height: Int)
}
/// WebRTC 客户端:负责 PeerConnection 的创建、Offer/Answer 协商、
/// ICE 候选交换、控制/视频 DataChannel 管理与统计信息获取。
/// 与 Android 端 WebRtcClient 行为保持一致。
final class WebRTCClient: NSObject {
static let streamModeWebRTC: Int32 = 0
static let streamModeSelfCodec: Int32 = 1
private static let controlChannelLabel = "control_channel"
private static let videoChannelLabel = "video_channel"
private static let factory: RTCPeerConnectionFactory = {
RTCInitializeSSL()
return RTCPeerConnectionFactory(
encoderFactory: RTCDefaultVideoEncoderFactory(),
decoderFactory: RTCDefaultVideoDecoderFactory())
}()
weak var delegate: WebRTCClientDelegate?
/// 自编码模式的 H.264 裸流解码器(video_channel 二进制数据直接转发给它)
var selfCodecDecoder: SelfCodecDecoder?
private let signaling: SignalingClient
private let myDeviceId: String
private var targetDeviceId: String = ""
private var peerConnection: RTCPeerConnection?
private var controlChannel: RTCDataChannel?
private var videoChannel: RTCDataChannel?
private weak var remoteRenderer: RTCVideoRenderer?
private var remoteVideoTrack: RTCVideoTrack?
private var connected = false
init(signaling: SignalingClient, myDeviceId: String) {
self.signaling = signaling
self.myDeviceId = myDeviceId
super.init()
}
var isDataChannelOpen: Bool {
controlChannel?.readyState == .open
}
// MARK: - 连接流程
/// 创建 PeerConnection 与 DataChannel,并向目标设备发送 Offer(携带鉴权信息)。
func createOffer(targetDeviceId: String,
renderer: RTCVideoRenderer,
authType: String?,
authValue: String?) {
self.targetDeviceId = targetDeviceId
self.remoteRenderer = renderer
let config = RTCConfiguration()
config.iceServers = [
RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]),
RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]),
RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"],
username: "ttstd",
credential: "ttstd123")
]
config.sdpSemantics = .unifiedPlan
config.continualGatheringPolicy = .gatherContinually
config.iceCandidatePoolSize = 10
config.iceTransportPolicy = .all
let pcConstraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
guard let pc = Self.factory.peerConnection(with: config, constraints: pcConstraints, delegate: self) else {
notifyFail("PeerConnection 创建失败")
return
}
peerConnection = pc
// 只接收视频,不发送本地媒体
let transceiverInit = RTCRtpTransceiverInit()
transceiverInit.direction = .recvOnly
pc.addTransceiver(of: .video, init: transceiverInit)
// 控制通道:可靠、有序
let controlConfig = RTCDataChannelConfiguration()
controlConfig.isOrdered = true
controlChannel = pc.dataChannel(forLabel: Self.controlChannelLabel, configuration: controlConfig)
controlChannel?.delegate = self
// 自编码视频通道:不可靠、低延迟(丢帧优先)
let videoConfig = RTCDataChannelConfiguration()
videoConfig.isOrdered = false
videoConfig.maxRetransmits = 0
videoChannel = pc.dataChannel(forLabel: Self.videoChannelLabel, configuration: videoConfig)
videoChannel?.delegate = self
let offerConstraints = RTCMediaConstraints(
mandatoryConstraints: [
"OfferToReceiveVideo": "true",
"OfferToReceiveAudio": "false"
],
optionalConstraints: nil)
pc.offer(for: offerConstraints) { [weak self] sdp, error in
guard let self else { return }
if let error {
self.notifyFail("创建 Offer 失败: \(error.localizedDescription)")
return
}
guard let sdp else {
self.notifyFail("创建 Offer 失败: SDP 为空")
return
}
pc.setLocalDescription(sdp) { [weak self] error in
guard let self else { return }
if let error {
self.notifyFail("设置本地 SDP 失败: \(error.localizedDescription)")
return
}
self.sendOffer(sdp: sdp.sdp, authType: authType, authValue: authValue)
}
}
}
private func sendOffer(sdp: String, authType: String?, authValue: String?) {
var msg = SignalMessage()
msg.type = "OFFER"
msg.fromDeviceId = myDeviceId
msg.toDeviceId = targetDeviceId
msg.payload = SignalMessage.encodePayload(["sdp": sdp])
msg.authType = authType
msg.authValue = authValue
signaling.send(msg)
}
/// 处理被控端返回的 Answer
func handleAnswer(sdp: String) {
let desc = RTCSessionDescription(type: .answer, sdp: sdp)
peerConnection?.setRemoteDescription(desc) { [weak self] error in
if let error {
self?.notifyFail("设置远端 SDP 失败: \(error.localizedDescription)")
}
}
}
/// 添加远端 ICE 候选
func addIceCandidate(sdpMid: String, sdpMLineIndex: Int32, candidate: String) {
let ice = RTCIceCandidate(sdp: candidate, sdpMLineIndex: sdpMLineIndex, sdpMid: sdpMid)
peerConnection?.add(ice) { error in
if let error {
NSLog("[WebRTC] addIceCandidate error: \(error.localizedDescription)")
}
}
}
// MARK: - 控制指令
func sendControlCommand(_ message: ControlMessage) {
guard let channel = controlChannel, channel.readyState == .open else { return }
let buffer = RTCDataBuffer(data: message.serializedData(), isBinary: true)
channel.sendData(buffer)
}
/// 请求被控端切换采集分辨率(width=0 表示恢复原生分辨率)
func requestResolutionChange(width: Int32, height: Int32, fps: Int32) {
var msg = ControlMessage()
msg.action = .setResolution
msg.width = width
msg.height = height
msg.fps = fps
sendControlCommand(msg)
}
/// 请求被控端切换串流模式(0=WebRTC 1=自编码)
func sendStreamMode(_ mode: Int32) {
var msg = ControlMessage()
msg.action = .setStreamMode
msg.streamMode = mode
sendControlCommand(msg)
}
// MARK: - 统计
func stats(_ completion: @escaping (RTCStatisticsReport) -> Void) {
peerConnection?.statistics(completionHandler: completion)
}
// MARK: - 关闭
func close() {
connected = false
controlChannel?.close()
videoChannel?.close()
controlChannel = nil
videoChannel = nil
if let track = remoteVideoTrack, let renderer = remoteRenderer {
track.remove(renderer)
}
remoteVideoTrack = nil
peerConnection?.close()
peerConnection = nil
}
private func notifyFail(_ text: String) {
DispatchQueue.main.async {
self.delegate?.webRTCClient(didFail: text)
}
}
}
// MARK: - RTCPeerConnectionDelegate
extension WebRTCClient: RTCPeerConnectionDelegate {
func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
DispatchQueue.main.async {
switch newState {
case .connected, .completed:
if !self.connected {
self.connected = true
self.delegate?.webRTCClientDidConnect()
}
case .disconnected, .failed, .closed:
if self.connected {
self.connected = false
self.delegate?.webRTCClientDidDisconnect()
} else if newState == .failed {
self.delegate?.webRTCClient(didFail: "ICE 连接失败")
}
default:
break
}
}
}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
var msg = SignalMessage()
msg.type = "ICE_CANDIDATE"
msg.fromDeviceId = myDeviceId
msg.toDeviceId = targetDeviceId
msg.payload = SignalMessage.encodePayload([
"sdpMid": candidate.sdpMid ?? "",
"sdpMLineIndex": Int(candidate.sdpMLineIndex),
"candidate": candidate.sdp
])
signaling.send(msg)
}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {
// 作为 Offer 方,通道由本端创建;此回调用于兜底远端创建的通道
dataChannel.delegate = self
if dataChannel.label == Self.videoChannelLabel {
videoChannel = dataChannel
} else if dataChannel.label == Self.controlChannelLabel {
controlChannel = dataChannel
}
}
func peerConnection(_ peerConnection: RTCPeerConnection,
didAdd rtpReceiver: RTCRtpReceiver,
streams mediaStreams: [RTCMediaStream]) {
guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
DispatchQueue.main.async {
self.remoteVideoTrack = track
if let renderer = self.remoteRenderer {
track.add(renderer)
}
}
}
}
// MARK: - RTCDataChannelDelegate
extension WebRTCClient: RTCDataChannelDelegate {
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
NSLog("[WebRTC] DataChannel \(dataChannel.label) state: \(dataChannel.readyState.rawValue)")
}
func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
if dataChannel.label == Self.videoChannelLabel {
// 自编码 H.264 裸流分片
selfCodecDecoder?.onBinaryMessage(buffer.data)
return
}
// 控制通道:被控端上报(串流模式 / 分辨率)
guard buffer.isBinary, let msg = ControlMessage.parse(from: buffer.data) else { return }
DispatchQueue.main.async {
switch msg.action {
case .reportStreamMode:
self.delegate?.webRTCClient(didReportStreamMode: Int(msg.streamMode))
case .reportResolution:
self.delegate?.webRTCClient(didReportResolution: Int(msg.width), height: Int(msg.height))
default:
break
}
}
}
}