Android平台RTMP/RTSP超低延迟直播播放器开发详解——基于SmartMediaKit深度实践

摘要:本文以大牛直播SDK(SmartMediaKit)为基础,结合RTMP协议规范,详细讲解如何在Android平台上实现超低延迟的RTMP/RTSP直播播放器,包括架构设计、核心模块封装、参数调优、录像管理以及回放播放等完整工程实践,所有代码均来自真实Demo工程。


一、前言:直播播放的延迟困局与RTMP的价值

在移动直播、工业监控、智慧教育等实时性要求极高的场景中,端到端延迟是衡量播放器优劣最核心的指标。延迟过高不仅破坏互动体验,在远程监控、应急调度等场景中甚至会带来安全隐患。

主流传输协议的延迟对比如下:

协议 典型延迟 特点
HLS 10~30s 分片式HTTP,CDN友好,高延迟
DASH 3~8s 自适应码率,延迟较高
WebRTC <1s 超低延迟,P2P为主,穿透复杂
RTMP 0.5~3s 基于TCP长连接,低延迟,成熟稳定,大牛直播SDK的可以做到100-200ms延迟
RTSP 0.5~2s 支持UDP/TCP,安防设备标准协议,大牛直播SDK的可以做到100-200ms延迟

RTMP(Real-Time Messaging Protocol)最初由Macromedia设计,后被Adobe收购并公开规范。尽管Adobe官方已停止维护Flash,RTMP在直播推流/拉流领域依然是事实标准,原因在于:

  1. 基于TCP长连接:连接建立后复用同一条TCP链路,消除了HTTP短连接的握手开销;
  2. 分块传输机制(Chunk Stream):将大消息切分为固定大小的Chunk(默认128字节),允许多路消息交错传输,降低大包阻塞风险;
  3. 流控与时序保障:内置流控窗口(Window Acknowledgement Size)和带宽控制(Set Peer Bandwidth)消息,保障弱网稳定性;
  4. Enhanced RTMP扩展:现代SDK已支持HEVC/AV1编码的Enhanced RTMP,突破了原规范只支持H.264/AAC的限制。

本文将结合实际工程代码,以RTMP为例,深入讲解如何基于大牛直播SDK在Android上构建一套工程级的RTMP超低延迟播放器。

Android平台RTMP直播播放器延迟测试


二、RTMP协议核心规范回顾

在进入代码讲解之前,有必要梳理RTMP核心流程,因为SDK的很多参数设计直接对应协议层的行为。

2.1 握手阶段(Handshake)

RTMP握手分三个阶段:

复制代码
Client                    Server
  |------ C0 (1 byte version) ------>|
  |------ C1 (1536 bytes) --------->   |
  |<------ S0+S1+S2 (3073 bytes)---|
  |------ C2 (1536 bytes) --------->   |
  • C0/S0 :版本标识,通常为 0x03(RTMP 3.0);
  • C1/S1:包含时间戳(4字节)+ 随机数(1528字节),用于混淆;
  • C2/S2:对方 C1/S1 的回显,握手完成后进入消息交互阶段。

完成握手后,客户端发送 connect 命令消息,建立逻辑连接(Application Level),之后通过 createStreamplay 消息序列拉流。

2.2 消息分块(Chunking)

RTMP最重要的优化机制是分块传输。每个Chunk由 Chunk Header + Chunk Data 组成:

复制代码
+------------------+   +------------------+
|   Chunk Header   |   |   Chunk Data     |
|  Basic Header    |   |  (payload data)  |
|  Message Header  |   |                  |
|  Extended Time   |   |                  |
+------------------+   +------------------+

Basic Header中的 csid(Chunk Stream ID) 标识消息流,fmt字段(2 bit)决定 Message Header 的长度(完整/相对/仅时间戳/无),极大节省了头部开销。

默认Chunk Size为128字节 ,可通过 Set Chunk Size 控制消息调整,大牛直播SDK在实现时会自动协商最优的Chunk Size,开发者无需手动干预。

2.3 消息类型与时序

播放器收到的核心消息类型:

Message Type ID 类型 用途
1 Set Chunk Size 协商分块大小
3 Acknowledgement 确认窗口字节数
5 Window Ack Size 设置确认窗口
6 Set Peer Bandwidth 设置带宽
8 Audio 音频数据(FLV AudioTag格式)
9 Video 视频数据(FLV VideoTag格式)
18 AMF0 Data 元数据(onMetaData)
20 AMF0 Command 命令消息(connect/play等)

音视频包的时间戳遵循**DTS(解码时间戳)**语义,SDK内部会根据PTS/DTS差值做音视频同步与抖动缓冲处理,这正是"缓冲时间"参数(SmartPlayerSetBuffer)发挥作用的地方。

2.4 Enhanced RTMP 与 HEVC 支持

原始RTMP规范的 VideoTagHeader 中,CodecID = 7 固定表示 AVC/H.264。Enhanced RTMP(由 Veovera 发布的扩展规范)通过修改 VideoTagHeader 的高4位来扩展编码器支持:

复制代码
ExVideoTagHeader:
  - IsExHeader = 1 (bit 7 set)
  - FrameType  (bits 4-6)
  - PacketType (bits 0-3)
  - FourCC     (4 bytes): 'hvc1', 'av01', 'vp09' etc.

大牛直播SDK默认开启 Enhanced RTMP 支持,可通过 DisableEnhancedRTMP 接口关闭(向后兼容老服务端):

java 复制代码
// SDK默认开启 Enhanced RTMP,如需关闭:
libPlayer.DisableEnhancedRTMP(handle, 1);

三、工程架构设计

3.1 整体模块划分

基于实际工程,播放器的完整模块架构如下:

