第一章 基础概述
第01节 一堆废话
这部分的内容,是针对前面帖子的升级。 进入到前面的帖子 MediaMTX的简单使用
几年前,曾经做过一款产品,关于 RTSP 和 RTMP 相关的项目,当时操作的过程中,总是懵逼。

最初接触到 rtsp 的情况下,总会 懵逼!
现在,经过前面的内容。关于 mediaMTX 的使用之后,我就想着 数年前的项目,准备再来看看,如果使用 rtsp
只是当前使用的是 ijkPlayer 来实现,但是现在我的想法是 如果我坚决不采用 ijkPlayer 来实现呢?
主要的是 ijkPlayer 已经停止更新了,ijkplayer 官方早已停止实质性的代码维护和更新。 并且历史包袱重。
它的底层基于较旧版本的 FFmpeg,很多现代的视频编码格式、协议(如更先进的 AV1 编码、新版 WebRTC、部分最新的 HDR 标准等)
无法做到原生或完美的开箱即用支持。
当然我目前所写的代码,也存在一定的问题, 内存性能在隐患
1、CPU 瞬间拉满:
bitmapToNV21 是用标准的 Kotlin for 循环在做像素级乘法。
1080P 的画面每帧有 200 万个像素点,每秒 25 帧就是 5000 万次循环计算!
2、内存海啸(GC 频繁导致卡顿):
每一帧都在 new IntArray、new ByteArray、new Bitmap。
Java 堆内存会瞬间被塞满,引发 Android 系统的频繁垃圾回收(GC),从而导致画面不可避免地严重卡顿、掉帧。
当然,我不可能现在做到尽善尽美,这里也存在着,优化的空间,但是前人栽树,后人乘凉。
路漫漫其修远兮,吾将上下而求索。
只有前人,不断的探索,铺路,把经验留给后人,才会有更深层的发展。
后续的一些优化方向: 直接从 Frame 提取 YUV 送入 WebRTC
说了这些,也只是有两方面的目的:
1、提前打好预防针,我目前提供的代码,也存在着一定的内存隐患,提前告知。
2、强调不使用 ijkPlayer 播放 RTSP 网络视频
第02节 思维图解
想要播放网络视频流 RTSP 目前我的思维方案,使用一张流程图来理解:

我当前代码的核心功能是
从一个网络摄像头或者流媒体服务器(RTSP 地址)实时拉取视频和声音,然后把画面显示在手机屏幕上,把声音通过手机喇叭播放出来
它主要由两个类组成:
1、MainActivity(大管家/舞台):负责初始化界面和工具,并把画面和声音推向手机的屏幕和喇叭
2、RtspVideoCapturer(搬运工兼翻译官):负责去网络上下载数据流,并把数据"翻译"成手机能听懂、看懂的格式
第03节 通俗拆解
我们可以把整个过程, 核心步骤,通俗拆解 为 4 个阶段:
第一阶段:舞台初始化(MainActivity 启动)
当程序启动时, MainActivity 会做三件事:
1. 准备画布 initViews 初始化 SurfaceViewRenderer 这是 WebRTC 提供的一个专门用来高性能画视频的屏幕组件。
2. 准备工厂 initWebRTCFactory 创建 WebRTC 的核心工厂类,用来管理视频的编码和解码。
3. 招募搬运工 startRtspStream 创建 RtspVideoCapturer, 把网络地址给它,并告诉它:"一旦收到声音,就立刻通知我"。
第二阶段:搬运工开始干活(RtspVideoCapturer 循环拉流)
当你调用 startCapture 时,搬运工在后台开启了一个 【死循环线程】 :
1. 连接服务器 initGrabber :
利用 FFmpegFrameGrabber(基于强大的 FFmpeg 开源库)去连接你的 RTSP 视频流。
2. 疯狂抓取 while (isRunning):
只要程序没停,它就一帧一帧地从网络上捞数据 grabber.grabFrame()。
3. 分家(判断是画面还是声音):
A、如果是画面(frame.image != null):
调用 handleVideoFrame。
网络过来的画面手机不能直接画,需要先转成图片(Bitmap),再转成通用的视频格式(NV21),最后包装好交还给 `MainActivity` 展示。
B、如果是声音(frame.samples != null):
调用 handleAudioFrame。
把声音数据提取出来,转成通用的 PCM 原始音频数据,然后通过 onAudioCapturedListener 喊一声:"大管家,声音好啦,快拿去放!"。
第三阶段:大管家播放(MainActivity 渲染与播放)
1. 画面显示:
通过 WebRTC 的数据管道( VideoTrack -> addSink ),画面直接投射到了我们第一步准备好的屏幕画布 SurfaceViewRenderer 上。
2. 声音播放(handleAudioPlayback):
A、第一次收到声音时,大管家会根据声音的采样率和声道,向手机系统申请一个随身听 AudioTrack
B、之后每次收到声音的字节数组(pcmBytes),就直接调用 track.write() 把数据塞进喇叭里,你就能听到声音了。
第四阶段:善后工作(onDestroy)
当退出界面时,大管家会非常负责地把所有资源释放掉:
1、停止拉流
2、关掉后台线程
3、释放喇叭(AudioTrack)
4、是否画布画布
目的:
防止手机发热和内存泄漏。
第二章 准备工作
第01节 准备MediaMTX
这部分内容,如果您不了解,可以查看 我之前发布的一篇文章,如何使用 MediaMTX
提前准备好 MediaMTX 服务器,导入相关的资源

