Android视频直播播放器简单封装
EasyVlcPlayer 工具类分析与文档
基于
libVLC的Android视频播放器封装,专用于IoT场景下的 RTSP/RTMP/HTTPS 视频直播流播放。
1.依赖导入:
arduino
implementation 'org.videolan.android:libvlc-all:3.6.5'
2.EasyVlcPlayer完整代码:
kotlin
package com.xx.xx.iot.webrtc
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.TextureView
import androidx.core.net.toUri
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.util.VLCVideoLayout
/**
* @作者: smile
* @时间: 2025/11/18 19:02
* @描述: vlc播放器
**/
class EasyVlcPlayer(private val context: Context) {
// VLC核心组件
private var libVLC: LibVLC ? =null
private var mediaPlayer: MediaPlayer ?= null
// 播放状态回调
private var listener: OnPlayListener? = null
private val progressHandler = Handler(Looper.getMainLooper())
private var progressRunnable: Runnable? = null // 进度更新任务
private var isInitialized = false
private val TAG = EasyVlcPlayer::class.java.name
private var surface: VLCVideoLayout? = null
// 播放状态监听接口
interface OnPlayListener {
fun onPrepared() // 准备完成
fun onError(message: String) // 错误回调
fun onStopped() // 停止回调
fun onProgress(currentPosition: Long, totalDuration: Long)
}
fun setOnPlayListener(listener: OnPlayListener) {
this.listener = listener
}
fun isInitialized(): Boolean = isInitialized
// 初始化VLC
fun init() = try {
// 配置VLC参数
val options = mutableListOf(
"--no-video-title-show",//隐藏视频标题栏
"--network-caching=800",//网络缓冲时间
"--demux=hevc",//强制 H.265 解复用
"--codec=hevc",//强制 H.265 解码
"--http-reconnect",//开启 HTTP 断连自动重连
"--vout=android_display",//指定视频输出模块为 Android 原生显示模块
"--live-caching=800",//设置直播流专属缓存时间
"-vvv",// 保留日志,方便排查(发布时移除)
"--hevc-fps=20",//设置帧率为20fps
"--avcodec-skiploopfilter=all", // 减少解码器压力
"--android-display-chroma=RV32", // 指定颜色格式
"--avcodec-hw=none", // 禁用硬件解码
"--video-on-top",
)
libVLC = LibVLC(context, options)
mediaPlayer = MediaPlayer(libVLC)
mediaPlayer?.spuTrack = -1 // 禁用字幕
isInitialized = true
} catch (e: Exception) {
listener?.onError("VLC初始化失败:${e.message}")
isInitialized = false
}
// 设置播放容器
fun setSurface(vlcVideoLayout: VLCVideoLayout) {
if (vlcVideoLayout.width == 0 || vlcVideoLayout.height == 0) {
Log.d(TAG, "Surface 未就绪(宽高为 0),延迟绑定")
vlcVideoLayout.post {
this.surface = vlcVideoLayout
Log.d(TAG, "Surface 延迟绑定完成")
}
return
}
this.surface = vlcVideoLayout
mediaPlayer?.attachViews(vlcVideoLayout, null, false, false)
}
// 播放RTSP/RTMP流
fun play(streamUrl: String) {
try {
if (!isInitialized) {
listener?.onError("播放器未初始化")
return
}
if (surface == null) {
listener?.onError("播放控件未绑定")
return
}
try {
val uri = streamUrl.toUri()
.buildUpon()
.scheme("https") // 强制指定 HTTPS 协议
.build()
val media = Media(libVLC, uri)
mediaPlayer?.let {
it.media = media
media.release()
it.play()
it.setEventListener { event ->
when (event.type) {
MediaPlayer.Event.Playing -> Log.d(TAG,"准备完成")
MediaPlayer.Event.EncounteredError -> listener?.onError("播放错误:${it.media?.uri}")
MediaPlayer.Event.EndReached,
MediaPlayer.Event.Stopped -> {
stop()
listener?.onStopped()
}
}
}
}
} catch (e: Exception) {
listener?.onError("播放失败:${e.message}")
}
} catch (e: Exception) {
listener?.onError("播放失败:${e.message}")
}
}
fun seekTo(position: Long) {
mediaPlayer?.time = position
}
fun getTotalDuration(): Long {
return mediaPlayer?.length ?: 0L
}
private fun stopProgressUpdate() {
progressRunnable?.let { progressHandler.removeCallbacks(it) }
}
/**
* 暂停渲染
*/
fun pauseRender() {
mediaPlayer?.let {
if (it.isPlaying) {
it.pause() // 先暂停解码
}
it.detachViews()
stopProgressUpdate()
Log.d(TAG, "渲染已暂停,释放 BufferQueue")
}
}
/**
* 恢复渲染(切前台/解锁时调用:重建渲染链路)
*/
fun resumeRender() {
val currentSurface = surface ?: run {
listener?.onError("恢复渲染失败:播放控件未绑定")
return
}
if (currentSurface.width > 0 && currentSurface.height > 0) {
mediaPlayer?.let { player ->
player.attachViews(currentSurface, null, false, false) // 重新绑定 Surface
if (!player.isPlaying) {
player.play()
}
Log.d(TAG, "渲染已恢复,重建 BufferQueue")
}
} else {
currentSurface.postDelayed({
resumeRender()
}, 300)
Log.w(TAG, "Surface 未就绪,延迟恢复渲染")
}
}
// 暂停播放
fun pause() {
mediaPlayer?.let {
if (it.isPlaying) {
it.pause()
stopProgressUpdate()
}
}
}
// 恢复播放
fun resume() {
if (mediaPlayer == null || !isInitialized) {
listener?.onError("播放器未初始化")
return
}
val currentSurface = surface ?: run {
listener?.onError("播放控件未绑定")
return
}
if (currentSurface.width > 0 && currentSurface.height > 0) {
mediaPlayer?.let { player ->
if (!player.isPlaying) {
player.detachViews()
player.attachViews(currentSurface, null, false, false)
player.play() // 恢复播放
Log.d(TAG, "恢复播放成功,Surface 宽高:${currentSurface.width}x${currentSurface.height}")
}
}
} else {
currentSurface.postDelayed({
resume()
}, 300)
Log.w(TAG, "Surface 未就绪(宽高:${currentSurface.width}x${currentSurface.height}),延迟恢复播放")
}
}
// 停止播放
fun stop() {
mediaPlayer?.stop()
stopProgressUpdate()
}
// 释放资源
fun release() {
stopProgressUpdate()
progressHandler.removeCallbacksAndMessages(null)
mediaPlayer?.stop()
mediaPlayer?.release()
libVLC?.release()
libVLC = null
mediaPlayer = null
surface = null
listener = null
isInitialized = false
}
fun detachSurface() {
mediaPlayer?.detachViews()
}
}
3、类概述
| 项目 | 说明 |
|---|---|
| 包路径 | com.x.x.iot.webrtc |
| 作者 | smile |
| 核心依赖 | org.videolan.libvlc (VLC Android SDK) |
| 主要用途 | 播放设备端推送的实时视频流(H.265/HEVC 编码) |
| 典型场景 | 机器人/IoT 设备的远程视频监控、直播回放 |
设计定位
EasyVlcPlayer 是对 libVLC 的轻量级封装,专注于:
- 直播流播放:支持 RTSP、RTMP、HTTPS 等协议
- H.265 硬解适配:强制 HEVC 解码,适配 IoT 设备编码格式
- 前台/后台生命周期管理 :通过
pauseRender()/resumeRender()处理 Surface 绑定/解绑 - 低延迟播放:缓存配置为 800ms,帧率限制 20fps
4、核心成员
4.1 属性
| 属性 | 类型 | 说明 |
|---|---|---|
libVLC |
LibVLC? |
VLC 核心引擎实例 |
mediaPlayer |
MediaPlayer? |
VLC 媒体播放器实例 |
surface |
VLCVideoLayout? |
视频渲染容器(Android Surface) |
listener |
OnPlayListener? |
播放状态回调监听器 |
progressHandler |
Handler |
主线程 Handler,用于进度更新 |
progressRunnable |
Runnable? |
进度更新定时任务 |
isInitialized |
Boolean |
初始化状态标志 |
4.2 回调接口
kotlin
interface OnPlayListener {
fun onPrepared() // 准备完成
fun onError(message: String) // 错误回调
fun onStopped() // 停止回调
fun onProgress(currentPosition: Long, // 进度回调
totalDuration: Long)
}
5、方法详解
5.1 初始化流程
kotlin
fun init()
执行步骤:
ini
1. 配置 VLC 启动参数 (MutableList<String>)
├── --no-video-title-show → 隐藏视频标题栏
├── --network-caching=800 → 网络缓冲 800ms
├── --demux=hevc → 强制 H.265 解复用
├── --codec=hevc → 强制 H.265 解码
├── --http-reconnect → HTTP 断连自动重连
├── --vout=android_display → Android 原生显示模块
├── --live-caching=800 → 直播流缓存 800ms
├── -vvv → 详细日志(发布时移除)
├── --hevc-fps=20 → 限制帧率 20fps
├── --avcodec-skiploopfilter=all → 跳过环路滤波,降低解码压力
├── --android-display-chroma=RV32 → 颜色格式 RV32
├── --avcodec-hw=none → 禁用硬件解码(兼容性优先)
└── --video-on-top → 视频置顶显示
2. libVLC = LibVLC(context, options)
3. mediaPlayer = MediaPlayer(libVLC)
4. mediaPlayer?.spuTrack = -1 → 禁用字幕轨道
5. isInitialized = true
异常处理:
- 捕获所有异常,回调
listener?.onError("VLC初始化失败:${e.message}") - 设置
isInitialized = false
5.2 Surface 绑定
kotlin
fun setSurface(vlcVideoLayout: VLCVideoLayout)
逻辑分支:
| 条件 | 处理 |
|---|---|
| `width == 0 | |
width > 0 && height > 0 |
立即绑定:mediaPlayer?.attachViews(vlcVideoLayout, null, false, false) |
注意 :
attachViews()必须在 Surface 有效时调用,否则会导致渲染失败。
5.3 播放视频流
kotlin
fun play(streamUrl: String)
完整流程:
scss
1. 前置检查
├── !isInitialized → onError("播放器未初始化") → return
└── surface == null → onError("播放控件未绑定") → return
2. URL 处理(关键!)
└── streamUrl.toUri()
.buildUpon()
.scheme("https") // 强制将协议改为 HTTPS
.build()
3. 创建 Media 对象
└── val media = Media(libVLC, uri)
4. 设置并播放
├── mediaPlayer?.media = media
├── media.release() // 立即释放 Media 对象(VLC 内部已引用)
└── mediaPlayer?.play()
⚠️ 强制 HTTPS :无论传入的 URL 是什么协议,都会被强制改为
https://。这是为了统一安全传输,但可能导致 RTSP/RTMP 流无法正常播放(需确认服务器支持)。
5.4 渲染控制(前台/后台切换)
pauseRender() --- 暂停渲染(切后台/锁屏)
kotlin
fun pauseRender()
执行步骤:
scss
1. if (isPlaying) → pause() // 暂停解码
2. detachViews() // 解绑 Surface,释放 BufferQueue
3. stopProgressUpdate() // 停止进度更新任务
目的: 在 Activity 进入后台或屏幕锁定时调用,释放 Surface 资源,避免系统报错。
resumeRender() --- 恢复渲染(切前台/解锁)
kotlin
fun resumeRender()
执行步骤:
scss
1. 检查 surface 有效性
└── surface == null → onError("恢复渲染失败:播放控件未绑定") → return
2. 检查 Surface 宽高
├── width > 0 && height > 0
│ ├── attachViews(currentSurface, null, false, false) // 重建 BufferQueue
│ └── if (!isPlaying) → play()
│
└── width == 0 || height == 0
└── postDelayed(300ms) → resumeRender() // 递归延迟等待
目的: 在 Activity 回到前台时重建渲染链路,恢复视频显示。
5.5 播放控制
| 方法 | 作用 | 内部逻辑 |
|---|---|---|
pause() |
暂停播放 | mediaPlayer?.pause() + stopProgressUpdate() |
resume() |
恢复播放 | 检查初始化/Surface → detachViews() → attachViews() → play() |
stop() |
停止播放 | mediaPlayer?.stop() + stopProgressUpdate() |
seekTo(position) |
跳转进度 | mediaPlayer?.time = position |
getTotalDuration() |
获取总时长 | mediaPlayer?.length ?: 0L |
detachSurface() |
解绑 Surface | mediaPlayer?.detachViews() |
5.6 资源释放
kotlin
fun release()
释放顺序(严格!):
csharp
1. stopProgressUpdate() // 停止进度任务
2. progressHandler.removeCallbacksAndMessages(null) // 清空 Handler 消息队列
3. mediaPlayer?.stop() // 停止播放
4. mediaPlayer?.release() // 释放播放器
5. libVLC?.release() // 释放 VLC 引擎
6. libVLC = null // 置空引用
7. mediaPlayer = null
8. surface = null
9. listener = null
10. isInitialized = false
⚠️ 重要 :必须在 Activity
onDestroy()中调用,否则会造成内存泄漏和 Native 层崩溃。
6、VLC 参数配置详解
| 参数 | 值 | 作用 |
|---|---|---|
--no-video-title-show |
- | 隐藏视频标题栏,避免遮挡画面 |
--network-caching=800 |
800ms | 网络流缓冲时间,降低延迟 |
--demux=hevc |
hevc | 强制使用 H.265 解复用器 |
--codec=hevc |
hevc | 强制使用 H.265 解码器 |
--http-reconnect |
- | HTTP 断开后自动重连 |
--vout=android_display |
android_display | 使用 Android 原生显示输出 |
--live-caching=800 |
800ms | 直播流专属缓存 |
-vvv |
- | 最高级别日志(调试使用,发布移除) |
--hevc-fps=20 |
20fps | 限制 HEVC 解码帧率,降低 CPU 占用 |
--avcodec-skiploopfilter=all |
all | 跳过环路滤波,减少解码计算量 |
--android-display-chroma=RV32 |
RV32 | 指定 RGB32 颜色格式 |
--avcodec-hw=none |
none | 禁用硬件解码,使用软件解码(兼容性优先) |
--video-on-top |
- | 视频窗口置顶 |
7、生命周期管理
7.1 Activity 生命周期对应
| Activity 回调 | 应调用的方法 | 说明 |
|---|---|---|
onCreate() |
EasyVlcPlayer(context) → init() |
初始化播放器 |
onResume() |
resumeRender() |
恢复渲染 |
onPause() |
pauseRender() |
暂停渲染,释放 Surface |
onDestroy() |
release() |
彻底释放资源 |
7.2 状态流转图
scss
[未初始化]
│
▼ init()
[已初始化]
│
▼ setSurface()
[Surface 已绑定]
│
▼ play(url)
[播放中]
│
├──► pauseRender() ──► [渲染暂停]
│ │
│ ▼ resumeRender()
│ [恢复渲染]
│
├──► pause() ──► [已暂停]
│ │
│ ▼ resume()
│ [播放中]
│
└──► stop() / release()
│
▼
[已停止/已释放]
8、注意事项与潜在问题
8.1 ⚠️ 强制 HTTPS 问题
scss
val uri = streamUrl.toUri()
.buildUpon()
.scheme("https") // 强制指定 HTTPS
.build()
风险:
- 如果传入的是
rtsp://或rtmp://流地址,强制改为https://会导致播放失败 - 建议根据实际协议类型动态设置 scheme,或确保服务器同时提供 HTTPS 流
8.2 ⚠️ 禁用硬件解码
css
"--avcodec-hw=none"
影响:
- 使用纯软件解码,CPU 占用较高
- 在低端设备上可能出现卡顿、发热
- 优势是兼容性好,避免硬解兼容性导致的黑屏/花屏
8.3 ⚠️ Surface 未就绪处理
setSurface() 和 resumeRender() 都处理了 width/height == 0 的情况:
kotlin
vlcVideoLayout.post { this.surface = layout } // setSurface 延迟绑定
currentSurface.postDelayed({ resumeRender() }, 300) // resumeRender 递归等待
建议: 确保在 Surface 完全创建后再调用 play(),避免不必要的延迟。
8.4 ⚠️ 进度更新未实现
代码中定义了 progressRunnable 和 progressHandler,但没有实际启动进度更新任务。如果需要进度条功能,需要补充:
kotlin
// 在 play() 成功后启动
progressRunnable = object : Runnable {
override fun run() {
val current = mediaPlayer?.time ?: 0L
val total = mediaPlayer?.length ?: 0L
listener?.onProgress(current, total)
progressHandler.postDelayed(this, 1000)
}
}
progressHandler.post(progressRunnable!!)
8.5 ⚠️ 事件监听被注释
vbnet
/* it.setEventListener { event ->
when (event.type) {
MediaPlayer.Event.Playing -> ...
MediaPlayer.Event.EncounteredError -> ...
MediaPlayer.Event.EndReached -> ...
}
} */
影响: 播放完成、错误等事件无法通过 VLC 内部事件回调通知上层,只能通过外部调用感知状态。
9、使用示例
kotlin
package com.x.x.iot.ui.activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.google.gson.Gson
import com.x.x.iot.R
import com.x.x.iot.databinding.ActivityVlcPlaerBinding
import com.x.x.iot.event.StreamMessageEvent
import com.x.x.iot.http.ApiException
import com.x.x.iot.http.ApiResult
import com.x.x.iot.http.EasyHttpUtil
import com.x.x.iot.http.weakHashMapOf
import com.x.x.iot.utils.IotConstants
import com.x.x.iot.utils.FlowBus
import com.x.x.iot.utils.LogUtils
import com.x.x.iot.webrtc.EasyVlcPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* @作者: smile
* @时间: 2025/11/18 18:31
* @描述: 使用vlc播放视频流
**/
class VlcVideoPlayerActivity : AppCompatActivity(), EasyVlcPlayer.OnPlayListener {
private val TAG = "VlcVideoPlayerActivity"
private lateinit var binding: ActivityVlcPlaerBinding
private lateinit var vlcPlayer: EasyVlcPlayer
private val mainHandler = Handler(Looper.getMainLooper())
private var videoUrl: String = ""
private var isPlaying = false
private var isPaused = false
private val liveScope = CoroutineScope(Dispatchers.IO)
private var liveJob: Job? = null
private var requestSuccess: Boolean = false
private var code: Int = 0
private var errorMsg = ""
private var deviceId = ""
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
binding = ActivityVlcPlaerBinding.inflate(layoutInflater)
setContentView(binding.root)
initVlcPlayer()
initData()
initViews()
setupClickListeners()
setUpEdgeToEdge()
}
private fun initVlcPlayer() {
//设置屏幕常亮
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
vlcPlayer = EasyVlcPlayer(this)
vlcPlayer.setOnPlayListener(this)
vlcPlayer.init()
binding.vlcVideoLayout.post {
if (::vlcPlayer.isInitialized) {
vlcPlayer.setSurface(binding.vlcVideoLayout)
Log.d(TAG, "VLCVideoLayout 宽高:${binding.vlcVideoLayout.width}x${binding.vlcVideoLayout.height}")
}
}
}
private fun initData() {
intent?.let {
deviceId = it.getStringExtra("deviceId").toString()
}
getStartLiveLoop(deviceId)
FlowBus.with<StreamMessageEvent>("start_live")
.register(this@VlcVideoPlayerActivity) { it ->
LogUtils.d(TAG, "===接收到的消息为===" + Gson().toJson(it))
val originalLink = it.link?.trim().toString()
LogUtils.d(TAG, "===接收到的消息url为===$originalLink")
if (originalLink.isBlank() || !originalLink.startsWith("https://")) {
Log.e(TAG, "无效地址:$originalLink")
return@register
}
videoUrl = originalLink
playH265Video()
}
}
private fun setUpEdgeToEdge() {
val mainView = findViewById<View>(R.id.main)
ViewCompat.setOnApplyWindowInsetsListener(mainView) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
}
private fun initViews() {
updateButtonStates(false, false)
Log.d(TAG, "准备就绪")
}
private fun setupClickListeners() {
binding.btnPlay.setOnClickListener { playH265Video() }
binding.btnStop.setOnClickListener { stopH265Video() }
binding.btnPause.setOnClickListener { pauseH265Video() }
binding.btnResume.setOnClickListener { resumeH265Video() }
}
/**
* 下发开始播放设备指令
*/
private fun getStartLiveLoop(deviceId: String) {
stopLiveLoop()
liveJob = liveScope.launch {
while (isActive) {
// 每隔1秒执行1次
delay(1000)
val requestBody = weakHashMapOf<String, Any>(
"device_id" to deviceId,
"directive" to IotConstants.USER_START_LIVE)
val requestHeaders = weakHashMapOf(
"x-app-pkgname" to IotConstants.APP_PACKAGE_PET_GUGU,
"accept-language" to "en-us",
"x-app-id" to "10003"
)
try {
EasyHttpUtil.post(
urlStr = IotConstants.BASE_IOT_URL + IotConstants.USER_START_LIVE,
bodyParams = requestBody,
headers = requestHeaders,
clazz = Int::class.java)
.onStart {
LogUtils.d(TAG, "开始播放指令下发开始:")
}.catch { exception ->
errorMsg = exception.message.toString()
LogUtils.e(TAG, "开始播放指令下发失败: $errorMsg")
}.first().let { result ->
when (result) {
is ApiResult.Success -> {
requestSuccess = result.data == 200
LogUtils.d(
TAG,
"开始播放指令下发结果:${if (requestSuccess) "成功" else "失败"}"
)
}
is ApiResult.Error -> {
throw ApiException(result.code, result.message)
}
}
}
}catch (e: Exception){
LogUtils.e(TAG,"===请求出错==="+e.message)
throw e
}
}
}
}
private fun initStopVideo() {
val requestBody = weakHashMapOf<String, Any>("device_id" to deviceId, "directive" to IotConstants.USER_STOP_LIVE)
val requestHeaders = weakHashMapOf(
"x-app-pkgname" to IotConstants.APP_PACKAGE_PET_GUGU,
"accept-language" to "en-us",
"x-app-id" to "10003"
)
lifecycleScope.launch {
EasyHttpUtil.post(
urlStr = IotConstants.BASE_IOT_URL + IotConstants.USER_STOP_LIVE,
bodyParams = requestBody,
headers = requestHeaders,
clazz = Int::class.java
).onStart {
LogUtils.d(TAG, "停止播放指令下发开始:")
}.catch { exception ->
val errorMsg = exception.message.toString()
LogUtils.d(TAG, "停止播放指令下发出错: $errorMsg")
}.first().let { result ->
when (result) {
is ApiResult.Success -> {
requestSuccess = result.data == 200
LogUtils.d(
TAG,
"停止播放指令下发:${if (requestSuccess) "成功" else "失败"}"
)
}
is ApiResult.Error -> {
throw ApiException(result.code, result.message)
}
}
}
}
}
private fun stopLiveLoop() {
liveJob?.cancel()
}
// 播放视频
private fun playH265Video() {
if (!::vlcPlayer.isInitialized || !vlcPlayer.isInitialized()) {
Log.d(TAG, "播放器未初始化")
return
}
if (binding.vlcVideoLayout.width == 0 || binding.vlcVideoLayout.height == 0) {
Log.e(TAG, "播放失败:播放控件未就绪")
Log.d(TAG, "错误:播放控件未就绪")
return
}
try {
Log.d(TAG, "开始播放地址:$videoUrl")
vlcPlayer.play(videoUrl)
isPlaying = true
isPaused = false
updateButtonStates(isPlaying, isPaused)
} catch (e: Exception) {
Log.e(TAG, "播放失败", e)
Log.d(TAG, "播放失败: ${e.message}")
}
}
// 停止播放
private fun stopH265Video() {
Log.d(TAG, "停止播放")
if (!isPlaying) {
Log.d(TAG, "未在播放中")
return
}
vlcPlayer.stop()
isPlaying = false
isPaused = false
updateButtonStates(isPlaying, isPaused)
}
// 暂停播放
private fun pauseH265Video() {
Log.d(TAG, "暂停播放")
if (!isPlaying || isPaused) {
Log.d(TAG, "已暂停或未播放")
return
}
if (vlcPlayer.isInitialized()) {
vlcPlayer.pauseRender()
}
isPaused = true
updateButtonStates(isPlaying, isPaused)
}
// 恢复播放
private fun resumeH265Video() {
Log.d(TAG, "恢复播放")
if (!isPlaying || !isPaused) {
Log.d(TAG, "未暂停或未播放")
return
}
if (vlcPlayer.isInitialized()) {
vlcPlayer.resumeRender()
}
isPaused = false
updateButtonStates(isPlaying, isPaused)
}
private fun updateButtonStates(isPlaying: Boolean, isPaused: Boolean) {
mainHandler.post {
binding.btnPlay.isEnabled = !isPlaying
binding.btnStop.isEnabled = isPlaying
binding.btnPause.isEnabled = isPlaying && !isPaused
binding.btnResume.isEnabled = isPlaying && isPaused
}
}
override fun onStart() {
super.onStart()
Log.d("VlcActivity", "onStart:恢复渲染")
if (vlcPlayer.isInitialized()) {
vlcPlayer.resumeRender()
}
}
override fun onStop() {
super.onStop()
Log.d(TAG, "onPause")
if (vlcPlayer.isInitialized()) {
vlcPlayer.pauseRender()
}
}
/* override fun onResume() {
super.onResume()
Log.d(TAG, "onResume")
if (isPaused) {
resumeH265Video()
}
}*/
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "onDestroy")
vlcPlayer.detachSurface()
vlcPlayer.release()
mainHandler.removeCallbacksAndMessages(null)
}
override fun onPrepared() {
LogUtils.d(TAG,"准备完成,正在播放...")
}
override fun onError(message: String) {
LogUtils.d(TAG,"错误: $message")
mainHandler.post {
isPlaying = false
isPaused = false
updateButtonStates(isPlaying, isPaused)
}
}
override fun onStopped() {
LogUtils.d(TAG,"播放已停止")
mainHandler.post {
isPlaying = false
isPaused = false
updateButtonStates(isPlaying, isPaused)
}
}
override fun onProgress(currentPosition: Long, totalDuration: Long) {
}
}
10.实现效果:

11.两种方案对比:
设备端推过来的是完整 H.264/H.265 视频流,App 端有两种处理策略:
方案一:逐帧播放(Frame-by-Frame)
| 维度 | 处理方式 |
|---|---|
| 数据解析 | 手动解析 NAL 单元边界(0x00 00 00 01 或 0x00 00 01),按 nal_unit_type 拆分 I/P/B 帧,维护解码顺序(DTS)和显示顺序(PTS) |
| 缓冲策略 | 极小缓冲队列(1-3 帧),"收到即解码,解码即渲染" |
| 时钟控制 | 需自行维护帧率时钟(20fps → 每 50ms 渲染一帧),用 Choreographer 或自定义定时器精确控制 |
| 延迟 | 极低(50-100ms) |
| 抗抖动 | 差,网络波动直接表现为画面卡顿/跳帧 |
| CPU 占用 | 高,需频繁进行 NALU 拆分、解码线程调度 |
| 适用场景 | 远程实时控制、低延迟对讲 |
关键难点:H.265 的 NALU 通常比 H.264 更大,逐帧解析开销更高;且必须处理乱序、丢包、重传后的帧重组。
方案二:流式播放(Stream-based,当前 VLC 方案)
| 维度 | 处理方式 |
|---|---|
| 数据解析 | VLC 内部 Demuxer 自动处理,自动识别 AnnexB / AVCC 格式,无需手动拆帧 |
| 缓冲策略 | 依赖 network-caching + live-caching(当前代码设 800ms),内部维护 ES 流缓冲队列 |
| 时钟控制 | VLC 内部用 PCR/PTS 做音视频同步,自动处理帧率自适应、丢帧/重复帧 |
| 延迟 | 中等(300ms ~ 2s,取决于缓存大小) |
| 抗抖动 | 强,缓冲队列可吸收网络波动 |
| CPU 占用 | 可控,VLC 内部优化了解码流水线 |
| 适用场景 | 监控直播、IoT 视频回传(当前代码场景) |
逐帧播放: 手动拆帧 → 小缓冲 → 精确时钟 → 低延迟但易卡顿 流式播放: VLC自动处理 → 大缓冲 → 内部同步 → 延迟稍高但更稳定

12.起播慢、卡顿、黑屏的根因分析
起播慢(3-5 秒甚至更久)
| 根因 | 说明 |
|---|---|
| 缓存过大 | 当前 --network-caching=800 / --live-caching=800,VLC 会累积 800ms 数据才起播 |
| 等待关键帧 | H.265 编码以 GOP 为单位,播放器必须等到 IDR 帧才能开始解码渲染 |
| 协议握手 | RTSP 需经历 DESCRIBE → SETUP → PLAY,HTTP 需 TLS 握手 + 请求响应 |
| 软解初始化慢 | --avcodec-hw=none 强制软解,H.265 解码器初始化耗时明显高于 H.264 |
| Surface 延迟绑定 | setSurface() 中宽高为 0 时走 post { } 延迟绑定,增加起播时间 |
卡顿 / 黑屏
| 根因 | 说明 |
|---|---|
| 缓存太小 | 如果把缓存调太小,网络抖动时缓冲耗尽,直接断流卡顿 |
| 软解性能不足 | H.265 软解计算量大,低端设备解码速度跟不上码率,导致掉帧 |
| 延迟帧堆积 | 当前未配置 --drop-late-frames,过期帧堆积导致延迟越来越大 |
| 硬解兼容性 | 部分设备 H.265 硬解存在黑屏/花屏问题(代码中 --avcodec-hw=none 就是为了规避) |
| 无事件监听 | 代码中 setEventListener 被注释,无法感知 EncounteredError 等错误状态 |
13.总结:
13.1 优化效果预期:
| 指标 | 优化前 | 优化后(强网) | 优化后(弱网) |
|---|---|---|---|
| 起播时间 | 3-5s | 0.5-1s | 1-2s |
| 端到端延迟 | 1.5-3s | 200-400ms | 800ms-1.5s |
| 卡顿率 | 中 | 低(需稳定网络) | 低(缓冲保护) |
| 黑屏率 | 高(无错误感知) | 低(事件监听+自动降级) | 低(强制软解) |
13.2 小结:
我目前中使用的是H.265格式解码,最开始使用H.264发现效果不是很好,和设备端确认协议使用的也是H.265,所以改成了H.265,设置的帧率是根据设备端传输过来的协议调整的,网络缓冲时间和直播流专属缓冲时间都是可以动态调整,当然还有其他播放器和方案,参数都是根据需要自行调整,以上的实现是我自己的项目总结,欢迎大家积极讨论,ai时代技术且行且珍惜.