bash 复制代码
┌───────────────────────────────────────────────────────┐
│                   SmartPlayer (Activity)               │
│   UI控制层:播放控制 / 录像 / 快照 / 音量 / 翻转旋转    │
└──────────────────────┬────────────────────────────────┘
                       │
┌──────────────────────▼────────────────────────────────┐
│              LibPlayerWrapper (封装层)                  │
│   状态管理:is_playing / is_pulling / is_recording      │
│   读写锁保护:并发安全的Start/Stop操作                   │
│   生命周期:set / release / try_release / close        │
└──────────────────────┬────────────────────────────────┘
                       │
┌──────────────────────▼────────────────────────────────┐
│             SmartPlayerJniV2 (JNI接口层)               │
│   Native方法:SmartPlayerOpen / StartPlay / StopPlay   │
│   参数配置:Buffer / 硬解码 / 低延迟 / 快速启动         │
│   回调设置:事件回调 / UserData / 音视频数据回调         │
└──────────────────────┬────────────────────────────────┘
                       │
┌──────────────────────▼────────────────────────────────┐
│           libSmartPlayer.so (Native核心)               │
│   协议栈:RTMP/RTSP解析 / 音视频解复用                  │
│   解码器:H.264/H.265 软/硬解码                        │
│   渲染层:OpenGL ES / MediaCodec Surface渲染            │
└───────────────────────────────────────────────────────┘

┌─────────────────┐    ┌──────────────────────────────┐
│ RecorderManager │    │      RecorderPlayback         │
│ 录像文件管理     │    │   本地录像回放(VideoView)    │
│ 异步加载/删除    │    │   精准Seek / 进度控制          │
└─────────────────┘    └──────────────────────────────┘

3.2 核心设计原则

状态机保护 :播放(Playing)、拉流(Pulling)、录像(Recording)三个状态相互独立,通过 volatile 标记 + 读写锁保证多线程安全,避免重复Start/Stop导致的Native层崩溃。

资源生命周期管理LibPlayerWrapper 实现了 AutoCloseable,同时重写了 finalize() 作为兜底保护,确保Native句柄不泄漏。

弱引用Handler防内存泄漏 :UI回调通过静态内部类 MainHandler + WeakReference 持有 Activity,规避了内部类导致的内存泄漏问题。


四、核心封装层:LibPlayerWrapper 详解

LibPlayerWrapper 是整个播放器最核心的封装类,负责管理Native句柄的生命周期、运行状态以及线程安全。

4.1 状态管理与读写锁

java 复制代码
public class LibPlayerWrapper implements AutoCloseable {
    private final ReadWriteLock rw_lock_ = new ReentrantReadWriteLock(true);
    private final java.util.concurrent.locks.Lock write_lock_ = rw_lock_.writeLock();
    private final java.util.concurrent.locks.Lock read_lock_ = rw_lock_.readLock();

    private SmartPlayerJniV2 lib_player_;
    private volatile long native_handle_;

