feat: 集成 rtsp-client-android 零缓冲 RTSP 渲染,替换 ExoPlayer

- 新增本地模块 rtspclientlibrary/(拷贝自 rtsp-client-android v5.6.5 library-client-rtsp)
- RtspVideoPlayer.kt 重写:RtspSurfaceView + AndroidView,VideoCodec 枚举映射
  DecoderType.HARDWARE/SOFTWARE;指数退避自动重连(1s→30s,URL/codec 变化重置)
- 延迟统计:每 2s 轮询 statistics 输出 videoDecoderLatencyMsec/networkLatencyMsec
- 本地修复两个已知坑:CSD 参数集顺序 sps+pps+vps -> vps+sps+pps(H.265 起播黑屏根因),
  codecType 硬编码 H264 -> 按实际 MIME 标记(H.265 宽高解析)
- FrameQueue 60 -> 20 压低积压延迟;剔除 camera 依赖(删除 bitmap 渲染路径)
- app 移除 media3-exoplayer-rtsp/ui 依赖(media3-exoplayer 由库模块提供)
This commit is contained in:
2026-08-13 09:24:28 +08:00
parent d3b2ef1102
commit 18b326d416
30 changed files with 6203 additions and 165 deletions
+1 -3
View File
@@ -42,6 +42,7 @@ android {
dependencies {
implementation(project(":joysticklibrary"))
implementation(project(":rtspclientlibrary"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -54,9 +55,6 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.rtsp)
implementation(libs.androidx.media3.ui)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
@@ -1,5 +1,7 @@
package com.example.m20_gamepad.video
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,16 +23,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.alexvas.rtsp.codec.VideoDecodeThread
import com.alexvas.rtsp.widget.RtspStatusListener
import com.alexvas.rtsp.widget.RtspSurfaceView
import com.example.m20_gamepad.data.VideoResizeMode
import kotlinx.coroutines.delay
@@ -63,14 +61,20 @@ private const val RETRY_DELAY_INITIAL_MS = 1000L
private const val RETRY_DELAY_MAX_MS = 30_000L
private const val RETRY_DELAY_MULTIPLIER = 2
/** 延迟统计日志 TAG */
private const val TAG = "RtspVideoPlayer"
/**
* RTSP 视频流播放 Composable。
*
* 使用 Media3 ExoPlayer + RTSP 扩展拉取 RTSP 流,作为全屏背景层。
* [codec] 变化时重建播放器并应用新的解码器策略。
* 连接失败时显示错误提示
* 使用 rtspclientlibraryrtsp-client-android)的 [RtspSurfaceView] 拉取 RTSP 流,
* 作为全屏背景层。该库为纯 TCP interleaved + 零缓冲架构(无播放时钟 pacing),
* 帧到达即解码渲染,替代原 Media3 ExoPlayer 播放器缓冲架构以降低延迟
*
* @param url RTSP 流地址,如 `rtsp://10.21.31.103:554/stream`
* [codec] 变化时重建连接并应用新的解码器策略。
* 连接失败时按指数退避自动重试,URL/codec 变化时重置退避。
*
* @param url RTSP 流地址,如 `rtsp://10.21.41.1:8554/video1`
* @param codec 解码器选择策略
* @param resizeMode 视频缩放模式,默认 ZOOM(等比填充全屏)
* @param modifier 布局修饰符
@@ -83,62 +87,62 @@ fun RtspVideoPlayer(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var state by remember { mutableStateOf<RtspState>(RtspState.Connecting()) }
var retryCount by remember { mutableIntStateOf(0) }
var retryDelayMs by remember { mutableStateOf(RETRY_DELAY_INITIAL_MS) }
var retrySignal by remember { mutableIntStateOf(0) }
// 生命周期主动停止标志:ON_STOP 调用 stop() 触发的断开/失败不应进入重连
var stoppedByLifecycle by remember { mutableStateOf(false) }
// 最近一次生效的 url/codec:用于区分「参数变化(重置退避立即连接)」与「重试」
var lastParams by remember { mutableStateOf<Pair<String, VideoCodec>?>(null) }
// codec 变化时重建 ExoPlayer,新的 selector 在 RenderersFactory 中注入,
// 同时使用最小缓冲策略降低延迟
val player: ExoPlayer = remember(codec) {
val selector = codec.toMediaCodecSelector()
val renderersFactory = DefaultRenderersFactory(context)
renderersFactory.setMediaCodecSelector(selector)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
200, // minBufferMs — 必须 >= bufferForPlaybackAfterRebufferMs
500, // maxBufferMs
100, // bufferForPlaybackMs — 起播所需缓冲
200 // bufferForPlaybackAfterRebufferMs — 重缓冲后所需缓冲
)
.setBackBuffer(0, false)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
ExoPlayer.Builder(context, renderersFactory)
.setLoadControl(loadControl)
.build()
.apply {
repeatMode = Player.REPEAT_MODE_OFF
playWhenReady = true
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
state = when (playbackState) {
Player.STATE_BUFFERING -> RtspState.Connecting(retryCount)
Player.STATE_READY -> RtspState.Playing
Player.STATE_IDLE -> RtspState.Connecting(retryCount)
else -> state
}
}
// codec 变化时重建 RtspSurfaceView(在 factory 中映射为 DecoderType)。
// RtspSurfaceView 持有 RtspProcessor,内部线程在 dispose 时由 stop() 停止。
val rtspView = remember(codec, context) {
RtspSurfaceView(context).apply {
videoDecoderType = codec.toDecoderType()
// 实验性 SPS 低延迟参数重写(仅 H.264num_ref_frames -> 0,部分硬件
// 解码器可降低约 2 倍延迟;H.265 流自动跳过)
experimentalUpdateSpsFrameWithLowLatencyParams = true
setStatusListener(object : RtspStatusListener {
override fun onRtspStatusConnected() {
state = RtspState.Playing
}
override fun onPlayerError(error: PlaybackException) {
state = RtspState.Error(
message = error.message ?: "连接失败",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
// 递增 retrySignal 触发 LaunchedEffect 自动重试
retrySignal++
}
})
}
override fun onRtspStatusFailed(message: String?) {
if (stoppedByLifecycle) return
state = RtspState.Error(
message = message ?: "连接失败",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
// 递增 retrySignal 触发 LaunchedEffect 自动重试
retrySignal++
}
override fun onRtspStatusDisconnected() {
if (stoppedByLifecycle) return
// 非主动停止时视为连接丢失,进入重试
state = RtspState.Error(
message = "连接断开",
retryCount = retryCount,
nextRetryMs = retryDelayMs
)
retrySignal++
}
})
}
}
// 初始连接 + 自动重试:key 包含 url/codec 变化时自动取消旧重试
LaunchedEffect(url, codec, retrySignal) {
if (url.isNotBlank()) {
if (retrySignal == 0) {
// 首次连接 / URL 或 codec 变更:重置退避
val params = url to codec
if (lastParams != params) {
// 首次连接 / URL 或 codec 变更:重置退避并立即连接
lastParams = params
retryCount = 0
retryDelayMs = RETRY_DELAY_INITIAL_MS
} else {
@@ -148,40 +152,62 @@ fun RtspVideoPlayer(
retryCount++
}
state = RtspState.Connecting(retryCount)
player.stop()
player.clearMediaItems()
val mediaItem = MediaItem.fromUri(url)
player.setMediaItem(mediaItem)
player.prepare()
rtspView.init(Uri.parse(url))
rtspView.start(requestVideo = true, requestAudio = false)
}
}
// codec 变化时释放旧播放器
DisposableEffect(codec) {
// 延迟统计:轮询库内统计(读取 statistics 会激活测量),输出到 Logcat。
// 播放中每 2s 上报一次解码/网络延迟,用于验证低延迟改造效果。
LaunchedEffect(rtspView) {
while (true) {
delay(2000)
val s = rtspView.statistics
val decLat = s.videoDecoderLatencyMsec
val netLat = s.networkLatencyMsec
if (decLat >= 0 || netLat >= 0) {
Log.i(
TAG,
"decoder=${s.videoDecoderName} (${s.videoDecoderType}), " +
"videoDecoderLatency=${decLat}ms, networkLatency=${netLat}ms"
)
}
}
}
// 界面生命周期:onStop 时停止播放(后台停止拉流/解码),onStart 时恢复
DisposableEffect(lifecycleOwner, rtspView) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
if (rtspView.isStarted()) {
stoppedByLifecycle = true
rtspView.stop()
}
}
Lifecycle.Event.ON_START -> {
stoppedByLifecycle = false
// 重新连接;retrySignal 递增触发 LaunchedEffect(重置退避)
retrySignal++
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
player.release()
lifecycleOwner.lifecycle.removeObserver(observer)
stoppedByLifecycle = true
rtspView.stop()
}
}
Box(modifier = modifier) {
// 仅在视频播放中时渲染 PlayerView,否则透出底层背景
if (state is RtspState.Playing) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
this.resizeMode = resizeMode.toMedia3ResizeMode()
this.player = player
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
}
},
update = { view ->
view.player = player
view.resizeMode = resizeMode.toMedia3ResizeMode()
}
)
}
// 始终渲染 SurfaceView:首帧未到前为透明,SurfaceView 在 Compose 之上的
// 层级需保持存在,否则解码渲染目标 surface 会被销毁。
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { rtspView }
)
// 连接中 / 错误时的提示覆盖层
when (val s = state) {
@@ -232,71 +258,34 @@ private fun StatusOverlay(text: String, subtitle: String = "") {
}
}
// ==================== 解码器选择器 ====================
/** MIME 类型常量 */
private const val MIME_H264 = "video/avc"
private const val MIME_HEVC = "video/hevc"
// ==================== 解码器映射 ====================
/**
* 将 [VideoCodec] 枚举映射为 [MediaCodecSelector]。
* 将 [VideoCodec] 枚举映射为 rtspclientlibrary 的 [VideoDecodeThread.DecoderType]。
*
* - AUTO: 默认选择器(优先硬件解码)
* - FORCE_HW: 强制硬件解码,所有 MIME 类型仅保留硬件加速解码器
* - FORCE_SW_H264: 对 H.264 仅保留软件解码器,其余 MIME 走默认
* - FORCE_SW_H265: 针对 H.265/HEVC 仅保留软件解码器,其余 MIME 走默认
* - AUTO: 硬件解码(库默认,解码失败自动回退软件
* - FORCE_HW: 强制硬件解码
* - FORCE_SW_H264: 强制软件解码(对 H.264 流有效)
* - FORCE_SW_H265: 强制软件解码(对 H.265 流有效)
*
* 注意:库的 DecoderType 仅区分 HARDWARE/SOFTWARE,不支持按 MIME 分别指定,
* 因此 FORCE_SW_* 在码流与所选 MIME 不符时仍可能走硬件(与上游行为一致)。
*/
private fun VideoCodec.toMediaCodecSelector(): MediaCodecSelector = when (this) {
VideoCodec.AUTO -> MediaCodecSelector.DEFAULT
VideoCodec.FORCE_HW -> forceHardware()
VideoCodec.FORCE_SW_H264 -> forceSoftwareForMime(MIME_H264)
VideoCodec.FORCE_SW_H265 -> forceSoftwareForMime(MIME_HEVC)
private fun VideoCodec.toDecoderType(): VideoDecodeThread.DecoderType = when (this) {
VideoCodec.AUTO,
VideoCodec.FORCE_HW -> VideoDecodeThread.DecoderType.HARDWARE
VideoCodec.FORCE_SW_H264,
VideoCodec.FORCE_SW_H265 -> VideoDecodeThread.DecoderType.SOFTWARE
}
// ==================== 缩放模式 ====================
/**
* 创建一个 [MediaCodecSelector],仅对指定 [mimeType] 强制软件解码。
* 其余 MIME 类型使用默认行为。
* 缩放模式说明:
*
* rtspclientlibrary 的 [RtspSurfaceView] 不做等比缩放/裁剪,surface 填满其布局
* 区域,视频渲染为全幅铺满(与旧 ExoPlayer 的 FILL 行为一致)。本应用视频作为
* 全屏背景层,默认 ZOOM 与 FILL 视觉近似,因此当前三种模式统一按全幅铺满处理;
* 若需精确 FIT(留黑边)或 ZOOM(按视频宽高比裁剪),需在 [RtspStatusListener]
* 的 onRtspFrameSizeChanged 中拿到视频尺寸后覆写视图测量,留待后续按需实现。
*/
private fun forceSoftwareForMime(targetMime: String): MediaCodecSelector = object : MediaCodecSelector {
override fun getDecoderInfos(
mimeType: String,
requiresSecureDecoder: Boolean,
requiresTunnelingDecoder: Boolean
): List<MediaCodecInfo> {
val all = MediaCodecSelector.DEFAULT.getDecoderInfos(
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
)
// 只对目标 MIME 类型过滤,其余保持默认
return if (mimeType == targetMime) {
all.filter { !it.hardwareAccelerated }
} else {
all
}
}
}
/**
* 创建一个 [MediaCodecSelector],所有 MIME 类型仅保留硬件加速解码器。
* 用于强制硬件解码模式(延迟优先)。
*/
private fun forceHardware(): MediaCodecSelector = object : MediaCodecSelector {
override fun getDecoderInfos(
mimeType: String,
requiresSecureDecoder: Boolean,
requiresTunnelingDecoder: Boolean
): List<MediaCodecInfo> {
return MediaCodecSelector.DEFAULT.getDecoderInfos(
mimeType, requiresSecureDecoder, requiresTunnelingDecoder
).filter { it.hardwareAccelerated }
}
}
// ==================== 缩放模式映射 ====================
/** 将 [VideoResizeMode] 映射为 [AspectRatioFrameLayout] 的 resizeMode 常量。 */
private fun VideoResizeMode.toMedia3ResizeMode(): Int = when (this) {
VideoResizeMode.FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
VideoResizeMode.ZOOM -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
VideoResizeMode.FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
}