写点什么

从零开发一款 Android Rtmp 播放器

用户头像
轻口味
关注
发布于: 2021 年 10 月 07 日
从零开发一款Android Rtmp播放器

1. 背景介绍

15 年移动端直播应用火起来的时候,主要的直播协议是 RTMP,多媒体服务以 Adobe 的 AMS、wowza、Red5、crtmpserver、nginx rtmp module 等,后面过长 RTMP 服务 SRS 开始流行。Android 端播放器主要以开始以 EXOPlayer 播放 HLS,但是 HLS 有延迟高的确定,随后大家主要使用开源的 ijkplyer,ijkplayer 通过 ffmpeg 进行拉流及解码,支持多种音视频编码,还有跨平台,API 与系统播放器保持一致等特征,后续各大厂提供的直播 SDK 均有 ijkplayer 身影。


当时在做一款游戏 SDK,SDK 主要提供了游戏画面声音采集、音视频编解码、直播推流、直播拉流播放等,SDK 为游戏提供直播功能,播放也是采用了现成的 ijkplayer 播放器。但是 SDK 推广的时候遇到了问题,游戏厂家嫌弃 SDK 体积大(其实总共也就 3Mb 左右),我们需要一款体积小,性能高的播放器,由于开发成本的原因一直没有时间做,后面换工作期间,花了一个月时间把这款播放器开发出来,并开源了出来。oarplayer 是基于 MediaCodec 与 srs-librtmp,完全不依赖 ffmpeg,纯 C 语言实现的播放器。本文主要介绍这款播放器的实现思路。

2. 整体架构设计

播放器整体播放流程如下:

通过 srs-librtmp 拉取直播流,通过 package type 分离音视频流,将 package 数据缓存到 package 队列,解码线程不断从 package 队列读取 package 交由解码器解码,解码器将解码后的 frame 存储到 frame 队列,opensles 播放线程与 opengles 渲染线程从 frame 队列读取 frame 播放与渲染,这里还涉及到音视频同步。


播放器主要涉及了以下线程:


  1. rtmp 拉流线程;

  2. 音频解码线程;

  3. 视频解码线程;

  4. 音频播放线程;

  5. 视频渲染线程;

  6. JNI 回调线程。

3. API 接口设计

通过以下几步即可完成 rtmp 播放:


  1. 实例化 OARPlayer:OARPlayer player = new OARPlayer();

  2. 设置视频源:player.setDataSource(rtmp_url);

  3. 设置 surface:player.setSurface(surfaceView.getHolder());

  4. 开始播放:player.start();

  5. 停止播放:player.stop();

  6. 释放资源:player.release();


Java 层方法封装了 JNI 层方法,JNI 层封装调用了对应的具体功能。

4. rtmp 拉流线程

oarplayer 使用的是 srs-librtmp,srs-librtmp 是从 SRS 服务器导出的一个客户端库,作者提供 srs-librtmp 初衷是:


  1. 觉得 rtmpdump/librtmp 的代码太难读了,而 SRS 的代码可读性很好;

  2. 压测工具srs-bench是个客户端,需要一个客户端库;

  3. 觉得服务器能搞好,客户端也不在话下


目前 srs-librtmp 作者已经停止维护,主要原因如作者所说:


决定开源项目正义的绝对不是技术多好,而是能跑多久。技术很牛,性能很强,代码风格很好,固然是个好事,但是这些都顶不上一个“不维护”的大罪过,代码放出来不维护,怎么跟进业内技术的不断发展呢。而决定能跑多久的,首先是技术热情,然后是维护者的领域背景。SRS 的维护者都是服务器背景,大家的工作都是在服务器,客户端经验太少了,无法长久维护客户端的库。因此,SRS 决定果断放弃 srs-librtmp,不再维护客户端库,聚焦于服务器的快速迭代。客户端并非不重要,而是要交给专业的客户端的开源项目和朋友维护,比如 FFmpeg 也自己实现了 librtmp。


oarplayer 当初使用 srs-librtmp 是基于 srs-librtmp 代码的可读性考虑。oarplayer 有相当高的模块化特性,可以很方便的替换各个 rtmp lib 实现。这里介绍 srs-librtmp 接口:


  1. 创建 srs_rtmp_t 对象:srs_rtmp_create(url);

  2. 设置读写超时时间:srs_rtmp_set_timeout;

  3. 开始握手:srs_rtmp_handshake;

  4. 开始连接:srs_rtmp_connect_app;

  5. 设置播放模式:srs_rtmp_play_stream;

  6. 循环读取音视频包:srs_rtmp_read_packet(rtmp, &type, &timestamp, &data, &size);

  7. 解析音频包:

  8. 获取编码类型:srs_utils_flv_audio_sound_format;

  9. 获取音频采样率:srs_utils_flv_audio_sound_rate;

  10. 获取采样位深:srs_utils_flv_audio_sound_size;

  11. 获取声道数:srs_utils_flv_audio_sound_type;

  12. 获取音频包类型:srs_utils_flv_audio_aac_packet_type;

  13. 解析视频包:

  14. 获取编码类型:srs_utils_flv_video_codec_id;

  15. 是否关键帧:srs_utils_flv_video_frame_type;

  16. 获取视频包类型:srs_utils_flv_video_avc_packet_type;

  17. 解析 metadata 类型;

  18. 销毁 srs_rtmp_t 对象:srs_rtmp_destroy;