    // 三个独立的运行状态标志
    private volatile boolean is_playing_;
    private volatile boolean is_pulling_;
    private volatile boolean is_recording_;

使用 ReentrantReadWriteLock 的意义在于:状态读取(is_playing等)频率远高于状态写入,读写锁允许多个读操作并发执行,只在状态变更时加写锁,显著降低了锁竞争。

volatile 关键字保证了多线程可见性,但对复合操作(read-check-write)仍需要锁保护。

4.2 安全的 Start/Stop 模式

StartPlayerStopPlayer 为例,展示标准的并发安全模式:

java 复制代码
public boolean StartPlayer(SurfaceView view, Surface surface, NTExternalRender external_render,
                           int render_scale_mode, boolean is_fast_startup,
                           boolean is_hardware_decoder, boolean is_low_latency,
                           boolean is_hw_render_mode) {
    // 1. 前置检查:句柄有效 + 未处于播放状态
    if (!check_native_handle()) return false;
    if (is_playing()) {
        Log.e(TAG, "already playing, native_handle:" + get());
        return false;
    }

    // 2. 配置播放参数
    lib_player_.SmartPlayerSetSurface(get(), view);
    lib_player_.SmartPlayerSetExternalRender(get(), external_render);
    lib_player_.SmartPlayerSetRenderScaleMode(get(), render_scale_mode);
    lib_player_.SmartPlayerSetFastStartup(get(), is_fast_startup ? 1 : 0);
    lib_player_.SmartPlayerSetAudioOutputType(get(), 1);

    if (is_hardware_decoder) {
        int isSupportH264HwDecoder = lib_player_.SetSmartPlayerVideoHWDecoder(get(), 1);
        int isSupportHevcHwDecoder = lib_player_.SetSmartPlayerVideoHevcHWDecoder(get(), 1);
        Log.i(TAG, "isSupportH264HwDecoder: " + isSupportH264HwDecoder
                + ", isSupportHevcHwDecoder: " + isSupportHevcHwDecoder);
        lib_player_.SmartPlayerSetHWRenderMode(get(), is_hw_render_mode ? 1 : 0);
    }

    lib_player_.SmartPlayerSetLowLatencyMode(get(), is_low_latency ? 1 : 0);

    // 3. 调用Native启动
    int ret = lib_player_.SmartPlayerStartPlay(get());
    if (ret != OK) {
        Log.e(TAG, "call SmartPlayerStartPlay failed, native_handle:" + get() + ", ret:" + ret);
        return false;
    }

    // 4. 状态变更加写锁
    write_lock_.lock();
    try {
        this.is_playing_ = true;
    } finally {
        write_lock_.unlock();
    }

    Log.i(TAG, "call SmartPlayerStartPlay OK, native_handle:" + get());
    return true;
}

Stop操作采用"先置标志再调Native"的方式,通过本地 is_need_call 变量确保Native的 Stop 只会被调用一次:

java 复制代码
public boolean StopPlayer() {
    if (!check_native_handle()) return false;
    if (!is_playing()) {
        Log.w(TAG, "it's not playing, native_handle:" + get());
        return false;
    }

    boolean is_need_call = false;
    write_lock_.lock();
    try {
        if (this.is_playing_) {
            this.is_playing_ = false;
            is_need_call = true;    // 只有真正持有"正在播放"状态才执行Stop
        }
    } finally {
        write_lock_.unlock();
    }

    if (is_need_call)
        lib_player_.SmartPlayerStopPlay(get());

    return true;
}

4.3 高频路径的无阻塞设计

PostVideoPacketByteBuffer 是外部数据注入的高频接口(用于自定义Live Source场景),采用 tryLock() 而非 lock(),保证在锁被写操作持有时立即返回,不阻塞数据投递线程:

java 复制代码
public boolean PostVideoPacketByteBuffer(int codec_id,
        java.nio.ByteBuffer packet, int offset, int size, long timestamp_ms,
        int is_timestamp_discontinuity, int is_key,
        byte[] extra_data, int extra_data_size, int width, int height) {

    if (!check_native_handle() || !is_running()) return false;

    // tryLock:获取读锁失败立即返回,不阻塞
    if (!read_lock_.tryLock()) return false;

    try {
        if (!check_native_handle() || !is_running()) return false;

        return OK == lib_player_.PostVideoPacketByteBuffer(get(), codec_id,
                packet, offset, size, timestamp_ms, is_timestamp_discontinuity,
                is_key, extra_data, extra_data_size, width, height);
    } catch (Exception e) {
        Log.e(TAG, "PostVideoPacketByteBuffer Exception:", e);
        return false;
    } finally {
        read_lock_.unlock();
    }
}

4.4 资源释放的多重保障

release() 是主动释放路径,try_release() 提供非阻塞的尝试释放(适合在不确定是否运行的场景),finalize() 作为最后的兜底:

java 复制代码
@Override
public void close() {
    release();
}

public void release() {
    if (empty()) return;

    // 先停止所有运行中的任务
    if (is_playing())   StopPlayer();
    if (is_pulling())   StopPullStream();
    if (is_recording()) StopRecorder();

    long handle;
    write_lock_.lock();
    try {
        handle = this.native_handle_;
        this.native_handle_ = 0;      // 先清零句柄,防止重入
        clear_all_running_flags();
    } finally {
        write_lock_.unlock();
    }

    if (lib_player_ != null && handle != 0)
        lib_player_.SmartPlayerClose(handle);
}

五、播放器初始化与参数配置

5.1 完整初始化流程

SmartPlayer.java 中,initPlayer() 负责创建Native实例并完成全量参数配置:

java 复制代码
private boolean initPlayer() {
    // Step 1: 创建Native播放实例,获取句柄
    long handle = libPlayer.SmartPlayerOpen(appContext);
    if (handle == 0) {
        Log.e(TAG, "SmartPlayerOpen 失败");
        showToast("初始化播放器失败");
        return false;
    }

    // Step 2: 绑定到 Wrapper,后续操作通过 Wrapper 进行
    playerWrapper.set(libPlayer, handle);

    // Step 3: 注册事件回调
    libPlayer.SetSmartPlayerEventCallbackV2(handle, new PlayerEventCallback());

    // Step 4: 网络与协议参数配置
    libPlayer.SmartPlayerSetBuffer(handle, playBuffer);          // 缓冲时间(ms)
    libPlayer.SmartPlayerSetReportDownloadSpeed(handle, 1, 2);   // 每2秒上报一次下载速度
    libPlayer.SmartPlayerSetRTSPTimeout(handle, 10);             // RTSP超时10秒
    libPlayer.SmartPlayerSetRTSPAutoSwitchTcpUdp(handle, 1);     // 开启TCP/UDP自动切换

    // Step 5: 功能开关
    libPlayer.SmartPlayerSaveImageFlag(handle, 1);               // 启用快照功能

    // Step 6: 设置播放URL
    setPlaybackUrl(playbackUrl);
    setEncryptionKey();

    return true;
}

5.2 关键参数详解

缓冲时间与延迟的平衡
java 复制代码
libPlayer.SmartPlayerSetBuffer(handle, playBuffer);
// playBuffer: 0~5000ms,默认200ms

缓冲时间直接决定了播放延迟和流畅性的平衡:

  • 0ms(超低延迟模式):几乎无缓冲,网络抖动时容易卡顿,适合局域网/专网环境;
  • 100~300ms:适合质量较好的公网直播,兼顾延迟和流畅;
  • 500ms以上:抗网络抖动能力强,但延迟相应增加。

低延迟模式下,SDK会配合 SmartPlayerSetLowLatencyMode 进一步优化:

java 复制代码
lib_player_.SmartPlayerSetLowLatencyMode(get(), is_low_latency ? 1 : 0);
if (isLowLatency) playBuffer = 0;  // 低延迟模式下缓冲设为0
快速启动(秒开)
java 复制代码
lib_player_.SmartPlayerSetFastStartup(get(), is_fast_startup ? 1 : 0);

Fast Startup(秒开)针对推流端缓存了GOP(Group of Pictures)的场景。播放端连接后可以立即获取到最近一个完整IDR帧开始解码,无需等待下一个关键帧,显著降低首帧延迟。

在RTMP协议层,这依赖服务端(如SRS/NGINX-RTMP)开启 GOP Cache 功能,客户端 connect + play 后服务端立即推送缓存的GOP数据。

硬件解码与渲染模式
java 复制代码
if (is_hardware_decoder) {
    // H.264 硬解码
    int isSupportH264HwDecoder = lib_player_.SetSmartPlayerVideoHWDecoder(get(), 1);
    // H.265(HEVC) 硬解码
    int isSupportHevcHwDecoder = lib_player_.SetSmartPlayerVideoHevcHWDecoder(get(), 1);

    Log.i(TAG, "isSupportH264HwDecoder: " + isSupportH264HwDecoder
            + ", isSupportHevcHwDecoder: " + isSupportHevcHwDecoder);

    // HW Render Mode: MediaCodec直接渲染到Surface,兼容性更好
    lib_player_.SmartPlayerSetHWRenderMode(get(), is_hw_render_mode ? 1 : 0);
}

硬解码模式(MediaCodec)与软解码(FFmpeg)的选择:

对比维度 软解码 硬解码
CPU占用
兼容性 极好 依赖芯片厂商实现
功耗
4K支持 受CPU限制 较好
YUV回调 支持 HW Render模式下不支持

HW Render ModeSmartPlayerSetHWRenderMode)是硬解码下的特殊渲染模式,MediaCodec直接将解码输出写入Surface,绕过了SDK的渲染管线,兼容性和效率更佳,但代价是无法获取YUV/RGB回调帧数据(快照功能在此模式下不可用)。