第02节 测试RTSP
直接在cmd 小黑窗口当中,输入下面的命令,可以查看内容, 需要提前开启服务。
前提条件: 需要提前配置了 FFmpeg 的环境变量
ffplay -rtsp_transport tcp rtsp://192.168.0.142:8554/a
效果图

第03节 下载需要的jar包
在使用 安卓开发的过程中,因为 maven 仓库远程依赖,经常容易出现掉线的情况,建议提前下载好 相关的 jar 包
我们采用 jar 包的方式,来进行项目的开发。
我们需要下载的文件,对于安卓端来说,主要有两个
ffmpeg-6.1.1-1.5.10-android-arm64.jar
ffmpeg-6.1.1-1.5.10-android-x86_64.jar
第三章 安卓代码
第01节 引入jar包
说明
在 我们项目的 app 目录下面, 建立 libs 文件, 导入相关的 jar 包
效果图

第02节 相关依赖
在 模块的 build.gradle.kts 当中,需要引入 核心的依赖项。
下面的代码部分,只是写了核心的内容,其他的内容省略未写,注意所在的位置。
groovy
android {
defaultConfig {
ndk {
abiFilters.add("arm64-v8a")
abiFilters.add("armeabi-v7a")
}
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("libs")
}
}
}
dependencies {
implementation(fileTree("libs") { include("*.jar") })
// 引入 webrtc
implementation(libs.google.webrtc)
// JavaCV 核心库
implementation(libs.javacv)
}
在 gradle\libs.versions.toml 文件当中的写法
下面的代码部分,只是写了核心的内容,其他的内容省略未写,注意所在的位置。
toml
[versions]
googleWebrtc = "1.0.32006"
javacv = "1.5.10"
[libraries]
google-webrtc = { module = "org.webrtc:google-webrtc", version.ref = "googleWebrtc" }
javacv = { group = "org.bytedeco", name = "javacv", version.ref = "javacv" }
第03节 清单文件
在清单文件当中 app\src\main\AndroidManifest.xml
因为存在网络访问,加入两个权限
xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
第04节 布局文件
在布局文件当中 app\src\main\res\layout\activity_main.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.webrtc.SurfaceViewRenderer
android:id="@+id/surface_view"
android:layout_width="300dp"
android:layout_height="300dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
需要使用到 webrtc 自带的类 SurfaceViewRenderer, 他的本质就是一个 SurfaceView
因为 public class SurfaceViewRenderer extends SurfaceView { ... }
第05节 搬运工 RtspVideoCapturer
当前我采用的是 kotlin 代码实现,如果您想要使用 Java 代码使用,可以直接将代码通过 AI 翻译为 Java 代码。
对于这个类而言,详细代码如下:
kotlin
// 导入所需的Android和JavaCV、WebRTC相关类
import android.content.Context
import android.graphics.Bitmap
import android.os.SystemClock
import android.util.Log
import org.bytedeco.javacv.AndroidFrameConverter
import org.bytedeco.javacv.FFmpegFrameGrabber
import org.bytedeco.javacv.Frame
import org.webrtc.CapturerObserver
import org.webrtc.NV21Buffer
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoCapturer
import org.webrtc.VideoFrame
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.ShortBuffer
/**
* RtspVideoCapturer 类
*
* 该类实现了 WebRTC 的 VideoCapturer 接口,用于从 RTSP 视频流中拉取音视频数据。
* 它利用 JavaCV 的 FFmpegFrameGrabber 获取音视频帧,将视频帧转换为 NV21 格式注入 WebRTC,
* 同时通过回调接口将音频帧(PCM格式)传递给外部调用者。
*
* @param rtspUrl RTSP 视频流的拉流地址
*/
class RtspVideoCapturer(private val rtspUrl: String) : VideoCapturer {
/**
* 伴生对象,用于定义类的常量
*/
private companion object {
// 日志标签,用于在 Logcat 中过滤和识别该类的日志
private const val TAG = "RtspVideoCapturer"
}
/**
* 音频数据捕获监听器接口(函数式接口)
*
* 当音频帧被成功解码并转换为 PCM 字节数组后,通过该接口回调给调用者。
*/
fun interface OnAudioCapturedListener {
/**
* 音频样本数据就绪时的回调方法
*
* @param pcmBytes PCM 格式的音频字节数组
* @param sampleRate 音频采样率
* @param channels 音频声道数
*/
fun onAudioSampleReady(pcmBytes: ByteArray, sampleRate: Int, channels: Int)
}
// 音频捕获监听器实例,可由外部设置
var onAudioCapturedListener: OnAudioCapturedListener? = null
// WebRTC 视频帧观察者,用于将处理后的视频帧传递给 WebRTC 底层
private var videoCapturerObserver: CapturerObserver? = null
// 标识当前拉流器是否正在运行
private var isRunning = false
// FFmpeg 帧抓取器,负责 RTSP 流的连接和解码
private var grabber: FFmpegFrameGrabber? = null
// Android 帧转换器,用于将 JavaCV 的 Frame 转换为 Android 的 Bitmap
private val converter = AndroidFrameConverter()
/**
* 初始化视频捕获器
*
* 该方法是 VideoCapturer 接口的实现,在启动捕获前由 WebRTC 调用,
* 主要用于保存上层传入的 CapturerObserver 引用。
*
* @param surfaceTextureHelper Surface纹理辅助类(本例中未使用)
* @param context Android 上下文(本例中未使用)
* @param capturerObserver 视频帧观察者,用于传递捕获的视频帧
*/
override fun initialize(
surfaceTextureHelper: SurfaceTextureHelper?,
context: Context?,
capturerObserver: CapturerObserver?
) {
// 保存观察者引用
this.videoCapturerObserver = capturerObserver
}
/**
* 开始捕获视频流
*
* 启动一个新线程进行 RTSP 拉流和解码,避免阻塞主线程。
* 循环抓取音视频帧,分别进行处理。
*
* @param width 请求的视频宽度(本例中未直接使用,由 RTSP 流决定)
* @param height 请求的视频高度(本例中未直接使用,由 RTSP 流决定)
* @param fps 请求的帧率(本例中未直接使用,由 RTSP 流决定)
*/
override fun startCapture(width: Int, height: Int, fps: Int) {
// 如果已经在运行,则直接返回,避免重复启动
if (isRunning) return
isRunning = true
// 开启新线程进行拉流
Thread {
try {
// 初始化并启动 FFmpeg 抓取器
initGrabber()
// 持续抓取帧,直到停止运行
while (isRunning) {
// 抓取一帧数据,如果抓取失败或结束则跳出循环
val frame: Frame = grabber?.grabFrame() ?: break
// 如果帧中包含图像数据,则处理视频帧
if (frame.image != null) {
handleVideoFrame(frame)
}
// 如果帧中包含音频采样数据,且设置了音频监听器,则处理音频帧
if (frame.samples != null && onAudioCapturedListener != null) {
handleAudioFrame(frame)
}
}
} catch (e: Exception) {
// 捕获并记录拉流解码过程中的异常
Log.e(TAG, "RTSP 拉流解码发生异常", e)
} finally {
// 无论是否发生异常,最终都要确保停止捕获以释放资源
stopCapture()
}
}.start()
}
/**
* 停止捕获视频流
*
* 停止拉流循环,并释放 FFmpegFrameGrabber 占用的资源。
*/
override fun stopCapture() {
// 如果未在运行,则直接返回
if (!isRunning) return
isRunning = false
try {
// 停止并释放抓取器资源
grabber?.stop()
grabber?.release()
grabber = null
Log.d(TAG, "RTSP 播放器已停止并释放资源")
} catch (e: Exception) {
// 记录释放资源时的异常
Log.e(TAG, "停止 RTSP 释放失败", e)
}
}
/**
* 初始化并配置 FFmpegFrameGrabber
*
* 设置 RTSP 传输协议为 TCP,配置超时时间、禁用硬件解码及缓冲等参数,
* 以降低延迟,然后启动连接。
*/
private fun initGrabber() {
Log.d(TAG, "正在连接 RTSP 服务器: $rtspUrl")
// 实例化抓取器并应用配置
grabber = FFmpegFrameGrabber(rtspUrl).apply {
// 使用 TCP 传输 RTSP 数据,比 UDP 更稳定
setOption("rtsp_transport", "tcp")
// 设置超时时间 5 秒(单位:微秒)
setOption("stimeout", "5000000")
// 禁用硬件解码(mediacodec),使用软解
setOption("mediacodec", "0")
// 禁用格式层缓冲,降低延迟
setOption("fflags", "nobuffer")
// 启用低延迟标志
setOption("flags", "low_delay")
// 启动抓取器,建立连接并开始解析流
start()
}
Log.d(TAG, "RTSP 连接成功,开始解码音视频流...")
}
/**
* 处理视频帧
*
* 将 JavaCV 的 Frame 转换为 Bitmap,再进一步转换为 WebRTC 支持的 NV21 格式,
* 最后封装成 VideoFrame 传递给观察者。
*
* @param frame 从 RTSP 流中抓取的包含图像数据的帧
*/
private fun handleVideoFrame(frame: Frame) {
try {
// 将 Frame 转换为 Android Bitmap,失败则返回
val bitmap: Bitmap = converter.convert(frame) ?: return
val w = bitmap.width
val h = bitmap.height
// 将 Bitmap 转换为 NV21 格式的字节数组
val nv21Bytes = bitmapToNV21(w, h, bitmap)
// 创建 WebRTC 的 NV21Buffer
val nv21Buffer = NV21Buffer(nv21Bytes, w, h, null)
// 创建 WebRTC 的 VideoFrame,设置旋转角度为 0,时间戳为系统开机时间(纳秒)
val videoFrame = VideoFrame(
nv21Buffer, 0, SystemClock.elapsedRealtime() * 1000000
)
// 将视频帧传递给 WebRTC 观察者
videoCapturerObserver?.onFrameCaptured(videoFrame)
// 释放 VideoFrame 资源,防止内存泄漏
videoFrame.release()
} catch (e: Exception) {
Log.e(TAG, "视频帧渲染失败", e)
}
}
/**
* 处理音频帧
*
* 将 JavaCV 的 Frame 中的 ShortBuffer 音频采样数据转换为 PCM 字节数组,
* 并通过监听器回调传递给外部。
*
* @param frame 从 RTSP 流中抓取的包含音频采样数据的帧
*/
private fun handleAudioFrame(frame: Frame) {
try {
// 获取第一个声道的音频采样数据(ShortBuffer)
val sampleBuffer = frame.samples[0] as ShortBuffer
// 将缓冲区指针重置到起始位置
sampleBuffer.position(0)
// 计算所需的 PCM 字节数组大小(Short 占用 2 个字节)
val pcmBytes = ByteArray(sampleBuffer.remaining() * 2)
// 将 ShortBuffer 中的数据按本地字节序写入 ByteArray 中
ByteBuffer.wrap(pcmBytes).order(ByteOrder.nativeOrder()).asShortBuffer()
.put(sampleBuffer)
// 触发音频捕获监听器回调,传递 PCM 数据、采样率和声道数
onAudioCapturedListener?.onAudioSampleReady(
pcmBytes, frame.sampleRate, frame.audioChannels
)
} catch (e: Exception) {
Log.e(TAG, "音频帧处理失败", e)
}
}
/**
* 将 Bitmap 转换为 NV21 格式的字节数组
*
* NV21 是 Android 摄像头常用的 YUV 格式。该方法逐像素提取 ARGB 值,
* 根据公式转换为 Y、U、V 分量,并按照 NV21 的内存布局(Y 连续存储,VU 交错存储)组装数据。
*
* @param inputWidth 输入图像的宽度
* @param inputHeight 输入图像的高度
* @param bitmap 源 Bitmap 图像
* @return NV21 格式的字节数组
*/
private fun bitmapToNV21(inputWidth: Int, inputHeight: Int, bitmap: Bitmap): ByteArray {
// 获取 Bitmap 的所有像素 ARGB 值
val argb = IntArray(inputWidth * inputHeight)
bitmap.getPixels(argb, 0, inputWidth, 0, 0, inputWidth, inputHeight)
// 初始化 NV21 字节数组,大小为 width * height * 3 / 2
val yuv = ByteArray(inputWidth * inputHeight * 3 / 2)
var yIndex = 0 // Y 分量的写入索引
var uvIndex = inputWidth * inputHeight // UV 分量的写入索引(从 Y 数据之后开始)
// 遍历每个像素
for (j in 0 until inputHeight) {
for (i in 0 until inputWidth) {
val index = j * inputWidth + i
// 提取 R、G、B 分量
val r = (argb[index] shr 16) and 0xff
val g = (argb[index] shr 8) and 0xff
val b = argb[index] and 0xff
// 根据 RGB 计算 Y 分量,并限制在 [0, 255] 范围内
val y = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
yuv[yIndex++] = (if (y < 0) 0 else if (y > 255) 255 else y).toByte()
// 在偶数行、偶数列时计算 U 和 V 分量(4:2:0 采样,每 4 个 Y 共享一组 UV)
if (j % 2 == 0 && i % 2 == 0) {
// 根据 RGB 计算 U 分量,并限制在 [0, 255] 范围内
val u = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
// 根据 RGB 计算 V 分量,并限制在 [0, 255] 范围内
val v = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
// 注意:NV21 格式中,V 排在 U 前面(VU 交错排列)
yuv[uvIndex++] = (if (v < 0) 0 else if (v > 255) 255 else v).toByte()
yuv[uvIndex++] = (if (u < 0) 0 else if (u > 255) 255 else u).toByte()
}
}
}
return yuv
}
/**
* 改变捕获格式
*
* 该方法是 VideoCapturer 接口的实现,在此未作具体处理。
*/
override fun changeCaptureFormat(width: Int, height: Int, fps: Int) {}
/**
* 释放资源
*
* 该方法是 VideoCapturer 接口的实现,在此未作具体处理。
*/
override fun dispose() {}
/**
* 判断是否为屏幕录制
*
* 该方法是 VideoCapturer 接口的实现,返回 false 表示不是屏幕录制。
*/
override fun isScreencast(): Boolean = false
}
第06节 大管家 MainActivity
当前我采用的是 kotlin 代码实现,如果您想要使用 Java 代码使用,可以直接将代码通过 AI 翻译为 Java 代码。
对于这个类而言,详细代码如下:
kotlin
// 导入 Android 及 WebRTC 相关的依赖库
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.cosmo.ai.R
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.PeerConnectionFactory
import org.webrtc.RendererCommon
import org.webrtc.SurfaceTextureHelper
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
/**
* MainActivity 主活动类
* 主要功能:通过 WebRTC 组件拉取 RTSP 视频流并在 SurfaceViewRenderer 上渲染,
* 同时捕获 RTSP 流中的音频数据,使用 Android AudioTrack 进行本地音频播放。
*/
class MainActivity : AppCompatActivity() {
/**
* 伴生对象,用于定义类的常量
*/
private companion object {
/** 日志标签 */
private const val TAG = "MainActivity"
/** RTSP 视频流的拉取地址 */
private const val RTSP_URL = "rtsp://192.168.0.142:8554/a"
}
/** 视频渲染视图 */
private lateinit var videoView: SurfaceViewRenderer
/** WebRTC 对等连接工厂,用于创建视频轨道等 */
private var peerConnectionFactory: PeerConnectionFactory? = null
/** EGL 基础环境,用于 OpenGL 渲染上下文的管理 */
private var rootEglBase: EglBase? = null
/** WebRTC 视频轨道,用于传输和渲染视频数据 */
private var videoTrack: VideoTrack? = null
/** 自定义的 RTSP 视频捕获器,负责从 RTSP 源获取音视频数据 */
private var videoCapturer: RtspVideoCapturer? = null
/** 本地音频播放器,用于播放 PCM 原始音频数据 */
private var localAudioTrack: AudioTrack? = null
/** 标识 AudioTrack 是否已初始化 */
private var isAudioInitialized = false
/**
* Activity 创建时的回调方法
* 初始化视图、WebRTC 工厂,并开始拉取 RTSP 流
*
* @param savedInstanceState 保存的实例状态
*/
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化界面控件
initViews()
// 初始化 WebRTC 核心工厂
initWebRTCFactory()
// 开始拉取并播放 RTSP 音视频流
startRtspStream()
}
/**
* 初始化视图相关配置
* 包括获取视频渲染视图,初始化 EGL 上下文,以及设置视频缩放模式
*/
private fun initViews() {
videoView = findViewById(R.id.surface_view)
// 创建 EGL 上下文,为硬件加速渲染提供环境
rootEglBase = EglBase.create()
// 使用 EGL 上下文初始化渲染视图
videoView.init(rootEglBase?.eglBaseContext, null)
// 设置视频缩放类型为保持宽高比适应
videoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
}
/**
* 初始化 WebRTC PeerConnectionFactory
* 配置视频编解码器工厂,并创建 PeerConnectionFactory 实例
*/
private fun initWebRTCFactory() {
// 初始化 WebRTC 全局参数
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(this).createInitializationOptions()
)
// 构建 PeerConnectionFactory,配置硬件加速编解码
peerConnectionFactory = PeerConnectionFactory.builder().setVideoEncoderFactory(
DefaultVideoEncoderFactory(
rootEglBase?.eglBaseContext, true, // 启用硬件编码
true // 启用硬件编码优先
)
).setVideoDecoderFactory(DefaultVideoDecoderFactory(rootEglBase?.eglBaseContext))
.createPeerConnectionFactory()
}
/**
* 开始拉取 RTSP 流并进行渲染
* 初始化自定义的 RTSP 捕获器,创建视频源和视频轨道,并绑定到渲染视图
*/
private fun startRtspStream() {
// 实例化 RTSP 捕获器,并设置音频捕获回调
videoCapturer = RtspVideoCapturer(RTSP_URL).apply {
onAudioCapturedListener =
RtspVideoCapturer.OnAudioCapturedListener { pcmBytes, sampleRate, channels ->
// 收到音频 PCM 数据时,交由音频处理方法播放
handleAudioPlayback(pcmBytes, sampleRate, channels)
}
}
// 创建 SurfaceTexture 辅助类,用于在独立线程处理纹理数据
val surfaceTextureHelper =
SurfaceTextureHelper.create("RtspThread", rootEglBase?.eglBaseContext)
// 创建视频源
val videoSource = peerConnectionFactory?.createVideoSource(videoCapturer!!.isScreencast)
// 初始化捕获器,并开始捕获视频帧(1280x720 分辨率,25帧率)
videoCapturer?.initialize(surfaceTextureHelper, this, videoSource?.capturerObserver)
videoCapturer?.startCapture(1280, 720, 25)
// 通过视频源创建视频轨道,并将其绑定到渲染视图上输出
videoTrack = peerConnectionFactory?.createVideoTrack("RTSP_TRACK_ID", videoSource)?.apply {
addSink(videoView)
}
}
/**
* 处理音频播放逻辑
* 接收原始 PCM 字节数据,如果 AudioTrack 未初始化则先初始化,随后将音频数据写入播放
*
* @param pcmBytes PCM 音频字节数据
* @param sampleRate 采样率
* @param channels 声道数
*/
private fun handleAudioPlayback(pcmBytes: ByteArray, sampleRate: Int, channels: Int) {
try {
// 如果音频未初始化或 AudioTrack 实例丢失,则重新初始化
if (!isAudioInitialized || localAudioTrack == null) {
initAudioTrack(sampleRate, channels)
}
localAudioTrack?.let { track ->
// 如果当前未在播放状态,则启动播放
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) {
track.play()
}
// 将 PCM 字节数据写入 AudioTrack 进行播放
track.write(pcmBytes, 0, pcmBytes.size)
}
} catch (e: Exception) {
Log.e(TAG, "本地音频写入失败", e)
}
}
/**
* 初始化 Android AudioTrack
* 根据采样率和声道数配置 AudioTrack,并构建音频属性和格式
*
* @param sampleRate 采样率
* @param channels 声道数(1为单声道,2为立体声)
*/
private fun initAudioTrack(sampleRate: Int, channels: Int) {
try {
// 根据声道数选择对应的声道配置常量
val channelConfig = if (channels == 1) {
AudioFormat.CHANNEL_OUT_MONO
} else {
AudioFormat.CHANNEL_OUT_STEREO
}
// 计算最小缓冲区大小
val bufferSize = AudioTrack.getMinBufferSize(
sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT
)
// 构建 AudioTrack 实例
localAudioTrack = AudioTrack.Builder().setAudioAttributes(
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) // 用途:媒体播放
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) // 内容类型:电影/视频
.build()
).setAudioFormat(
AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_16BIT) // 编码格式:16位PCM
.setSampleRate(sampleRate) // 采样率
.setChannelMask(channelConfig) // 声道配置
.build()
).setBufferSizeInBytes(bufferSize) // 缓冲区大小
.setTransferMode(AudioTrack.MODE_STREAM) // 流模式:数据持续写入
.build()
// 标记音频已初始化
isAudioInitialized = true
Log.d(TAG, "AudioTrack 初始化成功: 采样率=$sampleRate, 声道数=$channels")
} catch (e: Exception) {
Log.e(TAG, "AudioTrack 初始化异常", e)
}
}
/**
* Activity 销毁时的回调方法
* 负责释放所有音视频相关资源,防止内存泄漏
*/
override fun onDestroy() {
super.onDestroy()
// 停止 RTSP 视频捕获
videoCapturer?.stopCapture()
// 移除视频轨道的渲染输出,并释放视图和 EGL 上下文
videoTrack?.removeSink(videoView)
videoView.release()
rootEglBase?.release()
// 释放音频播放资源
try {
localAudioTrack?.stop()
localAudioTrack?.release()
localAudioTrack = null
} catch (e: Exception) {
Log.e(TAG, "释放 AudioTrack 失败", e)
}
}
}
第四章 运行结果
第01节 步骤说明
1、先启动 mediaMTX 服务器
2、再运行 安卓app
第02节 效果图
