全手动写Android摄像头直播应用

1 前言

1.1 总览

短文将记录一个基本的摄像头直播APP开发的全部流程和技术点。项目使用x264进行视频数据处理,使用FAAC进行音频数据处理,使用RTMP协议进行数据推流,整个过程的大体如下。

整个APP实现了以下功能:

  • 直播前视频预览
  • 开始直播、编码、推流
  • 切换摄像头
  • 停止直播
  • 退出应用

短文将以各个功能为切入点记录各个技术点。

1.2 CameraX

1.2.1 简介

引自developer.android

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,显然,该ImageProxyImage的一个代理,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中的NV21I420两种格式。

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[] planesplanes[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个第三方库:

  1. x264用于视频数据处理;
  2. FAAC用于音频数据处理;
  3. libyuv用于图像数据处理(旋转、缩放);
  4. 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执有该对象。
  • 第二步,调用对象rtmpClientinitVideo()方法,在该方法中,创建Java层的VideoChannel对象,由rtmpClient执有该对象;在VideoChannel的构造方法中,新建并开启了一个“Analyze-Thread”线程,在该新线程中,会周期调用CameraX提供的analyze()回调,在该回调中,我们首先监测rtmpClient.isConnected标识,判断是否处于直播状态,若是,则进入编码推流操作;否则,维持当前的仅预览操作。完成VideoChannel的构造操作之后,initVideo()继续调用native方法initVideoEnc(),对native层的视频编码器进行初始化,时刻准备进入直播状态。
  • 第三步,调用对象rtmpClientinitAudio()方法,在该方法中,创建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(&param, "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(&param, "baseline");
    codec = x264_encoder_open(&param);
    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对象——helperonPrepare()方法,调用Java层的rtmpClient对象的onPrepare()方法;在这个方法内,第一,设置rtmpClientisConnected标识为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标识被设置成trueCameraX在执行回调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:()->voidhandler.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中绑定的方法,MainActivityRtmpClient的对象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,以及在CameraXanalyze()回调中都会检查的标志位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
来源:掘金

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容