5.3 RTSP 特殊配置

RTSP协议在安防摄像头场景中大量使用,有以下特殊配置需要注意:

java 复制代码
// 设置超时(单位秒,避免无法连接时长时间阻塞)
libPlayer.SmartPlayerSetRTSPTimeout(handle, 10);

// TCP/UDP自动切换:部分摄像头只支持其中一种
libPlayer.SmartPlayerSetRTSPAutoSwitchTcpUdp(handle, 1);

当RTSP URL包含用户名密码(如 rtsp://admin:password@192.168.0.1/stream)时,SDK会自动解析。若需独立设置认证信息(URL不含密码),使用专用接口:

java 复制代码
// URL不含认证信息时,单独设置
libPlayer.SetRTSPAuthenticationInfo(playerWrapper.get(), username, password);

代码中还提供了 parseAndSetRtspUrl() 方法,展示了如何手动解析RTSP URL中的认证信息并分离URL(适合含特殊字符需URL解码的场景):

java 复制代码
private void parseAndSetRtspUrl(String url) {
    final String rtspPrefix = "rtsp://";
    String afterPrefix = url.substring(rtspPrefix.length());

    int slashPos = afterPrefix.indexOf('/');
    int atPos = slashPos > -1
            ? afterPrefix.lastIndexOf('@', slashPos)
            : afterPrefix.lastIndexOf('@');

    if (atPos <= 0) {
        // 无认证信息,直接设置
        libPlayer.SmartPlayerSetUrl(playerWrapper.get(), url);
        return;
    }

    String credentials = afterPrefix.substring(0, atPos);
    String cleanUrl = rtspPrefix + afterPrefix.substring(atPos + 1);

    // 解析用户名密码,并处理URL编码(如%40 -> @)
    try {
        if (username != null && !username.isEmpty())
            username = java.net.URLDecoder.decode(username, "UTF-8");
        if (password != null && !password.isEmpty())
            password = java.net.URLDecoder.decode(password, "UTF-8");
    } catch (Exception e) {
        Log.e(TAG, "URL解码失败", e);
    }

    libPlayer.SmartPlayerSetUrl(playerWrapper.get(), cleanUrl);
    libPlayer.SetRTSPAuthenticationInfo(playerWrapper.get(), username, password);
}

六、视频渲染:SurfaceView 与 GLSurfaceView 的选择

6.1 渲染器创建

java 复制代码
private void createSurfaceView() {
    Log.i(TAG, "createSurfaceView, manufacturer: " + Build.MANUFACTURER);

    // isHardwareRenderMode=false -> GLSurfaceView(支持YUV回调、快照、色彩调节)
    // isHardwareRenderMode=true  -> SurfaceView(MediaCodec直渲,兼容性更好)
    if (isHardwareRenderMode) {
        surfaceView = NTRenderer.CreateRenderer(this, false);
    } else {
        surfaceView = NTRenderer.CreateRenderer(this, true);
    }

    if (isHardwareRenderMode) {
        // HW Render 模式需要监听Surface生命周期,在重建时更新Surface
        SurfaceHolder holder = surfaceView.getHolder();
        if (holder != null) holder.addCallback(this);
    }

    videoContainer.addView(surfaceView, 0);
}

HW Render 模式下,当 Activity 旋转或回到前台时,Surface 可能重建,需要及时通知SDK更新:

java 复制代码
@Override
public void surfaceCreated(SurfaceHolder holder) {
    if (isHardwareDecoder && isHardwareRenderMode && playerWrapper.is_playing()) {
        libPlayer.SmartPlayerUpdateHWRenderSurface(playerWrapper.get());
    }
}

6.2 外部渲染回调(External Render)

SDK支持将解码后的原始帧数据通过回调交给应用层处理,适合AI算法、美颜、水印等场景。支持两种格式:

RGBA格式回调(RGBAExternalRender)

java 复制代码
private static class RGBAExternalRender implements NTExternalRender {
    @Override
    public int getNTFrameFormat() {
        return NT_FRAME_FORMAT_RGBA;  // 返回期望的格式
    }

    @Override
    public void onNTFrameSizeChanged(int width, int height) {
        // 分辨率变化时重新分配Buffer
        row_bytes_ = width * 4;
        rgba_buffer_ = ByteBuffer.allocateDirect(row_bytes_ * height);
    }

    @Override
    public ByteBuffer getNTPlaneByteBuffer(int index) {
        if (index == 0) return rgba_buffer_;
        return null;
    }

    @Override
    public int getNTPlanePerRowBytes(int index) {
        if (index == 0) return row_bytes_;
        return 0;
    }

    public void onNTRenderFrame(int width, int height, long timestamp) {
        rgba_buffer_.rewind();
        // 此处可以对RGBA数据进行处理,如AI推理、截图等
    }
}

I420(YUV420P)格式回调(I420ExternalRender)

java 复制代码
private static class I420ExternalRender implements NTExternalRender {
    @Override
    public int getNTFrameFormat() {
        return NT_FRAME_FORMAT_I420;
    }

    @Override
    public void onNTFrameSizeChanged(int width, int height) {
        y_row_bytes_ = width;
        u_row_bytes_ = (width + 1) / 2;
        v_row_bytes_ = (width + 1) / 2;

        y_buffer_ = ByteBuffer.allocateDirect(y_row_bytes_ * height);
        u_buffer_ = ByteBuffer.allocateDirect(u_row_bytes_ * ((height + 1) / 2));
        v_buffer_ = ByteBuffer.allocateDirect(v_row_bytes_ * ((height + 1) / 2));
    }

    @Override
    public ByteBuffer getNTPlaneByteBuffer(int index) {
        switch (index) {
            case 0: return y_buffer_;  // Y平面
            case 1: return u_buffer_;  // U平面
            case 2: return v_buffer_;  // V平面
            default: return null;
        }
    }

    public void onNTRenderFrame(int width, int height, long timestamp) {
        y_buffer_.rewind();
        u_buffer_.rewind();
        v_buffer_.rewind();
        // 处理YUV数据,如保存JPEG(需要先转NV21格式)
    }
}

启用外部渲染时,在 StartPlayer 中传入实例:

java 复制代码
// 使用外部渲染(I420格式回调)
boolean success = playerWrapper.StartPlayer(surfaceView, null,
        new I420ExternalRender(imageSavePath), 1,
        isFastStartup, isHardwareDecoder, isLowLatency, isHardwareRenderMode);

七、事件回调系统

7.1 播放事件回调实现

事件回调通过 NTSmartEventCallbackV2 接口异步通知应用层:

java 复制代码
private class PlayerEventCallback implements NTSmartEventCallbackV2 {
    @Override
    public void onNTSmartEventCallbackV2(long handle, int id, long param1,
            long param2, String param3, String param4, Object param5) {
        String event = formatEventMessage(id, param1, param2, param3);
        if (!event.isEmpty()) {
            Log.i(TAG, event);
            // 通过 WeakReference Handler 更新UI,防止内存泄漏
            handler.obtainMessage(MSG_PLAYER_EVENT, event).sendToTarget();
        }
    }
}

关键事件列表及其含义:

java 复制代码
private String formatEventMessage(int id, long param1, long param2, String param3) {
    switch (id) {
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_STARTED:
            return "开始播放";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CONNECTING:
            return "连接中...";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CONNECTION_FAILED:
            return "连接失败";                       // 网络不通或地址错误
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CONNECTED:
            return "已连接";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_DISCONNECTED:
            return "连接断开";                       // 网络中断,SDK内部会自动重连
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_STOP:
            return "已停止";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_RESOLUTION_INFO:
            return "分辨率: " + param1 + "×" + param2;  // 视频分辨率变化
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_NO_MEDIADATA_RECEIVED:
            return "无数据,检查URL";                  // URL正确但无数据推入
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_SWITCH_URL:
            return "切换URL中...";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CAPTURE_IMAGE:
            return "快照: " + (param1 == 0 ? "成功" : "失败");
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_RECORDER_START_NEW_FILE:
            return "录像: " + param3;               // param3为新文件路径
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_ONE_RECORDER_FILE_FINISHED:
            return "录像完成";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_DOWNLOAD_SPEED:
            return "速度: " + (param1 * 8 / 1000) + " kbps";  // param1单位Byte/s
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_RTSP_STATUS_CODE:
            return "RTSP错误: " + param1;           // RTSP错误状态码
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_NEED_KEY:
            return "需要解密Key";
        case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_KEY_ERROR:
            return "Key错误";
        default:
            return "";
    }
}

7.2 SEI UserData 回调

RTMP中可以在H.264的SEI(Supplemental Enhancement Information)NAL单元中携带自定义数据,常用于字幕、弹幕时间戳同步等场景。SDK通过 NTUserDataCallback 接口回调:

java 复制代码
private class UserDataCallback implements NTUserDataCallback {
    private int bufferSize = 0;
    private ByteBuffer buffer;

    @Override
    public ByteBuffer getUserDataByteBuffer(int size) {
        // SDK会先调用此方法获取Buffer,避免重复分配
        if (size < 1) return null;
        if (size <= bufferSize && buffer != null) return buffer;
        bufferSize = size + 512;
        buffer = ByteBuffer.allocateDirect(bufferSize);
        return buffer;
    }

    @Override
    public void onUserDataCallback(int ret, int dataType, int size,
            long timestamp, long reserve1, long reserve2) {
        // dataType=2 时为字符串类型数据
        if (dataType == 2 && buffer != null) {
            buffer.rewind();
            byte[] data = new byte[size];
            buffer.get(data);
            handler.obtainMessage(MSG_USER_DATA, new String(data)).sendToTarget();
        }
    }
}

7.3 防内存泄漏的 Handler 实现

java 复制代码
private static class MainHandler extends Handler {
    private final WeakReference<SmartPlayer> activityRef;

    MainHandler(SmartPlayer activity) {
        this.activityRef = new WeakReference<>(activity);
    }

    @Override
    public void handleMessage(Message msg) {
        SmartPlayer activity = activityRef.get();
        if (activity == null) return;  // Activity已被回收,安全退出

        if (msg.what == MSG_PLAYER_EVENT) {
            activity.txtEventMsg.setText("Event: " + msg.obj);
        } else if (msg.what == MSG_USER_DATA) {
            activity.txtUserDataMsg.setText("数据: " + msg.obj);
        }
    }
}

Android平台Unity3D下RTMP播放器延迟测试


八、录像功能实现

8.1 录像启动与停止

录像功能可以与播放功能同时启用,也可以独立启动(只录像不播放),完全解耦:

java 复制代码
private void startRecording() {
    if (playbackUrl == null || playbackUrl.isEmpty()) {
        showToast("请先输入播放URL");
        return;
    }

    // 若播放器尚未初始化(只录不播场景),先初始化
    if (playerWrapper.empty() && !initPlayer()) return;

    // 配置录像参数
    if (recordDir != null && !recordDir.isEmpty()) {
        // 创建录像目录(如不存在)
        if (libPlayer.SmartPlayerCreateFileDirectory(recordDir) == 0) {
            playerWrapper.SetRecorderDirectory(recordDir);
            playerWrapper.SetRecorderFileMaxSize(200);  // 单文件最大200MB,自动切片
        }
    }

    // 启动录像,true表示非AAC音频自动转码为AAC
    if (!playerWrapper.StartRecorder(true)) {
        Log.e(TAG, "StartRecorder 失败");
        showToast("录像失败");
        return;
    }

    updateButtonStates();
}

录像文件以 .mp4 格式保存,达到 SetRecorderFileMaxSize 设置的大小后自动切片为新文件,并通过事件回调 EVENT_DANIULIVE_ERC_PLAYER_RECORDER_START_NEW_FILE 通知应用层新文件路径。

停止时,先判断是否有其他功能在运行,若无则释放播放器实例:

java 复制代码
private void stopRecording() {
    if (!playerWrapper.is_recording()) return;

    playerWrapper.StopRecorder();

    // 所有功能都停止后才释放实例
    if (!playerWrapper.is_running()) {
        playerWrapper.release();
    }

    updateButtonStates();
}

九、录像管理模块(RecorderManager)

RecorderManager 负责展示录像文件列表,支持点击播放和批量删除。其核心设计亮点是异步加载 ,避免在主线程中执行 MediaMetadataRetriever 这类耗时操作。

9.1 异步文件加载

java 复制代码
private void refreshFileList() {
    // 1. 显示加载进度条
    mProgressBar.setVisibility(View.VISIBLE);
    recFileListView.setVisibility(View.INVISIBLE);
    mTvNoFiles.setVisibility(View.GONE);

    // 2. 启动后台线程执行耗时的文件读取+元数据解析
    new Thread(new Runnable() {
        @Override
        public void run() {
            final ArrayList<ArrayList<String>> resultList = loadFileListInBackground();

            // 3. 切换回主线程更新UI
            runOnUiThread(new Runnable() {
                @Override
                public void run() {
                    if (isFinishing()) return;  // 防止Activity已退出后更新UI崩溃
                    fileList = resultList;
                    updateListView();
                    mProgressBar.setVisibility(View.GONE);
                }
            });
        }
    }).start();
}

9.2 文件元数据读取(后台线程)

java 复制代码
private ArrayList<ArrayList<String>> loadFileListInBackground() {
    ArrayList<ArrayList<String>> tempList = new ArrayList<>();
    // ...目录校验代码...

    MediaMetadataRetriever retriever = new MediaMetadataRetriever();
    try {
        for (File recFile : files) {
            if (!recFile.getName().endsWith(".mp4")) continue;

            String sizeStr = getFileSizeString(recFile.length());

            // 读取MP4时长(耗时操作,必须在后台线程执行)
            String durationStr = "--:--";
            try {
                retriever.setDataSource(recFile.getAbsolutePath());
                String time = retriever.extractMetadata(
                        MediaMetadataRetriever.METADATA_KEY_DURATION);
                if (time != null) {
                    durationStr = formatDuration(Long.parseLong(time));
                }
            } catch (Exception e) { /* 读取失败忽略,显示"--:--" */ }

            String metaInfo = "大小: " + sizeStr + "  |  时长: " + durationStr;

            ArrayList<String> item = new ArrayList<>();
            item.add(recFile.getName());
            item.add(recFile.getAbsolutePath());
            item.add(metaInfo);
            tempList.add(item);
        }

        // 按文件名(时间戳命名)倒序排列,最新文件在最上方
        Collections.sort(tempList, new Comparator<ArrayList<String>>() {
            @Override
            public int compare(ArrayList<String> o1, ArrayList<String> o2) {
                return o2.get(0).compareTo(o1.get(0));
            }
        });
    } finally {
        try { retriever.release(); } catch (Exception ex) {}
    }

    return tempList;
}

十、本地录像回放(RecorderPlayback)

RecorderPlayback 使用系统 VideoView 实现本地MP4文件回放,重点实现了精准 Seek 和完整的播放控制UI。

10.1 精准 Seek 实现

Android 8.0(API 26)引入了 MediaPlayer.SEEK_CLOSEST,可定位到最近的任意帧而不只是关键帧,避免拖动进度条后画面跳跃的问题:

java 复制代码
seekBarVideo.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
    @Override
    public void onStartTrackingTouch(SeekBar seekBar) {
        // 拖动开始时停止自动更新进度,防止进度条跳动
        mHandler.removeMessages(MSG_UPDATE_PROGRESS);
    }

    @Override
    public void onStopTrackingTouch(SeekBar seekBar) {
        int targetPosition = seekBar.getProgress();

        if (playVideoView != null) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && mMediaPlayer != null) {
                try {
                    // Android 8.0+ 精准Seek:定位到最近帧
                    mMediaPlayer.seekTo(targetPosition, MediaPlayer.SEEK_CLOSEST);
                } catch (Exception e) {
                    playVideoView.seekTo(targetPosition);  // 降级到普通Seek
                }
            } else {
                // 旧版本:只能Seek到关键帧
                playVideoView.seekTo(targetPosition);
            }

            if (!isPaused && !playVideoView.isPlaying()) {
                playVideoView.start();
            }
        }
        mHandler.sendEmptyMessage(MSG_UPDATE_PROGRESS);  // 恢复进度更新
    }
});

mMediaPlayer 引用在 onPrepared 回调中获取:

java 复制代码
playVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
    @Override
    public void onPrepared(MediaPlayer mp) {
        mMediaPlayer = mp;  // 保存引用,用于精准Seek

        int duration = playVideoView.getDuration();
        seekBarVideo.setMax(duration);
        tvTotalTime.setText(formatTime(duration));

        playVideoView.start();
        isPaused = false;
        updatePlayPauseIcon();
        mHandler.sendEmptyMessage(MSG_UPDATE_PROGRESS);
    }
});

10.2 进度条自动更新

java 复制代码
private static final int MSG_UPDATE_PROGRESS = 1;
private Handler mHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        if (msg.what == MSG_UPDATE_PROGRESS) {
            updateProgress();
            // 每500ms轮询一次进度
            mHandler.sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, 500);
        }
    }
};

private void updateProgress() {
    if (playVideoView != null && playVideoView.isPlaying()) {
        int currentPosition = playVideoView.getCurrentPosition();
        seekBarVideo.setProgress(currentPosition);
        tvCurrentTime.setText(formatTime(currentPosition));
    }
}

10.3 文件删除与资源清理

删除文件时需要先停止播放并清理资源,防止文件被占用导致删除失败:

java 复制代码
private void deleteCurrentFile() {
    try {
        File file = new File(recorderFilePath);
        if (file.exists() && file.isFile()) {
            if (file.delete()) {
                if (playVideoView != null && playVideoView.isPlaying()) {
                    playVideoView.stopPlayback();
                }

                // 清理 Handler 和 MediaPlayer 引用
                mHandler.removeCallbacksAndMessages(null);
                mMediaPlayer = null;

                // 通知 RecorderManager 刷新列表
                setResult(RESULT_OK);
                finish();
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

RecorderManager 通过 onActivityResult 监听删除结果并刷新列表:

java 复制代码
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQUEST_CODE_PLAYBACK && resultCode == RESULT_OK) {
        refreshFileList();  // 异步重新加载文件列表
    }
}

十一、横竖屏自适应与全屏体验

11.1 横竖屏布局切换

java 复制代码
@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    updateLayoutForOrientation(newConfig.orientation);
}

private void updateLayoutForOrientation(int orientation) {
    if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
        // 横屏:隐藏控制面板,视频全屏
        controlPanel.setVisibility(View.GONE);
        btnBackPortrait.setVisibility(View.VISIBLE);

        LinearLayout.LayoutParams params =
                (LinearLayout.LayoutParams) videoContainer.getLayoutParams();
        params.weight = 0;
        params.height = LinearLayout.LayoutParams.MATCH_PARENT;
        videoContainer.setLayoutParams(params);
    } else {
        // 竖屏:显示控制面板,视频占50%高度
        controlPanel.setVisibility(View.VISIBLE);
        btnBackPortrait.setVisibility(View.GONE);

        LinearLayout.LayoutParams params =
                (LinearLayout.LayoutParams) videoContainer.getLayoutParams();
        params.weight = 1;
        params.height = 0;
        videoContainer.setLayoutParams(params);
    }

    hideSystemUI();
}

