前言
本篇文章属于 Android NDK 模块,需要读者有一点 NDK 相关的基础和 C/C++ 基础,不然其中的语法会有点晦涩难懂。本篇文章共分为以下五个专题,通过这五个专题的学习最终带大家制作一款属于自己的直播流播放器。
- 直播流信息获取
- 视频解码与原生绘制
- 音频解码与 OpenSL
- 音视频同步
- 音视频停止与释放
在学习第一个专题之前我们先掌握一些基础知识。我们知道播放在手机上的视频图像是由 RGB 三原色组成的,视频的话是各种图片的集合,由于 RGB 数据量太大我们需要进行压缩减少数据大小节省带宽和磁盘空间。为什么可以压缩呢,可以从以下几个方面进行考虑。
去除冗余信息
- 空间冗余:图像相邻像素之间有较强的相关性
- 时间冗余:视频序列的相邻图像之间内容相似
- 编码冗余:不同像素值出现的概率不同- 视觉冗余:人的视觉系统对某些细节不敏感
- 知识冗余:规律性的结构可由先验知识和背景知识得到
我们需要做的是获得压缩数据解压缩展示在手机上。那具体的流程是什么样的呢?比如说我们获得了一个 MP4 文件,如何解压缩成为可以展示的 RGB 图像呢?
我们可以借助 FFmpeg 进行解封装解码的工作,FFmpeg 不仅内部实现了编解码算法还可以集成其他的编解码框架,目前抖音斗鱼等各种直播软件都使用了 FFmpeg 所以说还是很强大的。
Android 做视频只能通过 FFmpeg 吗?FFmpeg 是通过软编解码通过代码进行编解码,还有一个硬编解码,Android 中的 Media Codec 使用的就是硬编解码,由于兼容性比较差如果没有厂商的硬件支持基本上是兼容不了。OK,下面首先介绍一下我们的工程结构。
我的 build 文件,配置 CPU 兼容 和 CMakeLists:
externalNativeBuild {
cmake {
cppFlags ""
abiFilters 'armeabi-v7a'
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
externalNativeBuild {
cmake {
path "CMakeLists.txt"
}
}
我的 CMakeLists 文件
cmake_minimum_required(VERSION 3.4.1)
# 创建一个变量 source_file 它的值就是src/main/cpp/ 所有的.cpp文件
file(GLOB source_file src/main/cpp/*.cpp)
add_library(
native-lib
SHARED
${source_file} )
include_directories(src/main/cpp/include)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/cpp/libs/${ANDROID_ABI}")
#avfilter avformat avcodec avutil swresample swscale
target_link_libraries( native-lib
avformat avcodec avfilter avutil swresample swscale
log z)
Java 层我们新建两个文件,MainActivity 播放器的主界面,MyPlayer 播放器的管理类。
cpp 中的 libs 存放的是我们编译出来的 FFmpeg 的 .a 库,JavaCallHelper 类实现了 C++ 反射调用 Java 代码,MyFFmpeg 中编写获取直播流解码的代码,获取到流后分为视频流和音频流,分别用 VideoChannel 和 AudioChannel 类进行处理。为了方便我编写了一个 util.h 文件来定义一些宏函数,由于直播涉及到线程我编写了一个线程安全的类 safe_queue。OK,整体项目结构就是这样,下面开始进行代码的讲解。
直播流信息获取
public class MainActivity extends AppCompatActivity {
private MyPlayer myPlayer;
/**
* 1,RTMP协议直播源
* 香港卫视:rtmp://live.hkstv.hk.lxdns.com/live/hks
*
* 2,RTSP协议直播源
* 珠海过澳门大厅摄像头监控:rtsp://218.204.223.237:554/live/1/66251FC11353191F/e7ooqwcfbqjoo80j.sdp
* 大熊兔(点播):rtsp://184.72.239.149/vod/mp4://BigBuckBunny_175k.mov
*
* 3,HTTP协议直播源
* 香港卫视:http://live.hkstv.hk.lxdns.com/live/hks/playlist.m3u8
* CCTV1高清:http://ivi.bupt.edu.cn/hls/cctv1hd.m3u8
* CCTV3高清:http://ivi.bupt.edu.cn/hls/cctv3hd.m3u8
* CCTV5高清:http://ivi.bupt.edu.cn/hls/cctv5hd.m3u8
* CCTV5+高清:http://ivi.bupt.edu.cn/hls/cctv5phd.m3u8
* CCTV6高清:http://ivi.bupt.edu.cn/hls/cctv6hd.m3u8
* 苹果提供的测试源(点播):http://devimages.apple.com.edgekey.net/streaming/examples/bipbop_4x3/gear2/prog_index.m3u8
*
* @param savedInstanceState
*/
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
SurfaceView surfaceView = findViewById(R.id.surfaceView);
myPlayer = new MyPlayer();
myPlayer.setSurfaceView(surfaceView);
myPlayer.setDataSource("rtmp://live.hkstv.hk.lxdns.com/live/hks");
myPlayer.setOnPrepareListener(new MyPlayer.OnPrepareListener() {
@Override
public void onPrepare() {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "可以开始播放了", 0).show();
}
});
}
});
}
public void start(View view) {
myPlayer.prepare();
}}
主界面主要做的工作就是用 SurfaceView 来播放音视频流,实例化 MyPlayer 将 surfaceView 传递给 MyPlayer 设置播放地址,实现一个准备播放的监听,一个开始播放的方法。
/**
* 提供java 进行播放 停止 等函数
*/
public class MyPlayer implements SurfaceHolder.Callback {
static {
System.loadLibrary("native-lib");
}
private String dataSource;
private SurfaceHolder holder;
private OnPrepareListener listener;
/**
* 让使用 设置播放的文件 或者 直播地址
*/
public void setDataSource(String dataSource) {
this.dataSource = dataSource;
}
/**
* 设置播放显示的画布
*
* @param surfaceView
*/
public void setSurfaceView(SurfaceView surfaceView) {
holder = surfaceView.getHolder();
holder.addCallback(this);
}
public void onError(int errorCode){
System.out.println("Java接到回调:"+errorCode);
}
public void onPrepare(){
if (null != listener){
listener.onPrepare();
}
}
public void setOnPrepareListener(OnPrepareListener listener){
this.listener = listener;
}
public interface OnPrepareListener{
void onPrepare();
}
/**
* 准备好 要播放的视频
*/
public void prepare() {
native_prepare(dataSource);
}
/**
* 开始播放
*/
public void start() {
}
/**
* 停止播放
*/
public void stop() {
}
public void release() {
holder.removeCallback(this);
}
/**
* 画布创建好了
*
* @param holder
*/
@Override
public void surfaceCreated(SurfaceHolder holder) {
}
/**
* 画布发生了变化(横竖屏切换、按了home都会回调这个函数)
*
* @param holder
* @param format
* @param width
* @param height
*/
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
/**
* 销毁画布 (按了home/退出应用/)
*
* @param holder
*/
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
native void native_prepare(String dataSource);}
MyPlayer 类实现了一个 SurfaceHolder.Callback 接口,我们看下源码:
public interface Callback {
/**
* This is called immediately after the surface is first created.
* Implementations of this should start up whatever rendering code
* they desire. Note that only one thread can ever draw into
* a {@link Surface}, so you should not draw into the Surface here
* if your normal rendering will be in another thread.
*
* @param holder The SurfaceHolder whose surface is being created.
*/
public void surfaceCreated(SurfaceHolder holder);
/**
* This is called immediately after any structural changes (format or
* size) have been made to the surface. You should at this point update
* the imagery in the surface. This method is always called at least
* once, after {@link #surfaceCreated}.
*
* @param holder The SurfaceHolder whose surface has changed.
* @param format The new PixelFormat of the surface.
* @param width The new width of the surface.
* @param height The new height of the surface.
*/
public void surfaceChanged(SurfaceHolder holder, int format, int width,
int height);
/**
* This is called immediately before a surface is being destroyed. After
* returning from this call, you should no longer try to access this
* surface. If you have a rendering thread that directly accesses
* the surface, you must ensure that thread is no longer touching the
* Surface before returning from this function.
*
* @param holder The SurfaceHolder whose surface is being destroyed.
*/
public void surfaceDestroyed(SurfaceHolder holder);
}
可以看到这个接口有三个方法要我们实现,控制了画布创建、画布改变、画布销毁。在 setSurfaceView 中 getHolder 和 addCallback,在 release() 方法中调用 holder.removeCallback 释放掉 Callback,防止内存泄漏。由于视频的编解码操作是在 native 方法中,所以定义一个 native_prepare 方法把 dataSource 传进去。下面开始 native 方法的编写。在 Myplayer 类中编写 native void native_prepare(String dataSource);
通过快捷键 Alt+Enter 可以在 native-lib 中快速生成相对应的 native 方法。
MyFFmpeg *ffmpeg = 0;
extern "C"
JNIEXPORT void JNICALL
Java_com_my_player_MyPlayer_native_1prepare(JNIEnv *env, jobject instance,
jstring dataSource_) {
const char *dataSource = env->GetStringUTFChars(dataSource_, 0);
//创建播放器
JavaCallHelper *helper = new JavaCallHelper(javaVm, env, instance);
ffmpeg = new MyFFmpeg(helper, dataSource);
ffmpeg->prepare();
env->ReleaseStringUTFChars(dataSource_, dataSource);
}
第一个参数 JNIEnv 类型实际上代表了 Java 环境,通过这个 JNIEnv* 指针,就可以对 Java 端的代码进行操作。例如,创建 Java 类中的对象,调用 Java 对象的方法,获取 Java 对象中的属性等等。JNIEnv 的指针会被 JNI 传入到本地方法的实现函数中来对 Java 端的代码进行操作。JNIEnv 类中有很多函数可以用:
-
NewObject
:创建 Java 类中的对象 -
NewString
:创建 Java 类中的 String 对象 -
New
:创建类型为 Type 的数组对象Array -
Get
:获取类型为 Type 的字段Field -
Set
:设置类型为 Type 的字段的值Field -
GetStatic
:获取类型为 Type 的 static 的字段Field -
SetStatic
:设置类型为 Type 的 static 的字段的值Field -
Call
:调用返回类型为 Type 的方法Method -
CallStatic
:调用返回值类型为 Type 的 static 方法Method
等许多的函数,具体的可以查看 jni.h 文件中的函数名称。
参数:jobject instance
- 如果 native 方法不是 static 的话,这个 instance 就代表这个 native 方法的类实例。
- 如果 native 方法是 static 的话,这个 instance 就代表这个 native 方法的类的 class 对象实例(static 方法不需要类实例的,所以就代表这个类的 class 对象)。
将所有的操作封装在 Myffmpeg 中,在 native-lib 中进行调用。
MyFFmpeg::MyFFmpeg(JavaCallHelper *callHelper,const char *dataSource) {
this->callHelper = callHelper;
//防止 dataSource参数 指向的内存被释放
this->dataSource = new char[strlen(dataSource)];
//错误写法 this->dataSource = const_cast(dataSource);
strcpy(this->dataSource,dataSource); }
MyFFmpeg::~MyFFmpeg() {
//释放
DELETE(dataSource);
DELETE(callHelper);}
构造方法中的错误写法是因为 MyFFmpeg 的成员直接指向参数 dataSource 的话有可能这个 dataSource 在其他地方被释放了导致 MyFFmpeg 中的 dataSource 变成一个悬空指针。strcpy 指的是字符串拷贝。在析构方法中释放 dataSource 和 callHelper。到此我们的 MyFFmpeg 已经拿到了播放的地址,接下来要对这个地址进行解析。
解码流程
播放直播需要连接网络所以我们要加上网络权限,而且联网的操作肯定不能在主线程进行,所以我们要开辟一个子线程在子线程中操作。
void* task_prepare(void *args){
MyFFmpeg *ffmpeg = static_cast(args);
ffmpeg->_prepare();
return 0;}
void MyFFmpeg::prepare(){
//创建一个线程
pthread_create(&pid,0,task_prepare,this);
}
导入头文件 pthread.h、libavformat/avformat.h
通过查看pthread.h 头文件int pthread_create(pthread_t* __pthread_ptr, pthread_attr_t const* __attr, void* (*__start_routine)(void*), void*);
- 第一个参数为指向线程标识符的指针
- 第二个参数用来设置线程属性
- 第三个参数是线程运行函数的起始地址
- 最后一个参数是运行函数的参数
void MyFFmpeg::_prepare(){
// 初始化网络 让ffmpeg能够使用网络
avformat_network_init();
//1、打开媒体地址(文件地址、直播地址)
// AVFormatContext 包含了 视频的 信息(宽、高等)
formatContext = 0;
//文件路径不对 手机没网
int ret = avformat_open_input(&formatContext,dataSource,0,0);
//ret不为0表示 打开媒体失败
if(ret != 0){
LOGE("打开媒体失败:%s",av_err2str(ret));
callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_OPEN_URL);
return;
}
//2、查找媒体中的 音视频流 (给 contxt里的 streams等成员赋)
ret = avformat_find_stream_info(formatContext,0);
// 小于0 则失败
if (ret < 0){
LOGE("查找流失败:%s",av_err2str(ret));
callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_FIND_STREAMS);
return;
}
//nb_streams :几个流(几段视频/音频)
for (int i = 0; i < formatContext->nb_streams; ++i) {
//可能代表是一个视频 也可能代表是一个音频
AVStream *stream = formatContext->streams[i];
//包含了 解码 这段流 的各种参数信息(宽、高、码率、帧率)
AVCodecParameters *codecpar = stream->codecpar;
//无论视频还是音频都需要干的一些事情(获得解码器)
// 1、通过 当前流 使用的 编码方式,查找解码器
AVCodec *dec = avcodec_find_decoder(codecpar->codec_id);
if(dec == NULL){
LOGE("查找解码器失败:%s",av_err2str(ret));
callHelper->onError(THREAD_CHILD,FFMPEG_FIND_DECODER_FAIL);
return;
}
//2、获得解码器上下文
AVCodecContext *context = avcodec_alloc_context3(dec);
if(context == NULL){
LOGE("创建解码上下文失败:%s",av_err2str(ret));
callHelper->onError(THREAD_CHILD,FFMPEG_ALLOC_CODEC_CONTEXT_FAIL);
return;
}
ret = avcodec_parameters_to_context(context,codecpar);
//失败
if(ret < 0){
LOGE("设置解码上下文参数失败:%s",av_err2str(ret));
callHelper->onError(THREAD_CHILD,FFMPEG_CODEC_CONTEXT_PARAMETERS_FAIL);
return;
}
// 4、打开解码器
ret = avcodec_open2(context,dec,0);
if (ret != 0){
LOGE("打开解码器失败:%s",av_err2str(ret));
callHelper->onError(THREAD_CHILD,FFMPEG_OPEN_DECODER_FAIL);
return;
}
//音频
if (codecpar->codec_type == AVMEDIA_TYPE_AUDIO){
audioChannel = new AudioChannel;
} else if(codecpar->codec_type == AVMEDIA_TYPE_VIDEO){
videoChannel = new VideoChannel;
}
//没有音视频
if(!audioChannel && !videoChannel){
LOGE("没有音视频");
callHelper->onError(THREAD_CHILD,FFMPEG_NOMEDIA);
return;
}
// 准备完了 通知java 你随时可以开始播放
callHelper->onPrepare(THREAD_CHILD);
以上代码包含了获取音视频信息(宽高等)、查找解码器、获得解码器上下文、设置上下文参数、打开解码器,下面就几个重要的方法进行讲解。
avformat_open_input 打开头文件可以看到 int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options);
,里面传递了 AVFormatContext 点进去看,里面包含和音视频的信息(宽高等),第二个参数传递了地址,后面两个参数分别代表文件容器格式、最大延时,超时时间,以及支持的协议的白名单等。
可以看到 avformat_open_input 方法是有返回值的,返回 0 是成功,不为 0 失败。可以通过我之前编写的 JavaCallHelper 返回给 Java 进行处理,在这里我们需要注意的是,我们是在子线程中反射调用 Java,而 JNIEnv 不能跨线程调用这里就涉及到跨线程问题。这里我们需要一个 JavaVM 来获得对应线程的 JNIEnv。
class JavaCallHelper {
public:
JavaCallHelper(JavaVM *vm,JNIEnv* env,jobject instace);
~JavaCallHelper();
//回调java
void onError(int thread,int errorCode);
void onPrepare(int thread);
private:
JavaVM *vm;
JNIEnv *env;
jobject instance;
jmethodID onErrorId;
jmethodID onPrepareId;
下面介绍下我的 JavaCallHelper 类,其中包含了所有的回调 Java 的方法。
JavaCallHelper::JavaCallHelper(JavaVM *vm, JNIEnv *env, jobject instace) {
this->vm = vm;
//如果在主线程 回调
this->env = env;
// 一旦涉及到jobject 跨方法 跨线程 就需要创建全局引用
this->instance = env->NewGlobalRef(instace);
jclass clazz = env->GetObjectClass(instace);
onErrorId = env->GetMethodID(clazz,"onError","(I)V");
onPrepareId = env->GetMethodID(clazz,"onPrepare","()V");}
JavaCallHelper::~JavaCallHelper() {
env->DeleteGlobalRef(instance);}
void JavaCallHelper::onError(int thread,int error){
//主线程
if (thread == THREAD_MAIN){
env->CallVoidMethod(instance,onErrorId,error);
} else{
//子线程
JNIEnv *env;
//获得属于我这一个线程的jnienv
vm->AttachCurrentThread(&env,0);
env->CallVoidMethod(instance,onErrorId,error);
vm->DetachCurrentThread();
}}
void JavaCallHelper::onPrepare(int thread) {
if (thread == THREAD_MAIN){
env->CallVoidMethod(instance,onPrepareId);
} else{
//子线程
JNIEnv *env;
//获得属于我这一个线程的jnienv
vm->AttachCurrentThread(&env,0);
env->CallVoidMethod(instance,onPrepareId);
vm->DetachCurrentThread();
}}
onError 方法中处理我们的错误信息反射给 Java,如果是主线程 THREAD_MAIN 直接传递 Env,如果是子线程就通过 vm->AttachCurrentThread(&env,0);
获得属于我这一个线程的 JNIEnv,调用完毕后 DetachCurrentThread。然后在 MyFFmpeg 中 callHelper->onError(THREAD_CHILD,FFMPEG_CAN_NOT_OPEN_URL);
第一个参数代表子线程,第二个参数代表错误信息。
回到我们的 _prepare 方法中来,第二个方法 avformat_find_stream_info,查看头文件 int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);
这个方法表示查找媒体中的音视频流(给 contxt 里的 streams 等成员赋值)。返回小于 0 失败,大于等于 0 成功。失败的话就回调给 Java ,调用这个方法后,formatContext 就有值了。这里我们看一下 AVFormatContext 这个结构体: