Android视频直播播放器简单封装

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 ⚠️ 进度更新未实现

代码中定义了 progressRunnableprogressHandler,但没有实际启动进度更新任务。如果需要进度条功能,需要补充:

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 010x00 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时代技术且行且珍惜.

相关推荐
工会代表3 小时前
安卓手机搭建SOCKS5代理教程
android
雨白3 小时前
Android AOP 切面编程实战:优雅地处理全局断网拦截
android·架构
huluang5 小时前
专业级安卓现场取证利器:拍照取证(PaiZaoXue),补齐行业取证工具核心短板
android·数码相机
奇特認5 小时前
MySQL集群技术 4.高可用之组复制 (MGR)
android
阿pin5 小时前
Android随笔-SurfaceFlinger
android·surfaceflinger
洋不写bug5 小时前
JavaComparable、Comparator、Cloneable接口解析,深拷贝浅拷贝详细解析
android·java·开发语言
小孔龙6 小时前
Android 应用间 UI 刷新与合成
android
XiaoLeisj7 小时前
Kotlin Flow:冷流收集、并行订阅、emit 推送、delay 延迟、collect 全量收集与 collectLatest 的实时数据处理
android·kotlin·android jetpack·协程·flow