11.2 沉浸式全屏

java 复制代码
private void hideSystemUI() {
    if (Build.VERSION.SDK_INT >= 19) {
        getWindow().getDecorView().setSystemUiVisibility(
                View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                | View.SYSTEM_UI_FLAG_FULLSCREEN
                | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);  // 滑入后自动重新隐藏
    }
}

十二、实时功能:快照、翻转、旋转与音量控制

12.1 快照

快照功能支持 JPEG 和 PNG 格式,通过事件回调异步通知结果:

java 复制代码
private void captureImage() {
    if (!playerWrapper.is_playing()) {
        showToast("请先开始播放");
        return;
    }
    if (imageDateFormat == null)
        imageDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.getDefault());

    // 格式:0=JPEG,1=PNG;quality仅对JPEG有效
    String imagePath = imageSavePath + "/" + imageDateFormat.format(new Date()) + ".jpeg";
    boolean success = playerWrapper.CaptureImage(0, 100, imagePath, "snapshot");
    Log.i(TAG, "快照: " + imagePath + ", success: " + success);
    showToast(success ? "快照已保存" : "快照失败");
}

注意:快照功能在 HW Render Mode 下不可用,因为MediaCodec直渲绕过了SDK的帧缓冲。

12.2 翻转与旋转

java 复制代码
private void toggleFlipVertical() {
    isFlipVertical = !isFlipVertical;
    btnFlipVertical.setText(isFlipVertical ? "取消垂直" : "垂直翻转");
    if (!playerWrapper.empty())
        libPlayer.SmartPlayerSetFlipVertical(playerWrapper.get(), isFlipVertical ? 1 : 0);
}

