1 前言
1.1 总览
短文将记录一个基本的摄像头直播APP开发的全部流程和技术点。项目使用x264进行视频数据处理,使用FAAC进行音频数据处理,使用RTMP协议进行数据推流,整个过程的大体如下。
整个APP实现了以下功能:
- 直播前视频预览
- 开始直播、编码、推流
- 切换摄像头
- 停止直播
- 退出应用
短文将以各个功能为切入点记录各个技术点。
1.2 CameraX
1.2.1 简介
CameraX 是一个 Jetpack 支持库,旨在帮助您简化相机应用的开发工作。它提供一致且易于使用的 API 界面,适用于大多数 Android 设备,并可向后兼容至 Android 5.0(API 级别 21)。
虽然它利用的是 camera2 的功能,但使用的是更为简单且基于用例的方法,该方法具有生命周期感知能力。它还解决了设备兼容性问题,因此您无需在代码库中包含设备专属代码。这些功能减少了将相机功能添加到应用时需要编写的代码量。
最后,借助 CameraX,开发者只需两行代码就能利用与预安装的相机应用相同的相机体验和功能。 CameraX Extensions 是可选插件,通过该插件,您可以在支持的设备上向自己的应用中添加人像、HDR、夜间模式和美颜等效果。
1.2.2 CameraX基本使用
请参考Google官方的CameraX Demo,示例使用Kotlin编写。
git clone github.com/android/cam…
1.2.3 获取原始图片帧数据
如何获取原始图片帧数据?
CameraX提供了一个图像分析接口:ImageAnalysis.Analyzer
,实现这个接口需要实现接口中的analyze()
方法可以获得一个ImageProxy
,显然,该ImageProxy
是Image
的一个代理,Image
的所有方法,ImageProxy
都能调用。
//Java
package androidx.camera.core;
//import ...
public final class ImageAnalysis extends UseCase {
//...
public interface Analyzer {
/**
* Analyzes an image to produce a result.
*
* <p>This method is called once for each image from the camera, and called at the
* frame rate of the camera. Each analyze call is executed sequentially.
*
* <p>The caller is responsible for ensuring this analysis method can be executed quickly
* enough to prevent stalls in the image acquisition pipeline. Otherwise, newly available
* images will not be acquired and analyzed.
*
* <p>The image passed to this method becomes invalid after this method returns. The caller
* should not store external references to this image, as these references will become
* invalid.
*
* <p>Processing should complete within a single frame time of latency, or the image data
* should be copied out for longer processing. Applications can be skip analyzing a frame
* by having the analyzer return immediately.
*
* @param image The image to analyze
* @param rotationDegrees The rotation which if applied to the image would make it match
* the current target rotation of {@link ImageAnalysis}, expressed in
* degrees in the range {@code [0..360)}.
*/
void analyze(ImageProxy image, int rotationDegrees);
}
//...
}
ImageProxy
有哪些方法呢?
//Java
//获得一个裁剪矩形
Rect cropRect = image.getCropRect();
//获得图像格式
int format = image.getFormat();
//获得图像高度
int height = image.getHeight();
//获得图像宽度
int width = image.getWidth();
//获得图像
Image image1 = image.getImage();
//获得图像信息
ImageInfo imageInfo = image.getImageInfo();
//获得图像的平面代理
ImageProxy.PlaneProxy[] planes = image.getPlanes();
//获得图像的时间戳
long timestamp = image.getTimestamp();
复制代码
★着重说明!
第一,根据官方文档的介绍,CameraX
生产的图像数据格式为YUV_420_888,因此,在使用ImageProxy
对象时,如果得到的图像格式不匹配,应该报错;
//Java
if (format != ImageFormat.YUV_420_888) {
//抛出异常
}
复制代码
第二,在第一条通过的前提下,ImageProxy.PlaneProxy[]
数组包含着YUV的Y数据、U数据、V数据,即planes.length
值为3。
到这里,我们找到了原始图片的帧数据,接下来我们需要将Y数据、U数据、V数据取出来,按照I420格式进行排列。
Why I420?
因为获取的图像数据接下来需要编码,而在编码时,一般编码器接收的待编码数据格式为I420。
1.3 YUV_420_888
通过CameraX
生产的图像数据格式为YUV_420_888,本节将介绍YUV_420_888中的NV21和I420两种格式。
1.3.1 YUV_420_888格式
YUV即通过Y、U和V三个分量表示颜色空间,其中Y表示亮度,U和V表示色度。( 如果UV数据都为0,那么我们将得到一个黑白的图像。)
RGB中每个像素点都有独立的R、G和B三个颜色分量值,YUV根据U和V采样数目的不同,分为如YUV444、YUV422和YUV420等,而YUV420表示的就是每个像素点有一个独立的亮度表示,即Y分量;而色度,即U和V分量则由每4个像素点共享一个。举例来说,对于4x4的图片,在YUV420下,有16个Y值,4个U值和4个V值。
- YUV420中,每个像素独有一个Y,每四个像素共享一个U,每四个像素共享一个V;
- YUV420中,Y数据有效字节数=Height×Width;
- YUV420中,U数据有效字节数=(Height/2)×(Width/2);
- YUV420中,V数据有效字节数=(Height/2)×(Width/2);
- YUV420中,每一个像素点的YUV数据示意图如下。
★YUV格式认为,图片是由一个亮度平面和两个颜色平面叠加形成(Y plane+U plane+V plane);
★官方将YUV三个平面都称为颜色平面(color plane),我们一般习惯将Y plane称为亮度平面;
★
ImageProxy
对象调用getPlanes()
方法即可得到ImageProxy.PlaneProxy[] planes
,planes[0]
是Y plane,planes[1]
是U plane,planes[2]
是V plane;
★
ImageProxy.PlaneProxy
对象有三个方法:
getBuffer()
获得包含YUV数据字节的ByteBuffer
;
getPixelStride()
获得UV数据的存储方式(详见1.2.2);
getRowStride()
获得行跨距(详见1.2.3)。
1.3.2 YUV420下的UV排列顺序
YUV420根据颜色数据的存储顺序不同,又分为了多种不同的格式,这些格式实际存储的信息还是完全一致的。
举例来说,对于4x4的图片,在YUV420下,任何格式都有16个Y值,4个U值和4个V值,不同格式只是Y、U和V的排列顺序变化。
- I420存储示意图如下,U和V是分开的:
- NV21存储示意图如下,planes[1]中,UVU;planes[2]中,VUV:
★YUV420是一类格式的集合,包含I420在内。YUV_420_888中的888表示YUV三分量都用8 bits/1 byte表示。YUV420并不能完全确定颜色数据(即UV数据)的存储顺序,因此接下来需要分两种情况分别处理
planes[1]
和planes[2]
两个数据,至于planes[0]
表示的Y数据,在不同存储顺序中是一样的,不需要分别处理。★NV21存储格式中的UV数据是有冗余的,我们取
planes[1]
每一排索引为偶数的字节即可得到所有的U数据,取planes[2]
每一排索引为偶数的字节即可得到所有的V数据,冗余的可以忽略。★如何判断当前的图像格式是I420或者NV21呢?
ImageProxy.PlaneProxy[]
数组中的每一个plane
元素都有一个getPixelStride()
方法,该方法的返回值如果是1,则格式是I420;如果是2,则格式是NV21。★对于包含Y数据的
planes[0]
,其getPixelStride()
的结果只可能是1。
1.2.3 行跨距RowStride
Google对getRowStride()
方法的注释如下:
/**
* <p>The row stride for this color plane, in bytes.</p>
*
* <p>This is the distance between the start of two consecutive rows of
* pixels in the image. Note that row stried is undefined for some formats
* such as
* {@link android.graphics.ImageFormat#RAW_PRIVATE RAW_PRIVATE},
* and calling getRowStride on images of these formats will
* cause an UnsupportedOperationException being thrown.
* For formats where row stride is well defined, the row stride
* is always greater than 0.</p>
*/
public abstract int getRowStride();
简单翻译一下:此颜色平面的行跨距,以字节为单位,是图像中连续两行像素开始之间的距离。注意,对于某些格式,行跨距是未定义的,尝试从这些格式获取行跨距将会触发UnsupportedOperationException
异常。对于有行跨距定义的格式,其行跨距的值一定大于0。
★换句话说,planes[0/1/2].getBuffer()
中的每一行,除了有效数据,还可能有无效数据,这取决于rowStride
值与图像宽度width
值的关系。有一点可以确定的是,rowStride
一定大于或等于有效数据长度。
继续以4×4的图像为例:
I420/NV21 Y plane
-
rowStride
=width
-
rowStride
>width
I420 U/V plane
-
rowStride
=width/2
-
rowStride
>width/2
NV21 U/V plane
-
rowStride
=width-1
-
rowStride
>width-1
1.3 项目结构
1.4 第三方库
本项目在native
层使用到了4个第三方库:
- x264用于视频数据处理;
- FAAC用于音频数据处理;
- libyuv用于图像数据处理(旋转、缩放);
- RTMPDump用于使用rtmp协议发送数据包。
其中x264和FAAC使用前,已经在Linux系统下编译了静态连接库,其编译过程参考文章在Linux下用NDK编译第三方库(合集);
libyuv和RTMPDump库直接使用源代码。
配置项目CMakeLists
项目的CMakeLists.txt
编辑如下:
/cpp/CMakeLists.txt
#CMake
#/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)
#native-lib.cpp
add_library(native-lib SHARED native-lib.cpp JavaCallHelper.cpp VideoChannel.cpp)
#rtmp
include_directories(${CMAKE_SOURCE_DIR}/librtmp)
add_subdirectory(librtmp)
#x264
include_directories(${CMAKE_SOURCE_DIR}/x264/armeabi-v7a/include)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/x264/armeabi-v7a/lib")
#faac
include_directories(${CMAKE_SOURCE_DIR}/faac/armeabi-v7a/include)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/faac/armeabi-v7a/lib")
#log-lib
find_library(log-lib log)
#将native-lib及其使用的库链接起来
target_link_libraries(native-lib ${log-lib} x264 faac rtmp)
#libyuv
include_directories(${CMAKE_SOURCE_DIR}/libyuv/include)
add_subdirectory(libyuv)
add_library(ImageUtils SHARED ImageUtils.cpp)
#将ImageUtils及其使用的库链接起来
target_link_libraries(ImageUtils yuv)
/cpp/librtmp/CMakeLists.txt
#CMake
#/cpp/librtmp/CMakeLists.txt
#关闭ssl 不支持rtmps
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DNO_CRYPTO")
#所有源文件放入 rtmp_source 变量
file(GLOB rtmp_source *.c)
#编译静态库
add_library(rtmp STATIC ${rtmp_source})
/cpp/libyuv/CMakeLists.txt
#CMake
#/cpp/libyuv/CMakeLists.txt
aux_source_directory(source LIBYUV)
add_library(yuv STATIC ${LIBYUV})
2 直播前视频预览
2.1 时序图
当用户打开APP,进入首页,即打开摄像头,将预览画面渲染到TextureView
。当MainActivity
进入onCreate
生命周期,时序图如下(时序图很大,字很小,建议电脑查看):
2.2 过程描述
用户打开APP,当MainActivity
进入onCreate
生命周期,主要执行了三个动作:
- 第一步,创建
RtmpClient
对象,由MainActivity
执有该对象;在其构造方法中调用nativeInit()
方法,初始化native层的必要组件,创建JavaCallHelper
对象,由native-lib.cpp
执有该对象。 - 第二步,调用对象
rtmpClient
的initVideo()
方法,在该方法中,创建Java层的VideoChannel
对象,由rtmpClient
执有该对象;在VideoChannel
的构造方法中,新建并开启了一个“Analyze-Thread”线程,在该新线程中,会周期调用CameraX
提供的analyze()
回调,在该回调中,我们首先监测rtmpClient.isConnected
标识,判断是否处于直播状态,若是,则进入编码推流操作;否则,维持当前的仅预览操作。完成VideoChannel
的构造操作之后,initVideo()
继续调用native方法initVideoEnc()
,对native层的视频编码器进行初始化,时刻准备进入直播状态。 - 第三步,调用对象
rtmpClient
的initAudio()
方法,在该方法中,创建Java层的AudioChannel
对象,由rtmpClient
执有该对象;在AudioChannel
的构造方法中,新建并开启了一个“Audio-Recode”线程,在该线程中,直播状态下会进行音频的编码;完成AudioChannel
的构造之后,initAudio()
方法继续调用native方法initAudioEnc()
,对native层的音频编码器进行初始化,时刻准备进入直播状态。
2.3 关键代码
2.3.1 准备视频编码器
以下代码对应着时序图中的videoChannel.openCodec:(int,int,int,int)->void
方法,在该方法内,重点对一些x264视频参数进行了设置。注意看代码注释。
//C++
//VideoChannel.cpp
void VideoChannel::openCodec(int width, int height, int fps, int bitrate) {
//编码器参数
x264_param_t param;
//ultrafast: 编码速度与质量的控制 ,使用最快的模式编码
//zerolatency: 无延迟编码 , 实时通信方面
x264_param_default_preset(¶m, "ultrafast", "zerolatency");
//main base_line high
//base_line 3.2 编码规格 无B帧(数据量最小,但是解码速度最慢)
param.i_level_idc = 32;
//输入数据格式
param.i_csp = X264_CSP_I420;
param.i_width = width;
param.i_height = height;
//无b帧
param.i_bframe = 0;
//参数i_rc_method表示码率控制,CQP(恒定质量),CRF(恒定码率),ABR(平均码率)
param.rc.i_rc_method = X264_RC_ABR;
//码率(比特率,单位Kbps)
param.rc.i_bitrate = bitrate / 1000;
//瞬时最大码率
param.rc.i_vbv_max_bitrate = bitrate / 1000 * 1.2;
//帧率
param.i_fps_num = fps;
param.i_fps_den = 1;
param.pf_log = x264_log_default2;
//帧距离(关键帧) 2s一个关键帧
param.i_keyint_max = fps * 2;
//是否复制sps和pps放在每个关键帧的前面,该参数设置是让每个关键帧(I帧)都附带sps/pps
param.b_repeat_headers = 1;
//不使用并行编码。zerolatency场景下设置param.rc.i_lookahead=0
//那么编码器来一帧编码一帧,无并行、无延时
param.i_threads = 1;
param.rc.i_lookahead = 0;
x264_param_apply_profile(¶m, "baseline");
codec = x264_encoder_open(¶m);
ySize = width * height;
uSize = (width >> 1) * (height >> 1);
this->width = width;
this->height = height;
}
2.3.2 准备音频编码器
以下代码对应时序图中的audioChannel.openCodec:(int,int)->void
方法,主要对FAAC音频参数进行了设置。注意看代码注释。
//C++
//AudioChannel.cpp
void AudioChannel::openCodec(int sampleRate, int channels) {
//输入样本:要送给编码器编码的样本数
unsigned long inputSamples;
codec = faacEncOpen(sampleRate, channels, &inputSamples, &maxOutputBytes);
//样本是16位的,那么一个样本就是2个字节
inputByteNum = inputSamples * 2;
outputBuffer = static_cast<unsigned char *>(malloc(maxOutputBytes));
//得到当前编码器的各种参数配置
faacEncConfigurationPtr configurationPtr = faacEncGetCurrentConfiguration(codec);
configurationPtr->mpegVersion = MPEG4;
configurationPtr->aacObjectType = LOW;
//1.每一帧音频编码的结果数据都会携带ADTS(包含了采样、声道等信息的一个数据头)
//0.编码出aac裸数据
configurationPtr->outputFormat = 0;
configurationPtr->inputFormat = FAAC_INPUT_16BIT;
faacEncSetConfiguration(codec, configurationPtr);
}
3 开始直播
3.1 连接流媒体服务器
3.1.1 时序图
当用户点击页面上的开始直播按钮时,客户端首先需要做的是连接流媒体服务器。时序图如下:
3.1.2 过程描述
连接流媒体服务器需要在native层借助RTMPDump
库进行,同时,该过程涉及到网络请求,所以连接过程需要在新的线程进行。
- 当用户点击开始直播按钮,APP最终将在native层新建一个
pthread
,异步执行连接服务器操作; - 在异步执行的
void *connect(void *args)
方法中,将借助RTMPDump
库尝试连接流媒体服务器; - 成功连接流媒体服务器后,native层将利用
JavaCallHelper
对象——helper
的onPrepare()
方法,调用Java层的rtmpClient
对象的onPrepare()
方法;在这个方法内,第一,设置rtmpClient
的isConnected
标识为true
,开始编码视频;调用audioChannel.start()
方法,将音频编码任务post
到“Audio-Recode”线程,开始编码音频。
3.1.3 关键代码
开启连接流媒体服务器线程
以下代码对应时序图中的JNI_connect:(JNIEnv *,jobject,jstring)->void
函数,在该函数中,程序开启了新的线程。
//C++
//native-lib.cpp
extern "C"
JNIEXPORT void JNICALL
Java_com_tongbo_mycameralive_RtmpClient_connect(
JNIEnv *env,
jobject thiz,
jstring url_
) {
const char *url = env->GetStringUTFChars(url_, 0);
path = new char[strlen(url) + 1];
strcpy(path, url);
pthread_create(&ptid, NULL, connect, 0);
env->ReleaseStringUTFChars(url_, url);
}
尝试连接流媒体服务器
注意!其中,RtmpClient
的成员方法connect(String url)
是一个native方法,在其JNI实现中开启新的线程,异步执行void *connect(void *args)
函数,在该函数中,我们借助RTMPDump
库提供的API,实现连接流媒体服务器。void *connect(void *args)
异步函数的具体实现如下,对应时序图中的connect:(void *)->void *
异步函数。
//C++
//native-lib.cpp
//...
VideoChannel *videoChannel = 0;
AudioChannel *audioChannel = 0;
JavaVM *javaVM = 0;
JavaCallHelper *helper = 0;
pthread_t pid;
char *path = 0;
RTMP *rtmp = 0;
uint64_t startTime;
//...
void *connect(void *args) {
int ret;
rtmp = RTMP_Alloc();
RTMP_Init(rtmp);
do {
//解析url地址(可能失败,地址不合法)
ret = RTMP_SetupURL(rtmp, path);
if (!ret) {
//TODO:通知Java地址传的有问题(未实现)
break;
}
//开启输出模式,仅拉流播放的话,不需要开启
RTMP_EnableWrite(rtmp);
ret = RTMP_Connect(rtmp, 0);
if (!ret) {
//TODO:通知Java服务器连接失败(未实现)
break;
}
ret = RTMP_ConnectStream(rtmp, 0);
if (!ret) {
//TODO:通知Java未连接到流(未实现)
break;
}
//发送audio specific config(告诉播放器怎么解码我推流的音频)
RTMPPacket *packet = audioChannel->getAudioConfig();
callback(packet);
} while (false);
//TODO:清理变量空间,防止内存泄漏
if (!ret) {
RTMP_Close(rtmp);
RTMP_Free(rtmp);
rtmp = 0;
}
delete (path);
path = 0;
//TODO:通知Java层可以开始推流了
helper->onParpare(ret);
startTime = RTMP_GetTime();
return 0;
}
借助JavaCallHelper告知Java层连接成功
native层连接成功之后,通过以下代码调用Java层的rtmpClient.onPrepare()
方法,以下代码对应时序图中的helper.onPrepare:(jboolean,int)->void
。
//C++
//JavaCallHelper.cpp
void JavaCallHelper::onParpare(jboolean isConnect, int thread) {
if (thread == THREAD_CHILD) {
JNIEnv *jniEnv;
if (javaVM->AttachCurrentThread(&jniEnv, 0) != JNI_OK) {
return;
}
jniEnv->CallVoidMethod(jobj, jmid_prepare, isConnect);
javaVM->DetachCurrentThread();
} else {
env->CallVoidMethod(jobj, jmid_prepare);
}
}
3.2 视频编码
3.2.1 时序图
成功连接上流媒体服务器后,rtmpClient.isConnected
标识被设置成true
,CameraX
在执行回调analyze()
时,将能进入视频编码操作,时序图如下:
3.2.2 过程描述
其实,在用户进入APP初始化CameraX
并开始预览时,就已经开始执行analyze()
回调了,当时的rtmpClient.isConnected
标识为false
,当该标识被设置成true
后,视频编码过程大体分为两个步骤:
- 第一步,获取图像字节;
- 第二步,发送图像数据。
3.2.3 关键代码
获取图像字节
针对1.2.3中的六种情况,我们对每一个plane的每一行以一个字节为单位进行处理。以下代码对应时序图中的[rtmpClient.isConnected==true]ImageUtils.getBytes:(ImageProxy,int,int,int)->byte[]
部分:
//Java
//ImageUtils.java
package com.tongbo.mycameralive;
import android.graphics.ImageFormat;
import androidx.camera.core.ImageProxy;
import java.nio.ByteBuffer;
public class ImageUtils {
static {
System.loadLibrary("ImageUtils");
}
static ByteBuffer i420;
static byte[] scaleBytes;
public static byte[] getBytes(ImageProxy image, int rotationDegrees, int width, int height) {
int format = image.getFormat();
if (format != ImageFormat.YUV_420_888) {
//抛出异常
}
//创建一个ByteBuffer i420对象,其字节数是height*width*3/2,存放最后的I420图像数据
int size = height * width * 3 / 2;
//TODO:防止内存抖动
if (i420 == null || i420.capacity() < size) {
i420 = ByteBuffer.allocate(size);
}
i420.position(0);
//YUV planes数组
ImageProxy.PlaneProxy[] planes = image.getPlanes();
//TODO:取出Y数据,放入i420
int pixelStride = planes[0].getPixelStride();
ByteBuffer yBuffer = planes[0].getBuffer();
int rowStride = planes[0].getRowStride();
//1.若rowStride等于Width,skipRow是一个空数组
//2.若rowStride大于Width,skipRow就刚好可以存储每行多出来的几个byte
byte[] skipRow = new byte[rowStride - width];
byte[] row = new byte[width];
for (int i = 0; i < height; i++) {
yBuffer.get(row);
i420.put(row);
//1.若不是最后一行,将无效占位数据放入skipRow数组
//2.若是最后一行,不存在无效无效占位数据,不需要处理,否则报错
if (i < height - 1) {
yBuffer.get(skipRow);
}
}
//TODO:取出U/V数据,放入i420
for (int i = 1; i < 3; i++) {
ImageProxy.PlaneProxy plane = planes[i];
pixelStride = plane.getPixelStride();
rowStride = plane.getRowStride();
ByteBuffer buffer = plane.getBuffer();
int uvWidth = width / 2;
int uvHeight = height / 2;
//一次处理一行
for (int j = 0; j < uvHeight; j++) {
//一次处理一个字节
for (int k = 0; k < rowStride; k++) {
//1.最后一行
if (j == uvHeight - 1) {
//1.I420:UV没有混合在一起,rowStride大于等于Width/2,如果是最后一行,不理会占位数据
if (pixelStride == 1 && k >= uvWidth) {
break;
}
//2.NV21:UV混合在一起,rowStride大于等于Width-1,如果是最后一行,不理会占位数
if (pixelStride == 2 && k >= width - 1) {
break;
}
}
//2.非最后一行
byte b = buffer.get();
//1.I420:UV没有混合在一起,仅保存索引为偶数的有效数据,不理会占位数据
if (pixelStride == 1 && k < uvWidth) {
i420.put(b);
continue;
}
//2.NV21:UV混合在一起,仅保存索引为偶数的有效数据,不理会占位数据
if (pixelStride == 2 && k < width - 1 && k % 2 == 0) {
i420.put(b);
continue;
}
}
}
}
//TODO:将i420数据转成byte数组,执行旋转,并返回
int srcWidth = image.getWidth();
int srcHeight = image.getHeight();
byte[] result = i420.array();
if (rotationDegrees == 90 || rotationDegrees == 270) {
result = rotate(result, width, height, rotationDegrees);
srcWidth = image.getHeight();
srcHeight = image.getWidth();
}
if (srcWidth != width || srcHeight != height) {
//todo jni对scaleBytes修改值,避免内存抖动
int scaleSize = width * height * 3 / 2;
if (scaleBytes == null || scaleBytes.length < scaleSize) {
scaleBytes = new byte[scaleSize];
}
scale(result, scaleBytes, srcWidth, srcHeight, width, height);
return scaleBytes;
}
return result;
}
private static native byte[] rotate(byte[] data, int width, int height, int degress);
private native static void scale(byte[] src, byte[] dst, int srcWidth, int srcHeight, int dstWidth, int dstHeight);
}
★其中,native
函数rotate()
的实现如下,图像旋转的核心代码是libyuv::I420Rotate()
函数。之所以需要执行旋转是因为,数据默认有90度或270度的偏移,需要手动还原。
//C++
//ImageUtils.cpp
#include <jni.h>
#include <libyuv.h>
extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_tongbo_mycameralive_ImageUtils_rotate(JNIEnv *env, jclass thiz, jbyteArray data_, jint width,
jint height, jint degress) {
//TODO:为libyuv::I420Rotate()函数准备传入参数
jbyte *data = env->GetByteArrayElements(data_, 0);
uint8_t *src = reinterpret_cast<uint8_t *>(data);
int ySize = width * height;
int uSize = (width >> 1) * (height >> 1);
int size = (ySize * 3) >> 1;
uint8_t dst[size];
uint8_t *src_y = src;
uint8_t *src_u = src + ySize;
uint8_t *src_v = src + ySize + uSize;
uint8_t *dst_y = dst;
uint8_t *dst_u = dst + ySize;
uint8_t *dst_v = dst + ySize + uSize;
//TODO:调用libyuv::I420Rotate()函数
libyuv::I420Rotate(src_y, width, src_u, width >> 1, src_v, width >> 1,
dst_y, height, dst_u, height >> 1, dst_v, height >> 1,
width, height, static_cast<libyuv::RotationMode>(degress));
//TODO:准备返回值
jbyteArray result = env->NewByteArray(size);
env->SetByteArrayRegion(result, 0, size, reinterpret_cast<const jbyte *>(dst));
env->ReleaseByteArrayElements(data_, data, 0);
return result;
}
★native
函数scale()
的实现是:
//C++
//ImageUtils.cpp
extern "C"
JNIEXPORT void JNICALL
Java_com_tongbo_mycameralive_ImageUtils_scale(JNIEnv *env, jclass clazz, jbyteArray src_, jbyteArray dst_,
jint srcWidth,
jint srcHeight, jint dstWidth, jint dstHeight) {
jbyte *data = env->GetByteArrayElements(src_, 0);
uint8_t *src = reinterpret_cast<uint8_t *>(data);
int64_t size = (dstWidth * dstHeight * 3) >> 1;
uint8_t dst[size];
uint8_t *src_y;
uint8_t *src_u;
uint8_t *src_v;
int src_stride_y;
int src_stride_u;
int src_stride_v;
uint8_t *dst_y;
uint8_t *dst_u;
uint8_t *dst_v;
int dst_stride_y;
int dst_stride_u;
int dst_stride_v;
src_stride_y = srcWidth;
src_stride_u = srcWidth >> 1;
src_stride_v = src_stride_u;
dst_stride_y = dstWidth;
dst_stride_u = dstWidth >> 1;
dst_stride_v = dst_stride_u;
int src_y_size = srcWidth * srcHeight;
int src_u_size = src_stride_u * (srcHeight >> 1);
src_y = src;
src_u = src + src_y_size;
src_v = src + src_y_size + src_u_size;
int dst_y_size = dstWidth * dstHeight;
int dst_u_size = dst_stride_u * (dstHeight >> 1);
dst_y = dst;
dst_u = dst + dst_y_size;
dst_v = dst + dst_y_size + dst_u_size;
libyuv::I420Scale(src_y, src_stride_y,
src_u, src_stride_u,
src_v, src_stride_v,
srcWidth, srcHeight,
dst_y, dst_stride_y,
dst_u, dst_stride_u,
dst_v, dst_stride_v,
dstWidth, dstHeight,
libyuv::FilterMode::kFilterNone);
env->ReleaseByteArrayElements(src_, data, 0);
env->SetByteArrayRegion(dst_, 0, size, reinterpret_cast<const jbyte *>(dst));
}
x264视频编码
借助x264库的API进行视频编码,以下代码对应时序图中的videoChannel.encode:(uint8_t *)->void
部分:
//C++
//VideoChannel.cpp
void VideoChannel::encode(uint8_t *data) {
//输出的待编码数据
x264_picture_t pic_in;
x264_picture_alloc(&pic_in, X264_CSP_I420, width, height);
pic_in.img.plane[0] = data;
pic_in.img.plane[1] = data + ySize;
pic_in.img.plane[2] = data + ySize + uSize;
//TODO:编码的i_pts,每次需要增长
pic_in.i_pts = i_pts++;
x264_picture_t pic_out;
x264_nal_t *pp_nal;
int pi_nal;
//pi_nal:输出了多少nal
int error = x264_encoder_encode(codec, &pp_nal, &pi_nal, &pic_in, &pic_out);
if (error <= 0) {
return;
}
int spslen, ppslen;
uint8_t *sps;
uint8_t *pps;
for (int i = 0; i < pi_nal; ++i) {
int type = pp_nal[i].i_type;
//数据
uint8_t *p_payload = pp_nal[i].p_payload;
//数据长度
int i_payload = pp_nal[i].i_payload;
if (type == NAL_SPS) {
//sps后面肯定跟着pps
spslen = i_payload - 4; //去掉间隔 00 00 00 01
sps = (uint8_t *) alloca(spslen); //栈中申请,不需要释放
memcpy(sps, p_payload + 4, spslen);
} else if (type == NAL_PPS) {
ppslen = i_payload - 4; //去掉间隔 00 00 00 01
pps = (uint8_t *) alloca(ppslen);
memcpy(pps, p_payload + 4, ppslen);
//pps后面肯定有I帧,发I帧之前要发一个sps与pps
sendVideoConfig(sps, pps, spslen, ppslen);
} else {
sendFrame(type, p_payload, i_payload);
}
}
}
x264包装视频配置信息
根据x264和视频格式标准,视频配置信息帧和视频图像数据帧的头不一样,因此分了两个函数分别包装数据,以下代码对应时序图中的[data[i] is config]sendVideoConfig:(uint8_t *,uint8_t *,int,int)->void
部分:
//C++
//VideoChannel.cpp
void VideoChannel::sendVideoConfig(uint8_t *sps, uint8_t *pps, int spslen, int ppslen) {
int bodySize = 13 + spslen + 3 + ppslen;
RTMPPacket *packet = new RTMPPacket;
RTMPPacket_Alloc(packet, bodySize);
int i = 0;
//固定头
packet->m_body[i++] = 0x17;
//类型
packet->m_body[i++] = 0x00;
//composition time 0x000000
packet->m_body[i++] = 0x00;
packet->m_body[i++] = 0x00;
packet->m_body[i++] = 0x00;
//版本
packet->m_body[i++] = 0x01;
//编码规格
packet->m_body[i++] = sps[1];
packet->m_body[i++] = sps[2];
packet->m_body[i++] = sps[3];
packet->m_body[i++] = 0xFF;
//整个sps
packet->m_body[i++] = 0xE1;
//sps长度
packet->m_body[i++] = (spslen >> 8) & 0xff;
packet->m_body[i++] = spslen & 0xff;
memcpy(&packet->m_body[i], sps, spslen);
i += spslen;
//pps
packet->m_body[i++] = 0x01;
packet->m_body[i++] = (ppslen >> 8) & 0xff;
packet->m_body[i++] = (ppslen) & 0xff;
memcpy(&packet->m_body[i], pps, ppslen);
packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
packet->m_nBodySize = bodySize;
packet->m_headerType = RTMP_PACKET_SIZE_MEDIUM;
//时间戳 sps与pps(不是图像) 没有时间戳
packet->m_nTimeStamp = 0;
//使用相对时间
packet->m_hasAbsTimestamp = 0;
//随便给一个通道 ,避免rtmp.c中使用的就行
packet->m_nChannel = 0x10;
callback(packet);
}
x264包装视频图像数据
以下代码对应时序图中的[data[i] is config]sendFrame:(int,uint8_t *,int)->void
部分:
//C++
//VideoChannel.cpp
void VideoChannel::sendFrame(int type, uint8_t *p_payload, int i_payload) {
//去掉 00 00 00 01 / 00 00 01
if (p_payload[2] == 0x00) {
i_payload -= 4;
p_payload += 4;
} else if (p_payload[2] == 0x01) {
i_payload -= 3;
p_payload += 3;
}
RTMPPacket *packet = new RTMPPacket;
int bodysize = 9 + i_payload;
RTMPPacket_Alloc(packet, bodysize);
RTMPPacket_Reset(packet);
//int type = payload[0] & 0x1f;
packet->m_body[0] = 0x27;
//关键帧
if (type == NAL_SLICE_IDR) {
packet->m_body[0] = 0x17;
}
//类型
packet->m_body[1] = 0x01;
//时间戳
packet->m_body[2] = 0x00;
packet->m_body[3] = 0x00;
packet->m_body[4] = 0x00;
//数据长度 int 4个字节 相当于把int转成4个字节的byte数组
packet->m_body[5] = (i_payload >> 24) & 0xff;
packet->m_body[6] = (i_payload >> 16) & 0xff;
packet->m_body[7] = (i_payload >> 8) & 0xff;
packet->m_body[8] = (i_payload) & 0xff;
//图片数据
memcpy(&packet->m_body[9], p_payload, i_payload);
packet->m_hasAbsTimestamp = 0;
packet->m_nBodySize = bodysize;
packet->m_packetType = RTMP_PACKET_TYPE_VIDEO;
packet->m_nChannel = 0x10;
packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
callback(packet);
}
3.3 音频编码
3.3.1 时序图
3.3.2 过程描述
- 以上时序图从
JavaCallHelper
调用rtmpClient.onPrepare()
方法开始,在audioChannel.start()
方法中将音频编码任务通过post(new Runnable(){...})
提交到handler
绑定的looper
。 - 在音频编码任务中,首先创建一个
AudioRecord
对象——audioRecord
,该对象由audioChannel
执有,之后利用该audioRecord
开始录音,并循环读取,直到退出录音状态。
3.3.3 关键代码
提交音频编码任务
上面调用了rtmpClient.onPrepare()
方法,在该方法内,设置了isConnected
标识为true
;然后,调用了audioChannel.start()
方法,将音频编码任务提交到“Audio-Recode”线程,以下代码对应时序图中的audioChannel.start:()->void
和handler.post:(Runnable)->boolean
部分:
//Java
//AudioChannel.java
public void start() {
handler.post(new Runnable() {
@Override
public void run() {
audioRecord = new AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate,
channelConfig,
AudioFormat.ENCODING_PCM_16BIT,
minBufferSize
);
audioRecord.startRecording();
while (audioRecord.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
int len = audioRecord.read(buffer, 0, buffer.length);
if (len > 0) {
//样本数=字节数/2字节(16位)
rtmpClient.sendAudio(buffer, len >> 1);
}
}
}
});
}
音频编码
以下代码对应时序图的audioChannel.encode:(int32_t *)->void
部分:
//C++
//AudioChannel.cpp
void AudioChannel::encode(int32_t *data, int len) {
//len:输入的样本数
//outputBuffer:输出,编码之后的结果
//maxOutputBytes:编码结果缓存区能接收数据的个数
int bytelen = faacEncEncode(codec, data, len, outputBuffer, maxOutputBytes);
if (bytelen > 0) {
RTMPPacket *packet = new RTMPPacket;
RTMPPacket_Alloc(packet, bytelen + 2);
packet->m_body[0] = 0xAF;
packet->m_body[1] = 0x01;
memcpy(&packet->m_body[2], outputBuffer, bytelen);
packet->m_hasAbsTimestamp = 0;
packet->m_nBodySize = bytelen + 2;
packet->m_packetType = RTMP_PACKET_TYPE_AUDIO;
packet->m_nChannel = 0x11;
packet->m_headerType = RTMP_PACKET_SIZE_LARGE;
callback(packet);
}
}
3.4 音视频推流
应该注意到,每一次按照协议包装好一个音频帧或者视频帧后,都会调用一个callback(RTMPPacket *packet)
回调,利用RTMPDump库将数据发送出去。音视频使用的是同一个callback(RTMPPacket *packet)
回调,只是传入的数据分别是音频和视频罢了。
- 该
callback()
回调的定义是
//C++
//Callback.h
#ifndef PUSHER_CALLBACK_H
#define PUSHER_CALLBACK_H
#include <rtmp.h>
typedef void (*Callback)(RTMPPacket *);
#endif //PUSHER_CALLBACK_H
- 其具体实现是
//C++
//native-lib.cpp
void callback(RTMPPacket *packet) {
if (rtmp) {
packet->m_nInfoField2 = rtmp->m_stream_id;
//使用相对时间
packet->m_nTimeStamp = RTMP_GetTime() - startTime;
//放到队列中
RTMP_SendPacket(rtmp, packet, 1);
}
RTMPPacket_Free(packet);
delete (packet);
}
4 摄像头切换
4.1 时序图
4.2 过程描述
从时序图可以看出,基于CameraX
的摄像头切换实现十分简单,当用户点击切换摄像头按钮,首先调用MainActivity
中绑定的方法,MainActivity
与RtmpClient
的对象rtmpClient
交互,通过rtmpClient
分别操作音视频,最终在rtmpClient.toggleCamera()
方法中调用到具体切换摄像头的实现videoChannel.toggleCamera()
方法。
4.3 关键代码
具体切换摄像头的实现videoChannel.toggleCamera()
方法:
//Java
//VideoChannel.java
public void toggleCamera() {
CameraX.unbindAll();
if (currentFacing == CameraX.LensFacing.BACK) {
currentFacing = CameraX.LensFacing.FRONT;
} else {
currentFacing = CameraX.LensFacing.BACK;
}
CameraX.bindToLifecycle(lifecycleOwner, getPreView(), getAnalysis());
}
5 停止直播
5.1 时序图
5.2 过程描述
以上过程从用户点击停止直播按钮开始。首先MainActivity
中绑定按钮的stopLive(View view)
方法被调用,调用rtmpClient.stop()
方法,分别去停止音视频的直播。
- 视频:注意!停止直播并不代表退出APP,因此还是需要保留视频预览,所以时序图中对视频的处理主要在于对视频编码器的重置
i_pts=0
,以及在CameraX
的analyze()
回调中都会检查的标志位rtmpClient.isConnected
,将该标志位置为false
。 - 音频:关键需要调用
audioRecord.stop()
方法,停止录音。 - RTMP:在
JNI_disConnect:(JNIEnv *,jobject)->void
函数中,释放rtmp
指针指向的内容。
5.3 关键代码
JNI层断开连接
JNI_disConnect:(JNIEnv *,jobject)->void
函数:
//C++
//native-lib.cpp
extern "C"
JNIEXPORT void JNICALL
Java_com_tongbo_mycameralive_RtmpClient_disConnect(JNIEnv *env, jobject thiz) {
pthread_mutex_lock(&mutex);
if (rtmp) {
RTMP_Close(rtmp);
RTMP_Free(rtmp);
rtmp = 0;
}
if (videoChannel) {
videoChannel->resetPts();
}
pthread_mutex_unlock(&mutex);
}
6 退出应用
6.1 时序图
6.2 过程描述
当Android的执行生命周期回调onDestroy()
方法时,我们重写onDestroy()
方法,并在其中调用rtmpClient.release()
方法,然后在其中分别停止和释放内存资源。其中核心的释放过程已经在时序图中标红。至此,此基础直播APP所有功能都已经实现。
欢迎找茬。
作者:乐为
链接:https://juejin.im/post/5e0b2627e51d45412862a921
来源:掘金