关于 moq-kit
moq-kit 提供用于 Media over QUIC 直播的原生 Swift 和 Kotlin SDK,面向开发直播应用的 iOS 与 Android 开发者。它基于 moq-lite,支持播放、发布和数据轨道。
主要功能
- 01原生 Swift 与 Kotlin SDK
- 02订阅广播并播放轨道
- 03发布摄像头、麦克风和屏幕媒体
- 04交换原始数据轨道
- 05观察播放与发布状态
适用场景
- 01构建观看直播的 iOS 应用
- 02从 Android 发布摄像头或麦克风流
- 03通过数据轨道传输实时状态或聊天内容
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ software-mansion-labs/ skills --skill moq-kit安装后在 Claude Code 中输入/moq-kit确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/data-tracks-android.mddata-tracks-ios.mdplayback-android.mdplayback-ios.mdpublishing-android.mdpublishing-ios.mdscreen-capture-android.mdscreen-capture-ios.md详细介绍
moq-kit
用于 Media over QUIC 实时流媒体的原生 Swift 和 Kotlin SDK:连接到中继,发现广播,以亚秒级延迟播放由目录描述的流,发布摄像头/麦克风/屏幕轨道,并交换原始数据轨道。封装了由 moq-ffi(Luke Curley 的 moq-dev/moq)生成的 UniFFI 绑定;目标协议为 moq-lite,而非 IETF 的 moq-transport 草案。
要求: iOS 16+ / macOS 13+(构建需要 Xcode 16+)/ Android minSdk 29,compileSdk 35。需要连接到一个 moq-lite 中继。参考文档涵盖 iOS 和 Android;Swift 包声明支持 macOS,但此处未测试。
moq-kit 目前处于积极预览阶段:公开 API、打包方式和编解码器支持范围在不同版本之间仍可能变化。请固定使用精确版本,并根据已安装的制品核对签名,不要想当然地假设其形式。
安装 — iOS(SPM):
.package(url: "https://github.com/software-mansion-labs/moq-kit", exact: "<latest release>")
// product: .product(name: "MoQKit", package: "moq-kit")
安装 — Android(Maven Central):
dependencies { implementation("com.swmansion.moqkit:moqkit:<latest release>") }
从 https://github.com/software-mansion-labs/moq-kit/releases 获取 <latest release> 的值。两处都特意固定为精确版本 — 只有在确实想跟随陆续发布的预览版本时,才放宽 SPM 的版本限制(.upToNextMinor(from:))。
平台检测
参考文件按平台拆分 — 针对当前项目,只阅读对应的文件:
Package.swift、*.xcodeproj或Podfile→*-ios.md参考文件(Swift)。- 使用
com.swmansion.moqkit的build.gradle/build.gradle.kts→*-android.md参考文件(Kotlin)。 - 跨平台工作(功能对齐、基于两个 SDK 构建绑定)→ 两者都要阅读;两套 API 特意保持一致。
概念模型
- 一个
Session管理到中继的一条 QUIC 连接,同时支持订阅和发布。调用一次connect();一个会话只能使用一次 — 调用close()(或发生连接错误)后,请创建新的Session。Swift:actor,connect() async throws。Kotlin:可选传入parentScope(默认为内部 IO 作用域;传入viewModelScope/lifecycleScope,以便取消它时同时拆除会话);connect()是唯一的挂起调用。 - 订阅:
session.subscribe(prefix:)→ 广播流 →broadcast.catalogs()→ 选择可播放的轨道 →Player(catalog:videoTrackName:audioTrackName:targetBuffering:)→play()。渲染因平台而异:iOS 将player.videoLayer(一个AVSampleBufferDisplayLayer)添加到视图层级;Android 调用player.setSurface(surface),传入由你管理的Surface。 - 发布: 创建采集源(
CameraCapture、MicrophoneCapture、MultiCameraCapture、ScreenCapture),并由你自己启动 →Publisher()+addVideoTrack/addAudioTrack/addDataTrack→session.publish(path, publisher)→publisher.start()。一个 publisher 只能使用一次;请在调用start()前添加所有轨道。 - 数据轨道(
DataTrackEmitter→addDataTrack;通过broadcast.subscribeTrack(name:)消费)不经过媒体目录 — 发布者和订阅者需在带外约定轨道名称。 - 所有内容均可观测:
session.state、publisher.state/events、播放器事件/统计信息。Kotlin 使用StateFlow/SharedFlow/冷Flow;Swift 使用AsyncStream传递会话/发布者状态和诊断信息,但通过回调订阅(subscribeEvents/subscribeStats)获取播放器事件/统计信息。
快速开始 — 观看
iOS:
let session = Session(url: "http://localhost:4443/anon")
try await session.connect()
let subscription = try await session.subscribe(prefix: "live")
for await broadcast in subscription.broadcasts {
for await catalog in broadcast.catalogs() {
let video = catalog.playableVideoTracks.first?.name
let audio = catalog.playableAudioTracks.first?.name
guard video != nil || audio != nil else { continue }
let player = try await MainActor.run {
try Player(catalog: catalog, videoTrackName: video, audioTrackName: audio)
}
try await player.play() // render via player.videoLayer — see playback-ios.md
}
}
Android:
scope.launch {
val session = Session(url = "http://localhost:4443/anon", parentScope = scope)
session.connect()
session.subscribe(prefix = "live").broadcasts.collect { broadcast ->
broadcast.catalogs().collect { catalog ->
val video = catalog.playableVideoTracks.firstOrNull()?.name
val audio = catalog.playableAudioTracks.firstOrNull()?.name
if (video == null && audio == null) return@collect
val player = Player(catalog, video, audio, parentScope = scope)
player.setSurface(surfaceView.holder.surface)
player.play()
}
}
}
这里刻意保持精简:嵌套循环一次处理一个广播 — 实际代码中应为每个广播启动一个 Task/launch — 并且每次目录更新都应先拆除之前的播放器,再创建新播放器(播放器持有原生句柄)。
快速开始 — 开始直播
两个平台的步骤均相同,共四步,且顺序至关重要(规则 3):
iOS:
try await camera.start() // 1. captures — publisher.start() won't do this
let publisher = try Publisher() // 2. every track before start()
publisher.addVideoTrack(name: "camera", source: camera, config: VideoEncoderConfig())
try await session.publish(path: "live/ios", publisher: publisher) // 3. register
try await publisher.start() // 4. go live
Android:
camera.start(context, lifecycleOwner)
val publisher = Publisher()
publisher.addVideoTrack(name = "camera", source = camera, config = VideoEncoderConfig())
session.publish(path = "live/android", publisher = publisher)
publisher.start()
有关采集、预览、编码器配置和权限的内容,请参阅发布参考文档。
共享概念(两个平台均适用)
- 目录:
broadcast.catalogs()会流式发送目录更新——每个元素都会替换前一个;流结束表示广播已消失(将其标记为离线)。它通常以抛出异常而不是正常完成的方式结束——消失的发布者会导致collect抛出MoqException$Mux: json: remote error: code=…,因此应以相同方式处理异常和正常完成(两个演示应用和 react-native-moq 都是如此)。始终从playableVideoTracks/playableAudioTracks中选择(Android 上以及 iOS 音频轨道会经过设备筛选;iOS 视频可播放性仅通过编解码器系列识别),不要使用原始列表;每条轨道的isPlayable/unsupportedReason会说明其被排除的原因(这些属性位于具体的VideoTrackInfo/AudioTrackInfo上,而非基类TrackInfo上)。视频编解码器字符串:"avc1"、"hev1"、"av01";音频:"mp4a.40.2"/"aac"(Android 发布的轨道会标示为"aac")、"opus"。AV1 仅支持播放——两个平台都不会发布 AV1,且 Apple 端解码需要 iPhone 15 Pro 级别的硬件,并非普遍支持的能力。 - **广播路径相对于前缀:**使用前缀
"live"订阅时,广播live/game会以path: "game"的形式报告——使用""订阅才能查看完整路径。 Player上有三个观测通道——不要混淆:事件(生命周期节点——player.init、playback.start、track.ready、track.switch、track.stall.start、rebuffer.end、decode.error——线上使用的值;Kotlin 与PlayerEventName枚举匹配;不会重放)、统计数据(约每 1 秒更新一次的PlaybackStats——延迟、fps、比特率、StallStats、timeToFirst、缓冲深度、丢帧、切换),以及诊断信息(按帧类型化的PipelineEvent——frameDropped及其DropStage/DropReason、stallStarted、latencySample等——容量上限为 256,满时丢弃最旧数据——Android 会向所有调用者提供同一个共享流——且不会对播放施加背压;仅用于日志记录/遥测)。- 发布者状态:
Idle / Publishing / Stopped / Error(message)——但 Android 实际上不会进入Error,也不会响应会话终止:Android 发布者在会话消失后仍会无限期保持Publishing,因此只有会话自身的状态才能表明广播已停止。失败 UI 应绑定到事件:TrackStarted/TrackStopped/ 单轨道错误(Kotlin 的TrackError(name, message)、Swift 的.error(name, message));单条轨道的PublishedTrack.state(Idle / Starting / Active / Stopped)以及用于结束单条轨道的PublishedTrack.stop()。session.unpublish(path)会调用publisher.stop()。 - 原始轨道订阅的
TrackDelivery:monotonic(默认)会跳过较晚到达的组——适用于以最新值为准的实时状态;arrival会按顺序传递每个组——适用于聊天和所有需要无损传递的内容。
关键规则
- 会话、发布者和播放器均为一次性对象。
Session只能连接一次(重复使用会触发alreadyConnected/IllegalStateException);调用close()后请创建新实例。已停止的Publisher无法重启。在任一平台上,都不要重启已销毁的Player,而应创建新实例。 - 媒体轨道名称是本地标签,目录名称由复用器生成。 传给
addVideoTrack/addAudioTrack的name:(默认值为"video"/"audio")仅用于标记PublishedTrack和发布者事件;订阅者必须从Catalog.videoTracks/audioTracks中发现真实轨道名称。例外:数据轨道——addDataTrack(name:)(默认值为"data")就是订阅者传给subscribeTrack(name:)的名称。 publisher.start()不会启动采集。 你必须自行启动CameraCapture/MicrophoneCapture/等,并按以下顺序操作:启动采集 → 添加轨道 →session.publish(path, publisher)→publisher.start()。- 使用前先检查编解码器支持情况。 发布端使用
VideoEncoderConfig.supportedCodecs()/AudioEncoderConfig.supportedCodecs()/.isSupported;播放端使用catalog.playableVideoTracks/playableAudioTracks/ 每个轨道的isPlayable。不支持的配置会抛出错误(SessionError.unsupportedCodec/UnsupportedCodecException)。H.264 + AAC/Opus 是测试最充分的组合;默认使用 H.264。Opus 需要 48 kHz——在 Android 上,MicrophoneCapture(sampleRate = …)必须与编码器配置中的采样率一致;iOS 没有麦克风采样率设置项,内部会进行重采样。 - 播放器启动事件不会重放。 请在调用
play()前订阅:iOS 使用player.subscribeEvents { ... }(并保留返回的订阅;它会在释放时取消);Android 使用CoroutineStart.UNDISPATCHED收集player.events。在两个平台上,player.init都会在构造期间触发,无法被观察到——此规则旨在确保能收到track.ready/playback.start。 - 权限和音频会话由应用负责。 iOS:配置
NSCameraUsageDescription、NSMicrophoneUsageDescription、NSLocalNetworkUsageDescription(本地中继),并自行配置AVAudioSession(发布时使用.playAndRecord,观看时使用.playback)——MicrophoneCapture不会配置。Android:声明并请求CAMERA/RECORD_AUDIO(没有权限时,MicrophoneCapture.start()会静默地不执行任何操作),声明INTERNET,并为屏幕采集配置 MediaProjection 前台服务。 - 妥善持有引用并清理资源。 iOS:对
Session、BroadcastSubscription、Publisher、DataTrackEmitter、Player以及每个事件/统计信息订阅都保留强引用;还要持有用于驱动for await循环的Task句柄(AsyncStream也只能由单个消费者使用)。Android:几乎所有对象都实现了AutoCloseable,并持有引用计数的原生句柄——请对每个Broadcast、BroadcastSubscription、TrackSubscription、MediaTrack、AudioDataStream和Player调用close()(泄漏任一对象都会使广播保持活动状态)。broadcasts、objects、frames和audio流仅允许单个收集器。 - iOS
Player属于@MainActor——请在主执行器上构造并驱动它。iOS 上的Publisher.stop()会同步刷新编码器——请在非主线程调用(使用Task.detached;注意Publisher不符合Sendable,因此在严格的 Swift 6 并发检查下,需要使用nonisolated(unsafe)或 Swift 5 模式的目标)。 - 当一个会话同时发布和订阅相同前缀时,它会收到自己的广播——请将其过滤掉,并注意报告的路径是相对于前缀的:去掉订阅前缀后,将报告的路径与发布路径比较。也支持在一个进程中使用两个
Session(一个发布、一个订阅)——并且由于会话只能使用一次,分开使用可以避免发布端的传输故障或拆除影响播放。 - 可实时调整延迟:
targetBuffering默认值为 100 ms;播放期间可通过player.updateTargetLatency(_:)调整。若另一消费者使用同一媒体轨道(另一个Player、AudioDataStream),它会复用首个订阅者的上游缓冲设置,因此无法单独降低缓冲。 - 自定义源必须与发布者共享时钟域。
Publisher会根据同一纪元为所有轨道添加时间戳,该纪元由任一轨道的首帧确定,因此,源若发出从零开始的独立时间轴,就会与麦克风时间轴漂移,或导致视频被安排在很远的未来。iOS:使用主机时钟上的 PTS(CMClockGetTime(CMClockGetHostTimeClock()))——这是 AVFoundation 采集已使用的时钟域。Android:PCM 使用SystemClock.elapsedRealtimeNanos() / 1_000(MicrophoneCapture使用的时钟),并使用编码器表面演示时间戳背后的同一时钟。
症状 → 原因
| 症状 | 可能原因 |
|---|---|
| 始终收不到广播 | 中继 URL 缺少命名空间路径(本地为 /anon)——中继返回 NotFound(code=13),不会显示任何内容;或者在会话进入已连接状态前就调用了 subscribe() |
| 发布“成功”,但订阅者收不到媒体 | 从未启动采集——publisher.start() 不会启动采集(规则 3) |
| 没有麦克风音频 | iOS:AVAudioSession 未设置为支持录音的类别。Android:缺少 RECORD_AUDIO,且 MicrophoneCapture.start() 会静默地不执行任何操作——无论哪种情况都不会报错 |
| Android:有音频,但视频是黑屏 | 未设置 surface,或 surfaceDestroyed 后 surface 已过期——在此处将其置空,并且仅重新应用 isValid 的 surface |
| iOS:有音频,但视频是黑屏 | 从未将 player.videoLayer 添加到图层层级中,或从未设置其尺寸——只有将它添加到图层树中并调用 play() 后才会渲染 |
从未触发 track.ready / playback.start | 在调用 play() 后才订阅,或者(iOS)未保留返回的 PlayerEventSubscription |
| 数据轨道的发送内容消失 | 在 publisher.start() 之前或 stop() 之后发送——两个平台都会静默丢弃;根据发布者状态控制发送 |
| 网络短暂中断后无法恢复 | 会话只能使用一次;在 Android 上,非主动请求的 Closed 就是故障信号。创建新的 Session——但如果看到 Error(message),请记录日志,它包含了出错原因的唯一描述 |
远程错误只有一个 code=N | 代码对应 moq_net::Error 的判别值:13 NotFound、24 Dropped(“producer dropped without finishing”)、25 Closed、26 Lagged;完整列表见 moq-net 的 error.rs(moq-kit 检出目录中的 vendor/moq/rs/moq-net/src/error.rs) |
| iOS:停止播出时 UI 卡顿 | Publisher.stop() 会同步刷新编码器——请在主线程之外调用 |
| 发布者自己的广播出现在其订阅者列表中 | 同一会话以相同前缀发布和订阅(规则 9)——按相对于前缀的路径进行过滤 |
| 自定义源:音视频逐渐不同步,或视频冻结但音频继续播放 | 帧时间戳与发布者其他轨道的时钟域不一致(规则 11) |
Android:play() 抛出 IllegalArgumentException | 实际上是 UnsupportedCodecException(其子类),表示所选轨道无法解码——请优先捕获该异常 |
| Android:屏幕采集启动后停止 | mediaProjection 前台服务尚未运行,或在同意结果回调之外启动(Android 14+ 会拒绝这种做法) |
| 设备或模拟器无法连接本地中继 | localhost 指的是设备本身——在 Android 模拟器上使用 10.0.2.2,在实体设备上使用主机的 LAN IP |
调试
中继、订阅和发布故障通常会先出现在原生传输日志中,然后才显示为状态。使用其他 API 前,请先提高日志级别:
KitLogger.setNativeLogLevel("debug") // "error"/"warn"/"info"/"debug"/"trace"; only the first call takes effect
NativeLogging.setLogLevel("debug") // same levels; invalid values are ignored with a warning
Android 原生日志会出现在 logcat 的 MoQNative 标签下,但前提是原生库使用 android-logcat cargo 功能构建(moq-ffi → moq-native → tracing-android);否则,tracing 订阅器会将日志写入 stderr,而 Android 会丢弃这些日志。若 setLogLevel 调用成功,但之后没有任何 MoQNative 日志,说明你使用的构建产物未启用该功能——请改为查看会话/发布者状态和播放器事件。
iOS 也会通过 os_log 记录日志,子系统为 com.swmansion.MoQKit(类别包括 session、transport、catalog、media、player、publish)——可在 Console.app 中按此项筛选。每帧丢弃/卡顿的详细信息位于播放器的诊断通道中,不在这些日志里。
使用中继服务进行测试
在 moq-kit 检出目录中(需要 Rust + mise):mise run relay:run 会在 http://localhost:4443/anon 启动本地 moq-lite 中继服务;mise run media:to-fmp4 会将视频转换为 CMAF fMP4,mise run stream:file --input file.mp4 会将其循环推送到中继服务,作为广播。在实体设备上,请使用机器的局域网 IP 替代 localhost;在 Android 模拟器中,请使用 10.0.2.2(这是模拟器指向主机环回地址的固定别名——模拟器自己的 localhost 指向它自身;iOS 模拟器则与主机共享网络)。中继服务 URL 必须包含命名空间路径(本地为 /anon)——如果路径错误或缺失,广播就不会出现(中继服务返回 NotFound,code=13)。演示应用(examples/ios/demo/MoQDemo、examples/android/demo/MoQDemo)是权威的集成参考。
参考资料
| 任务 | iOS(Swift) | Android(Kotlin) |
|---|---|---|
| 播放:会话、发现、Player、渲染、事件/统计信息接线 | references/playback-ios.md | references/playback-android.md |
| 发布:摄像头、多摄像头、麦克风、预览、编码器配置、权限 | references/publishing-ios.md | references/publishing-android.md |
| 屏幕共享 | references/screen-capture-ios.md | references/screen-capture-android.md |
| 数据轨道、原始压缩媒体(MediaTrack)、解码后的 PCM(AudioDataStream) | references/data-tracks-ios.md | references/data-tracks-android.md |