private void toggleRotation() {
    // 每次点击顺时针旋转90度:0 -> 90 -> 180 -> 270 -> 0
    rotateDegrees = (rotateDegrees + 90) % 360;
    btnRotation.setText("旋转" + rotateDegrees + "°");
    if (!playerWrapper.empty())
        libPlayer.SmartPlayerSetRotation(playerWrapper.get(), rotateDegrees);
}

12.3 动态切换播放URL

无需停止重启,直接切换到新的RTMP/RTSP流:

java 复制代码
private void toggleSwitchUrl() {
    if (playerWrapper.empty()) {
        showToast("请先开始播放");
        return;
    }
    switchUrlFlag = !switchUrlFlag;
    libPlayer.SmartPlayerSwitchPlaybackUrl(playerWrapper.get(),
            switchUrlFlag ? switchUrl : playbackUrl);
}

在RTMP层,SDK会在新连接建立成功后再关闭旧连接,保证切换过程中播放不中断(无缝切换)。

安卓RTMP播放器同时播放4路RTMP流延迟测试


十三、性能优化与常见问题

13.1 延迟优化清单

优化项 建议设置 效果
缓冲时间 0~100ms(局域网) 降低播放延迟
低延迟模式 开启 SDK内部优化缓冲策略
快速启动 服务端开启GOP Cache 降低首帧延迟
硬件解码 中高端设备建议开启 降低CPU占用,减少解码耗时
RTSP协议 局域网摄像头建议TCP 避免UDP丢包重传延迟

