mcpField
M
moq-kit

moq-kit

github.com/software-mansion-labs开发296311 次安装2 周前更新
代码仓库

关于 moq-kit

moq-kit 提供用于 Media over QUIC 直播的原生 Swift 和 Kotlin SDK,面向开发直播应用的 iOS 与 Android 开发者。它基于 moq-lite,支持播放、发布和数据轨道。

主要功能

  1. 01原生 Swift 与 Kotlin SDK
  2. 02订阅广播并播放轨道
  3. 03发布摄像头、麦克风和屏幕媒体
  4. 04交换原始数据轨道
  5. 05观察播放与发布状态

适用场景

  1. 01构建观看直播的 iOS 应用
  2. 02从 Android 发布摄像头或麦克风流
  3. 03通过数据轨道传输实时状态或聊天内容

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/software-mansion-labs/skills --skill moq-kit

安装后在 Claude Code 中输入/moq-kit确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
data-tracks-android.md
data-tracks-ios.md
playback-android.md
playback-ios.md
publishing-android.md
publishing-ios.md
screen-capture-android.md
screen-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 会按顺序传递每个组——适用于聊天和所有需要无损传递的内容。

关键规则

  1. 会话、发布者和播放器均为一次性对象。 Session 只能连接一次(重复使用会触发 alreadyConnected/IllegalStateException);调用 close() 后请创建新实例。已停止的 Publisher 无法重启。在任一平台上,都不要重启已销毁的 Player,而应创建新实例。
  2. 媒体轨道名称是本地标签,目录名称由复用器生成。 传给 addVideoTrack/addAudioTrack 的 name:(默认值为 "video"/"audio")仅用于标记 PublishedTrack 和发布者事件;订阅者必须从 Catalog.videoTracks/audioTracks 中发现真实轨道名称。例外:数据轨道——addDataTrack(name:)(默认值为 "data")就是订阅者传给 subscribeTrack(name:) 的名称。
  3. publisher.start() 不会启动采集。 你必须自行启动 CameraCapture/MicrophoneCapture/等,并按以下顺序操作:启动采集 → 添加轨道 → session.publish(path, publisher) → publisher.start()。
  4. 使用前先检查编解码器支持情况。 发布端使用 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 没有麦克风采样率设置项,内部会进行重采样。
  5. 播放器启动事件不会重放。 请在调用 play() 前订阅:iOS 使用 player.subscribeEvents { ... }(并保留返回的订阅;它会在释放时取消);Android 使用 CoroutineStart.UNDISPATCHED 收集 player.events。在两个平台上,player.init 都会在构造期间触发,无法被观察到——此规则旨在确保能收到 track.ready/playback.start。
  6. 权限和音频会话由应用负责。 iOS:配置 NSCameraUsageDescription、NSMicrophoneUsageDescription、NSLocalNetworkUsageDescription(本地中继),并自行配置 AVAudioSession(发布时使用 .playAndRecord,观看时使用 .playback)——MicrophoneCapture 不会配置。Android:声明并请求 CAMERA/RECORD_AUDIO(没有权限时,MicrophoneCapture.start() 会静默地不执行任何操作),声明 INTERNET,并为屏幕采集配置 MediaProjection 前台服务。
  7. 妥善持有引用并清理资源。 iOS:对 Session、BroadcastSubscription、Publisher、DataTrackEmitter、Player 以及每个事件/统计信息订阅都保留强引用;还要持有用于驱动 for await 循环的 Task 句柄(AsyncStream 也只能由单个消费者使用)。Android:几乎所有对象都实现了 AutoCloseable,并持有引用计数的原生句柄——请对每个 Broadcast、BroadcastSubscription、TrackSubscription、MediaTrack、AudioDataStream 和 Player 调用 close()(泄漏任一对象都会使广播保持活动状态)。broadcasts、objects、frames 和 audio 流仅允许单个收集器。
  8. iOS Player 属于 @MainActor——请在主执行器上构造并驱动它。iOS 上的 Publisher.stop() 会同步刷新编码器——请在非主线程调用(使用 Task.detached;注意 Publisher 不符合 Sendable,因此在严格的 Swift 6 并发检查下,需要使用 nonisolated(unsafe) 或 Swift 5 模式的目标)。
  9. 当一个会话同时发布和订阅相同前缀时,它会收到自己的广播——请将其过滤掉,并注意报告的路径是相对于前缀的:去掉订阅前缀后,将报告的路径与发布路径比较。也支持在一个进程中使用两个 Session(一个发布、一个订阅)——并且由于会话只能使用一次,分开使用可以避免发布端的传输故障或拆除影响播放。
  10. 可实时调整延迟:targetBuffering 默认值为 100 ms;播放期间可通过 player.updateTargetLatency(_:) 调整。若另一消费者使用同一媒体轨道(另一个 Player、AudioDataStream),它会复用首个订阅者的上游缓冲设置,因此无法单独降低缓冲。
  11. 自定义源必须与发布者共享时钟域。 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.mdreferences/playback-android.md
发布:摄像头、多摄像头、麦克风、预览、编码器配置、权限references/publishing-ios.mdreferences/publishing-android.md
屏幕共享references/screen-capture-ios.mdreferences/screen-capture-android.md
数据轨道、原始压缩媒体(MediaTrack)、解码后的 PCM(AudioDataStream)references/data-tracks-ios.mdreferences/data-tracks-android.md

评论

支持 Markdown