这里有个小技巧,我们在拉流线程中,循环调用srs_rtmp_read_packet方法,可以通过srs_rtmp_set_timeout设置超时时间,但是如果超时时间设置的太短,会导致频繁的唤起线程,如果设置超时时间太长,我们在停止时,必须等待超时结束才会能真正结束。这里我们可以使用 poll 模型,将 rtmp 的 tcp socket 放入 poll 中,再放入一个管道 fd,在需要停止时向管道写入一个指令,唤醒 poll,直接停止 rtmp 拉流线程。

5. 主要数据结构

5.1 package 结构:

typedef struct OARPacket {    int size;//包大小    PktType_e type;//包类型    int64_t dts;//解码时间戳    int64_t pts;//显示时间戳    int isKeyframe;//是否关键帧    struct OARPacket *next;//下一个包地址    uint8_t data[0];//包数据内容}OARPacket;
复制代码

5.2 package 队列:

typedef struct oar_packet_queue {    PktType_e media_type;//类型    pthread_mutex_t *mutex;//线程锁    pthread_cond_t *cond;//条件变量    OARPacket *cachedPackets;//队列首地址    OARPacket *lastPacket;//队列最后一个元素
int count;//数量 int total_bytes;//总字节数 uint64_t max_duration;//最大时长
void (*full_cb)(void *);//队列满回调
void (*empty_cb)(void *);//队列为空回调
void *cb_data;} oar_packet_queue;
复制代码

5.3 Frame 类型

typedef struct OARFrame {    int size;//帧大小    PktType_e type;//帧类型    int64_t dts;//解码时间戳    int64_t pts;//显示时间戳    int format;//格式(用于视频)    int width;//宽(用于视频)    int height;//高(用于视频)    int64_t pkt_pos;    int sample_rate;//采样率(用于音频)    struct OARFrame *next;    uint8_t data[0];}OARFrame;
复制代码

5.4 Frame 队列

typedef struct oar_frame_queue {    pthread_mutex_t *mutex;    pthread_cond_t *cond;    OARFrame *cachedFrames;    OARFrame *lastFrame;    int count;//帧数量    unsigned int size;} oar_frame_queue;
复制代码

6. 解码线程

我们的 rtmp 流拉取、解码、渲染、音频输出都在 C 层实现。在 C 层,Android 21 之后系统提供了 AMediaCodec 接口,我们直接find_library(media-ndk mediandk),并引入<media/NdkMediaCodec.h>头文件即可。对于 Android 21 之前版本,可以在 C 层调用 Java 层的 MediaCodec。下面分别介绍两种实现:

6.1 Java 层代理解码

Java 层 MediaCodec 解码使用步骤:


  1. 创建解码器:codec = MediaCodec.createDecoderByType(codecName);

  2. 配置解码器格式:codec.configure(format, null, null, 0);

  3. 启动解码器:codec.start()

  4. 获取解码输入缓存 ID:dequeueInputBuffer

  5. 获取解码输入缓存:getInputBuffer

  6. 获取解码输出缓存:dequeueOutputBufferIndex

  7. 释放输出缓存:releaseOutPutBuffer

  8. 停止解码器:codec.stop();


Jni 层封装对应的调用接口即可。

6.2 C 层解码器使用

C 层接口介绍:


  1. 创建 Format:AMediaFormat_new;

  2. 创建解码器:AMediaCodec_createDecoderByType;

  3. 配置解码参数:AMediaCodec_configure;

  4. 启动解码器:AMediaCodec_start;

  5. 输入音视频包:

  6. 获取输入 buffer 序列:AMediaCodec_dequeueInputBuffer

  7. 获取输入 buffer:AMediaCodec_getInputBuffer

  8. 拷贝数据:memcpy

  9. 输入 buffer 放入解码器:AMediaCodec_queueInputBuffer

  10. 获取解码后帧:

  11. 获取输出 buffer 序列:AMediaCodec_dequeueOutputBuffer

  12. 获取输出 buffer:AMediaCodec_getOutputBuffer


我们发现不管是 Java 层还是 C 层的接口都是提供了类似的思路,其实他们最终调用的还是系统的解码框架。


这里我们可以根据系统版本来觉得使用 Java 层接口和 C 层接口,我们的 oarplayer,主要的代码都是在 C 层实现,所以我们也有限使用 C 层接口。

7. 音频输出线程

音频输出我们使用 opensl 实现,之前文章介绍过 Android 音频架构,其实也可以使用 AAudio 或者 Oboe。这里再简单介绍下 opensl es 的使用。


  1. 创建引擎:slCreateEngine(&engineObject, 0, NULL, 0, NULL, NULL);

  2. 实现引擎:(*engineObject)->Realize(engineObject, SL_BOOLEAN_FALSE);

  3. 获取接口:(*engineObject)->GetInterface(engineObject, SL_IID_ENGINE, &engineEngine);

  4. 创建输出混流器:(*engineEngine)->CreateOutputMix(engineEngine, &outputMixObject, 0, NULL, NULL);;

  5. 实现混流器:(*outputMixObject)->Realize(outputMixObject, SL_BOOLEAN_FALSE);

  6. 配置音频源:SLDataLocator_AndroidSimpleBufferQueue loc_bufq = {SL_DATALOCATOR_ANDROIDSIMPLEBUFFERQUEUE, 2};

  7. 配置 Format:SLDataFormat_PCM format_pcm = {SL_DATAFORMAT_PCM, channel, SL_SAMPLINGRATE_44_1,SL_PCMSAMPLEFORMAT_FIXED_16, SL_PCMSAMPLEFORMAT_FIXED_16,SL_SPEAKER_FRONT_LEFT | SL_SPEAKER_FRONT_RIGHT, SL_BYTEORDER_LITTLEENDIAN};

  8. 创建播放器:(*engineEngine)->CreateAudioPlayer(engineEngine,&bqPlayerObject, &audioSrc, &audioSnk,2, ids, req);

  9. 实现播放器:(*bqPlayerObject)->Realize(bqPlayerObject, SL_BOOLEAN_FALSE);

  10. 获取播放接口:(*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_PLAY, &bqPlayerPlay);

  11. 获取缓冲区接口:(*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_ANDROIDSIMPLEBUFFERQUEUE,&bqPlayerBufferQueue);

  12. 注册缓存回调:(*bqPlayerBufferQueue)->RegisterCallback(bqPlayerBufferQueue, bqPlayerCallback, oar);

  13. 获取音量调节器:(*bqPlayerObject)->GetInterface(bqPlayerObject, SL_IID_VOLUME, &bqPlayerVolume);

  14. 缓存回调中不断的从音频帧队列读取数据,并写入缓存队列:(*bqPlayerBufferQueue)->Enqueue(bqPlayerBufferQueue, ctx->buffer,(SLuint32)ctx->frame_size);


上面就是音频播放的 opensl es 接口使用介绍。

8. 渲染线程

相比较于音频播放,视频渲染可能更复杂一些,除了 opengl 引擎创建,opengl 线程创建,oarplayer 使用的是基于音频的同步方式,所以在视频渲染时还需要考虑音视频同步问题。

8.1 OpenGL 引擎创建

  1. 生成 buffer:glGenBuffers

  2. 绑定 buffer:glBindBuffer(GL_ARRAY_BUFFER, model->vbos[0])

  3. 设置清屏色:glClearColor

  4. 创建纹理对象:texture2D

  5. 创建着色器对象:glCreateShader

  6. 设置着色器源码:glShaderSource

  7. 编译着色器源码:glCompileShader

  8. 附着着色器:glAttachShader

  9. 连接着色器:glLinkProgram


opengl 与硬件交互还需要 EGL 环境,下面展示 EGL 初始化流程代码:


static void init_egl(oarplayer * oar){    oar_video_render_context *ctx = oar->video_render_ctx;    const EGLint attribs[] = {EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE,                              EGL_OPENGL_ES2_BIT, EGL_BLUE_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_RED_SIZE,                              8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 0, EGL_STENCIL_SIZE, 0,                              EGL_NONE};    EGLint numConfigs;    ctx->display = eglGetDisplay(EGL_DEFAULT_DISPLAY);    EGLint majorVersion, minorVersion;    eglInitialize(ctx->display, &majorVersion, &minorVersion);    eglChooseConfig(ctx->display, attribs, &ctx->config, 1, &numConfigs);    ctx->surface = eglCreateWindowSurface(ctx->display, ctx->config, ctx->window, NULL);    EGLint attrs[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};    ctx->context = eglCreateContext(ctx->display, ctx->config, NULL, attrs);    EGLint err = eglGetError();    if (err != EGL_SUCCESS) {        LOGE("egl error");    }    if (eglMakeCurrent(ctx->display, ctx->surface, ctx->surface, ctx->context) == EGL_FALSE) {        LOGE("------EGL-FALSE");    }    eglQuerySurface(ctx->display, ctx->surface, EGL_WIDTH, &ctx->width);    eglQuerySurface(ctx->display, ctx->surface, EGL_HEIGHT, &ctx->height);    initTexture(oar);
oar_java_class * jc = oar->jc; JNIEnv * jniEnv = oar->video_render_ctx->jniEnv; jobject surface_texture = (*jniEnv)->CallStaticObjectMethod(jniEnv, jc->SurfaceTextureBridge, jc->texture_getSurface, ctx->texture[3]); ctx->texture_window = ANativeWindow_fromSurface(jniEnv, surface_texture);
}
复制代码

8.2 音视频同步

常见的音视频同步有三种:


  1. 基于视频同步;

  2. 基于音频同步;

  3. 基于第三方时间戳同步。


这里我们使用基于音频帧同步的方法,渲染画面时,判断音频时间戳 diff 与视频画面渲染周期,如果大于周期,则等待,如果大于 0 小于周期,如果小于 0 则立马绘制。


下面展示渲染代码:


/** * * @param oar * @param frame * @return  0   draw *         -1   sleep 33ms  continue *         -2   break */static inline int draw_video_frame(oarplayer *oar) {    // 上一次可能没有画, 这种情况就不需要取新的了    if (oar->video_frame == NULL) {        oar->video_frame = oar_frame_queue_get(oar->video_frame_queue);    }    // buffer empty  ==> sleep 10ms , return 0    // eos           ==> return -2    if (oar->video_frame == NULL) {        _LOGD("video_frame is null...");        usleep(BUFFER_EMPTY_SLEEP_US);        return 0;
} int64_t time_stamp = oar->video_frame->pts;

int64_t diff = 0; if(oar->metadata->has_audio){ diff = time_stamp - (oar->audio_clock->pts + oar->audio_player_ctx->get_delta_time(oar->audio_player_ctx)); }else{ diff = time_stamp - oar_clock_get(oar->video_clock); } _LOGD("time_stamp:%lld, clock:%lld, diff:%lld",time_stamp , oar_clock_get(oar->video_clock), diff); oar_model *model = oar->video_render_ctx->model;

// diff >= 33ms if draw_mode == wait_frame return -1 // if draw_mode == fixed_frequency draw previous frame ,return 0 // diff > 0 && diff < 33ms sleep(diff) draw return 0 // diff <= 0 draw return 0 if (diff >= WAIT_FRAME_SLEEP_US) { if (oar->video_render_ctx->draw_mode == wait_frame) { return -1; } else { draw_now(oar->video_render_ctx); return 0; } } else { // if diff > WAIT_FRAME_SLEEP_US then use previous frame // else use current frame and release frame// LOGI("start draw..."); pthread_mutex_lock(oar->video_render_ctx->lock); model->update_frame(model, oar->video_frame); pthread_mutex_unlock(oar->video_render_ctx->lock); oar_player_release_video_frame(oar, oar->video_frame);
JNIEnv * jniEnv = oar->video_render_ctx->jniEnv; (*jniEnv)->CallStaticVoidMethod(jniEnv, oar->jc->SurfaceTextureBridge, oar->jc->texture_updateTexImage); jfloatArray texture_matrix_array = (*jniEnv)->CallStaticObjectMethod(jniEnv, oar->jc->SurfaceTextureBridge, oar->jc->texture_getTransformMatrix); (*jniEnv)->GetFloatArrayRegion(jniEnv, texture_matrix_array, 0, 16, model->texture_matrix); (*jniEnv)->DeleteLocalRef(jniEnv, texture_matrix_array);
if (diff > 0) usleep((useconds_t) diff); draw_now(oar->video_render_ctx); oar_clock_set(oar->video_clock, time_stamp); return 0; }}
复制代码

9. 总结

本文基于 Android 端的 RTMP 播放器实现过程,介绍了 RTMP 推拉流库、Android MediaCodec Java 层与 C 层接口、OpenSL ES 接口、OpenGL ES 接口、EGL 接口、以及音视频相关知识。具体播放器代码可直接在官方地址查看:oarplayer



发布于: 2021 年 10 月 07 日阅读数: 25
用户头像

轻口味

关注

🏆2021年InfoQ写作平台-签约作者 🏆 2017.10.17 加入

Android音视频、AI相关领域从业者,开源RTMP播放器:https://github.com/qingkouwei/oarplayer

评论 (1 条评论)

发布
用户头像
好文!
2021 年 10 月 07 日 18:21
回复
没有更多了
从零开发一款Android Rtmp播放器