13.2 常见问题排查

问题1:连接成功但无画面(NO_MEDIADATA_RECEIVED)

  • 推流端未开始推流
  • 服务端URL路径错误(注意大小写)
  • 防火墙端口未开放(RTMP默认1935,RTSP默认554)

问题2:画面有声音无视频或有视频无声音

  • 检查编码格式:原始RTMP只支持H.264视频和AAC/MP3音频
  • Enhanced RTMP可支持H.265,但需要服务端也支持

问题3:硬解码失败,自动降级软解码

  • 部分国产SoC的H.265硬解码存在兼容性问题
  • 可通过事件回调中的 isSupportHevcHwDecoder 返回值判断是否支持

问题4:录像文件无法播放

  • 确认录像目录有写权限(Android 10+ 注意分区存储限制)
  • 检查 SmartPlayerSetRecorderAudioTranscodeAAC 是否正确设置(音频格式兼容性)

十四、完整流程总结

bash 复制代码
应用启动
    │
    ▼
initPlayer()
    ├── SmartPlayerOpen()           获取Native句柄
    ├── SetSmartPlayerEventCallbackV2()  注册事件回调
    ├── SmartPlayerSetBuffer()      配置缓冲
    ├── SmartPlayerSetRTSPTimeout() 配置超时
    └── SmartPlayerSetUrl()         设置播放URL
    │
    ▼
startPlayback() / startRecording()
    ├── LibPlayerWrapper.StartPlayer()
    │       ├── SmartPlayerSetSurface()
    │       ├── SetSmartPlayerVideoHWDecoder()  (可选)
    │       ├── SmartPlayerSetLowLatencyMode()
    │       └── SmartPlayerStartPlay()          → 开始拉流解码渲染
    │
    └── LibPlayerWrapper.StartRecorder()
            ├── SmartPlayerSetRecorderDirectory()
            ├── SmartPlayerSetRecorderFileMaxSize()
            └── SmartPlayerStartRecorder()      → 开始写MP4文件
    │
    ▼
运行中实时操作
    ├── SetMute() / SmartPlayerSetAudioVolume()
    ├── SmartPlayerSetFlipVertical/Horizontal()
    ├── SmartPlayerSetRotation()
    ├── CaptureImage()
    └── SmartPlayerSwitchPlaybackUrl()
    │
    ▼
stopPlayback() / stopRecording()
    └── LibPlayerWrapper.release()
            └── SmartPlayerClose()              释放Native资源

十五、总结

本文以大牛直播SDK的真实Demo代码为基础,结合RTMP协议规范,从以下维度完整呈现了一个工程级Android RTMP播放器的实现:

  • 协议层:RTMP握手、Chunk机制、消息类型、Enhanced RTMP扩展;
  • 架构层LibPlayerWrapper 的状态机设计、读写锁并发控制、资源安全释放;
  • 参数调优:缓冲时间、低延迟模式、快速启动、硬件解码的选择策略;
  • 渲染层:GLSurfaceView vs MediaCodec直渲,RGBA/I420外部渲染回调;
  • 录像系统:录像启动/停止、自动切片、加密流处理;
  • 录像管理:异步文件加载、元数据读取、删除与列表刷新;
  • 回放功能:VideoView+精准Seek(API 26+)、进度条控制;
  • 体验优化:横竖屏自适应、沉浸式全屏、实时翻转旋转快照。

大牛直播SDK将复杂的协议实现、解码、渲染封装在Native层,通过清晰的JNI接口暴露给Android层,配合 LibPlayerWrapper 的状态管理封装,使得开发者可以以极少的代码量实现功能完整、性能优秀的直播播放器。

📎 CSDN官方博客:音视频牛哥-CSDN博客

相关推荐
Mixtral1 小时前
会议纪要AI工具深度测评:4款工具准确率与效率对比
人工智能
龙亘川1 小时前
大模型驱动智能运维:四大核心方向与技术实践深度解析
人工智能·机器人·智能化工具链 + 平台化支撑
莫寒清1 小时前
Apache Tika
java·人工智能·spring·apache·知识图谱
Youngchatgpt1 小时前
如何在 Excel 中使用 ChatGPT:自动化任务和编写公式
人工智能·chatgpt·自动化·excel
星爷AG I1 小时前
12-12 内隐人格观(AGI基础理论)
人工智能
麻瓜生活睁不开眼1 小时前
Android 14 开机自启动第三方 APK 全流程踩坑与最终解决方案(含 RescueParty 避坑)
android·java·深度学习
qq_416276421 小时前
通用音频表征的对比学习
学习·音视频
掘金安东尼1 小时前
Cursor:长执行模式,验证大模型“7*24h自动编程”的可能性
人工智能