Android视音频开发初探【二】(简单的相机推流器)

1,907 阅读16分钟

上一篇 Android视音频开发初探【一】(clang编译FFmpeg+fdk-aac+x264+openssl)
下一篇 Android视音频开发初探【三】(简单的播放器)

demo地址github.com/ColorfulHor…

前言

上一篇博客中我们已经成功编译出了 FFmpeg动态库,现在来使用它实现一个简单的推流器,以此逐渐熟悉一些C/C++知识,NDK以及CMake知识,java层代码为kotlin编写

简单梳理一下相机推流流程

  1. 搭建流媒体服务器
  2. 采集相机数据
  3. 编码相机数据为H.264流
  4. 推流到流媒体服务器

搭建流媒体服务器

要推流首先需要一个流媒体服务器来收流,可以使用nginx-rtmp搭建,也可以使用srs
nginx-rtmp可以参照这篇博客 搭建RTMP服务器
srs由于是国人开发的,可以直接去看wiki srs wiki
如果你暂时不想去弄这些,也可以先使用我搭建好的,地址在demo里面有,不过不太稳定,可能容易连接失败

构建项目

配置项目

可以直接创建一个包含native的项目,也可以在现有项目通过添加配置引入,这里我们使用CMake来构建。
app gradle文件如下

android {
    .....
        ndk {
            // app build时检查哪些平台的库
            abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
        }

        externalNativeBuild {
            cmake {
                arguments "-DANDROID_STL=c++_shared"
                cppFlags "-std=c++11  -frtti -fexceptions"
                // cmake 构建哪些平台的库
                abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
            }
        }
    }
    externalNativeBuild {
        cmake {
            // 此路径指定了native代码的根目录
            path "src/main/cpp/CMakeLists.txt"
            version "3.10.2"
        }
    }
    sourceSets {
        main {
            jniLibs.srcDirs = ['libs']
        }
    }
    ......
}

项目目录如下 然后将编译出的库文件和头文件分别放入libs和include目录,src目录则是我们存放.c/.cpp文件的地方

编写CMakeLists构建文件

关于cmake这里只需要知道一些简单的用法,这里无非就是做了下面几件事

  • 通过file指令将src文件夹下面的所有.cpp文件抽取赋值给SRC_LIST变量,将它添加为动态库
  • include_directories指定头文件路径
  • 指定库文件路径,依次添加所有需要依赖的动态库,同时添加log库(这个库android系统内部自带)
  • 链接动态库

CMake文件写完以后直接make project就可以生成动态库了,它将会输出到你指定的路径,要注意的是src目录下至少需要一个源文件

cmake_minimum_required(VERSION 3.4)
project(lyjplayer)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11 -frtti -fexceptions")
#获取上级目录
get_filename_component(PARENT_DIR "${PROJECT_SOURCE_DIR}" PATH)
get_filename_component(SRC_DIR "${PARENT_DIR}" PATH)
get_filename_component(APP_DIR "${SRC_DIR}" PATH)
# GLOB将所有匹配的文件生成一个list赋值给SRC_LIST
file(GLOB SRC_LIST "${PROJECT_SOURCE_DIR}/src/*.cpp")
# 输出.so库位置
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
        ${APP_DIR}/output/${CMAKE_ANDROID_ARCH_ABI})
# 第三方库目录
set(LIBS_DIR ${APP_DIR}/libs/${CMAKE_ANDROID_ARCH_ABI})
# 头文件目录
include_directories(
        ${PROJECT_SOURCE_DIR}/include
)
# 生成动态库
add_library(lyjplayer SHARED ${SRC_LIST})
# 编解码(最重要的库)
add_library(avcodec SHARED IMPORTED)
# 设备信息
add_library(avdevice SHARED IMPORTED)
# 滤镜特效处理库
add_library(avfilter SHARED IMPORTED)
# 封装格式处理库
add_library(avformat SHARED IMPORTED)

add_library(avutil SHARED IMPORTED)
# 音频采样数据格式转换库
add_library(swresample SHARED IMPORTED)
# 视频像素数据格式转换
add_library(swscale SHARED IMPORTED)
# 后处理
add_library(postproc SHARED IMPORTED)
add_library(yuv SHARED IMPORTED)

find_library(log-lib log)

set_target_properties(avcodec PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libavcodec.so)
set_target_properties(avdevice PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libavdevice.so)
set_target_properties(avfilter PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libavfilter.so)
set_target_properties(avformat PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libavformat.so)
set_target_properties(avutil PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libavutil.so)
set_target_properties(swresample PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libswresample.so)
set_target_properties(swscale PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libswscale.so)
set_target_properties(postproc PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libpostproc.so)
set_target_properties(yuv PROPERTIES IMPORTED_LOCATION ${LIBS_DIR}/libyuv.so)
# 指定生成版本号,VERSION指代动态库版本,SOVERSION指代API版本
set_target_properties(lyjplayer PROPERTIES VERSION 1.2 SOVERSION 1)
target_link_libraries(
        lyjplayer#目标库
        # 依赖库,可以写多个
        log
        android
        avcodec
        avdevice
        avfilter
        avformat
        avutil
        swresample
        swscale
        postproc
        yuv
)

获取相机预览数据

支持格式

相机这部分我使用的是Camera2的API,不得不说比较难用,不太建议用。如果对Camera2没兴趣可以跳过这一节直接用Camera API实现,毕竟最终只需要拿到预览数据就行了。要注意的是由于要使用x264进行编码,相机的预览数据的格式最终要转换成为x264支持的格式,x264支持格式如下。

开启相机预览并捕获数据

  1. 启动一个handlerThread用来操作camera(官方建议),准备一个TextureView(使用surfaceView也可以)作为预览载体
private fun setup() {
        // 对于camera
        startBackgroundThread()
        if (preview.isAvailable) {
            mSurfaceTexture = preview.surfaceTexture
            openBackCamera()
        } else {
            preview.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
                override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
                    mSurfaceTexture = surface
                }

                override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
                    mSurfaceTexture = surface
                }

                override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
                    mSurfaceTexture = null
                    return true
                }

                override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
                    mSurfaceTexture = surface
                    openBackCamera()
                }
            }
        }
    }
  1. 打开相机,Camera2要通过getSystemService拿到CameraManager服务,然后遍历过滤拿到需要的摄像头。这里需要一个支持YUV_420_888的后置摄像头;ImageFormat.YUV_420_888是一个特殊的格式,后面我们组装yuv420的时候再讲。
private fun openBackCamera() {
        val manager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
        var cameraId = ""
        // 遍历所有摄像头,找到支持YUV_420_888输出格式的后置摄像头
        for (id in manager.cameraIdList) {
            // 获取摄像头特征
            val cameraInfo = manager.getCameraCharacteristics(id)
            // 支持的硬件等级
            val level = cameraInfo[CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL]
            val previewFormat = ImageFormat.YUV_420_888
            //if (level == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_FULL || level == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_3) {
            // 是否后置
            if (cameraInfo[CameraCharacteristics.LENS_FACING] == CameraCharacteristics.LENS_FACING_BACK) {
                val map = cameraInfo.get(
                    CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
                ) ?: continue
                if (!map.isOutputSupportedFor(previewFormat)) {
                    continue
                }
                cameraId = id
                mCameraInfo = cameraInfo
                break
            }
            //}
        }
        // 通过cameraId打开摄像头
        if (cameraId.isNotBlank()) {
            manager.openCamera(cameraId, object : CameraDevice.StateCallback() {
                override fun onOpened(camera: CameraDevice) {
                    mCamera = camera
                    // 创建Session
                    createSession()
                }

                override fun onDisconnected(camera: CameraDevice) {
                }

                override fun onError(camera: CameraDevice, error: Int) {
                    camera.close()
                }

            }, backgroundHandler)
        }
    }
  1. 创建输出,camera2无论是预览还是拍照还是接收数据都需要传入surface作为输出对象,这边我们创建两个输出对象,一个用于预览画面(分辨率较高),一个用于接收数据(分辨率较低),预览画面用的surface通过textureView的surfaceTexture创建,接收数据的通过ImageReader创建,ImageReader是一个专门用来接收图像数据的类。
private fun createOutputs() {
        mCameraInfo?.let { info ->
            val map = info[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP]
            map?.let { config ->
                // 获取预览分辨率,这里预览画面是全屏的,通过屏幕尺寸计算获取最接近的相机支持分辨率
                val previewSize =
                    getOptimalSize(
                        config.getOutputSizes(SurfaceTexture::class.java),
                        previewWidth,
                        previewHeight
                    )
                // 获取预览数据分辨率,获取最接近480x640的
                val previewDataSize =
                    getOptimalSize(config.getOutputSizes(SurfaceTexture::class.java), 480, 640)
                this.previewDataSize = previewDataSize;
                // 创建接收预览数据的imageReader
                val previewReader =
                    ImageReader.newInstance(
                        previewDataSize.width,
                        previewDataSize.height,
                        ImageFormat.YUV_420_888,
                        3
                    )
                mPreviewReader = previewReader
                // 设置imageReader回调,相机的帧画面数据将会会掉到imageListener中
                previewReader.setOnImageAvailableListener(imageListener, backgroundHandler)
                // 接收预览数据用的surface
                previewDataSurface = previewReader.surface

                mSurfaceTexture?.run {
                    // 设置surfaceTexture的实际尺寸
                    setDefaultBufferSize(previewSize.width, previewSize.height)
                     // 预览画面用的surface
                    val surface = Surface(this)
                    previewSurface = surface
                }
            }
        }
    }
  1. 创建CaptureSession,CaptureSession是实际用来操作相机的类,你可以把它当场camera的代理。
private fun createSession() {
        mCamera?.let { camera ->
            createOutputs()
            // 输出对象
            val outputs = listOf(previewSurface, previewDataSurface)
            camera.createCaptureSession(
                outputs,
                object : CameraCaptureSession.StateCallback() {

                    override fun onConfigureFailed(session: CameraCaptureSession) {
                    }

                    override fun onConfigured(session: CameraCaptureSession) {
                        // 创建session成功,开始预览
                        mSession = session
                        startPreview()
                    }

                    override fun onClosed(session: CameraCaptureSession) {
                        super.onClosed(session)
                    }

                },
                backgroundHandler
            )
        }
    }
  1. 开始预览;通过CameraCaptureSession发起预览,session对相机进任何行操作都以发送request的方式进行,比如拍照、连拍、预览,都使用createCaptureRequest创建,只是类型不同。
private fun startPreview() {
        mCamera?.let { camera ->
            mSession?.let { session ->
                // 创建一个适用于预览的request
                val builder = camera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
                // 绑定预览画面和数据的surface
                previewSurface?.let { builder.addTarget(it) }
                previewDataSurface?.let { builder.addTarget(it) }
                // 自动对焦
                builder[CaptureRequest.CONTROL_AF_MODE] =
                    CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE
                builder[CaptureRequest.CONTROL_AE_MODE] =
                    CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH
                val request = builder.build()
                mSession = session
                // 请求预览
                session.setRepeatingRequest(
                    request, object : CameraCaptureSession.CaptureCallback() {
                        override fun onCaptureStarted(session: CameraCaptureSession, request: CaptureRequest, timestamp: Long, frameNumber: Long) {
                            // 预览成功
                            super.onCaptureStarted(session, request, timestamp, frameNumber)
                        }

                        override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) {
                        }
                    },
                    backgroundHandler
                )
            }
        }
    }
  1. 获取相机每一帧的YUV数据并组合成YUV420p。

由于设置的接收类型为YUV_420_888,我们需要做一些转换,YUV_420_888格式将Y U V三个分量分别存在三个通道的ByteBuffer里面,y通道buffer的大小为 plane.rowStride*height(rowStride为每一行的实际数据长度),u v 分量的buffer size是y的一半。

u v 的buffer中其实都存有完整的u分量和v分量,在u通道里下标0、2、4...存有u分量,1、3、5...存有v分量;而在v通道里0、2、4...存有v分量,1、3、5...存有u分量。我们这里将Y U V分别取出来按顺序装到一个byte[]中组成yuvi420格式。

有关YUV_420_888更详细的解释可以看Android: Image类浅析(结合YUV_420_888)

另外这里还有一个内存对齐的问题,比如说摄像头内存对齐规定为16byte,输出分辨率宽度为500,那么rowStride因为内存对齐会变成512,就是16的倍数,但是实际上多出来的数据我们并不需要,所以需要处理一下,关于此格式内存对齐更详细的解释可以看这里Android的YUV_420_888图片转换Bitmap时的rowStride问题

private val imageListener = { reader: ImageReader ->
        val image = reader.acquireNextImage()
        if (image != null) {
            // y u v三通道
            val yBuffer = image.planes[0].buffer

            val uBuffer = image.planes[1].buffer
            // 两像素之间u分量的间隔,yuv420中uv为1
            val uStride = image.planes[1].pixelStride

            val vBuffer = image.planes[2].buffer

            val uvSize = image.width * image.height / 4
            // yuvi420: Y:U:V = 4:1:1 = YYYYUV
            val buffer = ByteArray(image.width * image.height * 3 / 2)
            // 每一行的实际数据长度,可能因为内存对齐大于图像width
            val rowStride = image.planes[0].rowStride
            // 内存对齐导致每行末多出来的长度
            val padding = rowStride - image.width
            var pos = 0
            // 将y buffer拼进去
            if (padding == 0) {
                pos = yBuffer.remaining()
                yBuffer.get(buffer, 0, pos)
            } else {
                var yBufferPos = 0
                for (row in 0 until image.height) {
                    yBuffer.position(yBufferPos)
                    yBuffer.get(buffer, pos, image.width)
                    // 忽略行末冗余数据,偏移到下一行的位置
                    yBufferPos += rowStride
                    pos += image.width
                }
            }

            var i = 0

            val uRemaining = uBuffer.remaining()
            while (i < uRemaining) {
                // 循环u v buffer,隔一个取一个
                buffer[pos] = uBuffer[i]
                buffer[pos+uvSize] = vBuffer[i]
                pos++
                i += uStride

                if (padding == 0) continue
                // 并跳过每一行冗余数据
                val rowLen = i % rowStride
                if (rowLen >= image.width) {
                    i += padding
                }
            }
            // 调用native方法将byte[]丢到推流器队列,方法实现看后面native层部分
            publisher.publishData(buffer)
            image.close()
        }
    }

定义JNI接口

JNI简单介绍

JNI是java与native层交互的桥梁,通过在java代码中定义native方法,然后在c代码中定义相应的方法建立一个映射关系,达到互相调用的目的;建立映射关系的方法有静态注册和动态注册两种。由于Java层和Native层享有不同的内存空间,在编写native代码的时候要注意内存管理。

静态注册

静态注册比较简单,规定了native层方法必须写成特定的格式,方法名需要带上java类的包名
java层

package com.lyj.learnffmpeg

class LyjPlayer {
    ......
    external fun initPlayer()
    
}

c层

extern "C"
JNIEXPORT void JNICALL
Java_com_lyj_learnffmpeg_LyjPlayer_initPlayer(JNIEnv *env, jobject thiz) {}

动态注册

动态注册比较灵活,方法名并没有格式规定,但是需要在写一些代码手动注册,本文就是用的动态注册。
java文件

class Publisher {
    ......
    external fun startPublish(path: String, width: Int, height: Int, orientation: Int): Int
}

cpp文件, 这里只需要看JNI_OnLoad中具体是如何注册的

template<class T>
int arrayLen(T &array) {
    return (sizeof(array) / sizeof(array[0]));
}

#ifdef __cplusplus
extern "C" {
#endif

const char *cls_publish = "com/lyj/learnffmpeg/Publisher";

Publisher *publisher = nullptr;
......
// 开始推流
int publisher_start_publish(JNIEnv *env, jobject thiz, jstring path, jint width, jint height,
                            jint orientation) {
    const char *p_path = nullptr;
    p_path = env->GetStringUTFChars(path, nullptr);
    if (publisher) {
        publisher->startPublish(p_path, width, height, orientation);
    }
    env->ReleaseStringUTFChars(path, p_path);
    return 0;
}

// 方法映射,(Ljava/lang/String;)I代表入参为String类型,返回值为int
JNINativeMethod player_methods[] = {
        {
        ......
        {"startPlay", "(Ljava/lang/String;)I", (void *) player_start_play}
        }
};

int jniRegisterNativeMethods(JNIEnv *env, const char *className, const JNINativeMethod *methods,
                             int count) {
    int res = -1;
    jclass cls = env->FindClass(className);
    if (cls != nullptr) {
        int ret = env->RegisterNatives(cls, methods, count);
        if (ret > 0) {
            res = 0;
        }
    }
    env->DeleteLocalRef(cls);
    return res;
}
// 在此方法中进行动态注册
JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env = nullptr;
    jint result = -1;
    if (vm->GetEnv((void **) (&env), JNI_VERSION_1_6) != JNI_OK) {
        return result;
    }
    // 动态注册,绑定java方法
    jniRegisterNativeMethods(env, cls_publish, publisher_methods, arrayLen(publisher_methods));
    return JNI_VERSION_1_6;
}

JNIEXPORT void JNI_OnUnload(JavaVM *jvm, void *reserved) {
}

#ifdef __cplusplus
}
#endif

可以看到动态注册方法映射的时候需要知道java方法的参数和返回值的类型签名,这点比较麻烦。

静态注册和动态注册各有优劣,静态注册在AndroidStudio中有更好的支持,可以通过点击java/c方法前的图标直接跳转到映射方法定义处,非常方便;而动态注册能让你更自由地管理代码。

编写推流逻辑

简单流程

  1. 创建一个队列,从java层接收相机数据,放入阻塞队列
  2. 开启另一个线程,不断从队列取帧数据编码推给流媒体服务器

代码实现

Publisher.kt,定义java层类

class Publisher {
    private val handler = Handler()

    companion object {
        // 状态码
        const val STATE_CONNECTED = 0
        const val STATE_START = STATE_CONNECTED + 1
        const val STATE_STOP = STATE_START + 1
        // 错误码
        const val CONNECT_ERROR = 0
        const val UNKNOW = CONNECT_ERROR + 1
        init {
            // 加载so库
            LibLoader.loadLib("lyjplayer")
        }
    }

    init {
        // 初始化
        initPublish()
    }
    // 初始化
    external fun initPublish()
    // 设置回调
    external fun setCallBack(callback: PublishCallBack)
    // 开始推流
    external fun startPublish(path: String, width: Int, height: Int, orientation: Int): Int

    external fun stopPublish(): Int
    // 推相机帧数据
    external fun publishData(data: ByteArray): Int

    external fun release()
    
    interface PublishCallBack {
    fun onState(state: Int)

    fun onError(code: Int)
    }

    fun setPublishListener(callback: PublishCallBack) {
        setCallBack(object : PublishCallBack {
            override fun onState(state: Int) {
                handler.post { callback.onState(state) }
            }
            override fun onError(code: Int) {
                handler.post { callback.onError(code) }
            }
        })
    }
}

register_jni.cpp, 主要用作jni动态注册,绑定java方法和native方法

#include <publisher.h>
#include <logger.h>

template<class T>
int arrayLen(T &array) {
    return (sizeof(array) / sizeof(array[0]));
}

#ifdef __cplusplus
extern "C" {
#endif
// java类包名
const char *cls_publish = "com/lyj/learnffmpeg/Publisher";

Publisher *publisher = nullptr;

void publisher_init(JNIEnv *env, jobject thiz) {
    if (publisher == nullptr) {
        publisher = new Publisher();
        // 将jvm实例赋值进去,以便回调java方法
        env->GetJavaVM(&publisher->vm);
    }
}
// 设置回调
void publisher_set_callback(JNIEnv *env, jobject thiz, jobject callback) {
    if (publisher) {
        if (publisher->callback) {
            env->DeleteGlobalRef(publisher->callback);
        }
        publisher->callback = env->NewGlobalRef(callback);
    }
}

void publisher_release(JNIEnv *env, jobject thiz) {
    if (publisher != nullptr) {
        env->DeleteGlobalRef(publisher->callback);
        publisher->release();
        delete publisher;
        publisher = nullptr;
    }
}

// 初始化推流
int publisher_start_publish(JNIEnv *env, jobject thiz, jstring path, jint width, jint height, jint orientation) {
    const char *p_path = nullptr;
    p_path = env->GetStringUTFChars(path, nullptr);
    if (publisher) {
        publisher->startPublish(p_path, width, height, orientation);
    }
    env->ReleaseStringUTFChars(path, p_path);
    return 0;
}
// 接收相机帧数据放入队列
int publisher_publish_data(JNIEnv *env, jobject thiz, jbyteArray data) {
    if (publisher && publisher->isPublish()) {
        int len = env->GetArrayLength(data);
        jbyte *buffer = new jbyte[len];
        // 这里复制一份数据给native层用,因为相机本身的数据需要回收
        env->GetByteArrayRegion(data, 0, len, buffer);
        if (publisher) {
            publisher->pushData((unsigned char *) (buffer));
        }
    }
    return 0;
}

int publisher_stop_publish(JNIEnv *env, jobject thiz) {
    if (publisher) {
        publisher->stopPublish();
    }
    return 0;
}

JNINativeMethod publisher_methods[] = {
        {"initPublish",  "()V",                                      (void *) publisher_init},
        {"setCallBack",  "(Lcom/lyj/learnffmpeg/PublishCallBack;)V", (void *) publisher_set_callback},
        {"release",      "()V",                                      (void *) publisher_release},
        {"startPublish", "(Ljava/lang/String;III)I",                 (void *) publisher_start_publish},
        {"stopPublish",  "()I",                                      (void *) publisher_stop_publish},
        {"publishData",  "([B)I",                                    (void *) publisher_publish_data}
};

// jni注册
int jniRegisterNativeMethods(JNIEnv *env, const char *className, const JNINativeMethod *methods,
                             int count) {
    int res = -1;
    jclass cls = env->FindClass(className);
    if (cls != nullptr) {
        int ret = env->RegisterNatives(cls, methods, count);
        if (ret > 0) {
            res = 0;
        }
    }
    env->DeleteLocalRef(cls);
    return res;
}

JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env = nullptr;
    jint result = -1;
    if (vm->GetEnv((void **) (&env), JNI_VERSION_1_6) != JNI_OK) {
        return result;
    }
    jniRegisterNativeMethods(env, cls_publish, publisher_methods, arrayLen(publisher_methods));
    return JNI_VERSION_1_6;
}

JNIEXPORT void JNI_OnUnload(JavaVM *jvm, void *reserved) {
}
#ifdef __cplusplus
}
#endif
ub

publisher.h定义了一些函数和变量,注释都比较详细,LinkedBlockingQueue是自己定义的一个阻塞队列,这里就不贴代码了,可以去demo中看

// 由于是FFmpeg是纯c编写,所以需要在extern "C"{}中引入
#ifdef __cplusplus
extern "C" {
#endif

#include <libavformat/avformat.h>

#ifdef __cplusplus
}
#endif

using namespace std;

class Publisher {
private:
    mutex pool_mutex;
    const char *path;
    int width = 0;
    int height = 0;
    atomic_bool is_publish = {false};
    int fps = 25;
    int64_t index = 0;
    uint8_t *pic_buf = nullptr;
    // AVFormatContext用于封装/解封装 flv,avi,rmvb,mp4
    AVFormatContext *formatContext = nullptr;
    // 用于编解码
    AVCodecContext *codecContext = nullptr;
    AVDictionary *codec_dict = nullptr;
    AVStream *stream = nullptr;
    // AVPacket是存储压缩编码数据相关信息的结构体
    AVPacket *packet = nullptr;
    // 解码后/压缩前的数据
    AVFrame *frame = nullptr;
    // 从队列取数据编码线程
    thread worker;
    // 初始化推流
    int initPublish(JNIEnv *env);

    int destroyPublish();
    // 编码一帧
    int encodeFrame(AVFrame *frame);

    void callbackState(JNIEnv *env, PublishState state);

    void callbackError(JNIEnv *env, PublishError error);

public:
    JavaVM *vm = nullptr;
    // 回调实例
    jobject callback = nullptr;
    // 阻塞队列
    LinkedBlockingQueue<unsigned char *> dataPool;
    // 线程控制
    atomic_bool running = {false};

    atomic_bool initing = {false};

    Publisher();

    int startPublish(const char *path, int width, int height, int orientation);

    int stopPublish();

    int pushData(unsigned char *buffer);

    int release();

    bool isPublish();

};

#endif

主要推流流程

publisher.cpp,主要逻辑实现都放在这里,基本函数调用可分为下面几步

  1. avformat_network_init() 初始化网络
  2. avformat_alloc_output_context2(&formatContext, nullptr, "flv", 推流地址) 根据文件名创建AVFormatContext,用于格式封装
  3. avcodec_find_encoder(AV_CODEC_ID_H264) 获取h.264编码器
  4. avcodec_alloc_context3(编码器) 根据编码器创建AVCodecContext,并给它设置一些参数,用于编码
  5. avcodec_open2(codecContext, codec, &codec_dict) 初始化编码器
  6. stream = avformat_new_stream(formatContext, nullptr) 创建一个视频流一个流并设置给formatContext
  7. avcodec_parameters_from_context(stream->codecpar, codecContext) 将编码器配置复制到流
  8. avio_open(&formatContext->pb, 推流路径, AVIO_FLAG_WRITE) 打开输入流,准备推流
  9. avformat_write_header(formatContext, nullptr) 写视频文件头
  10. frame = av_frame_alloc() 创建帧数据
  11. avcodec_send_frame(codecContext, frame) 将帧数据送入编码器
  12. packet = av_packet_alloc(),av_new_packet(packet, pic_size) 创建packet用于接收编码后的帧数据
  13. avcodec_receive_packet(codecContext, packet) 接收编码后的帧数据,然后设置packet的pts,dts
  14. av_interleaved_write_frame(formatContext, packet) 写入packet到码流
  15. av_write_trailer(formatContext) 写文件尾

准备推流

int Publisher::startPublish(const char *path, int width, int height, int orientation) {
    if (initing.load() || running.load()) {
        LOGE("已经在推流");
        return -1;
    }
    this->path = path;
    // 如果摄像头旋转角度为90/270度则宽高对换
    this->width = orientation % 180 == 0 ? width:height;
    this->height = orientation % 180 == 0 ? height:width;
    LOGE("publish width:%d, height:%d", this->width, this->height);
    this->orientation = orientation;
    running = true;
    initing = true;
    if (worker.joinable()) {
        worker.join();
    }
    // 开启编码线程,循环队列编码
    worker = thread([=]() {
        encodeRun();
    });
    return 0;
}

初始化推流

Publisher::initPublish(JNIEnv *env) {
    int ret = avformat_network_init();
    AVCodec *codec = nullptr;
    // 根据输出封装格式创建AVFormatContext
    avformat_alloc_output_context2(&formatContext, nullptr, "flv", path);
    // 获取编码器
    codec = avcodec_find_encoder(AV_CODEC_ID_H264);
    codecContext = avcodec_alloc_context3(codec);
    codecContext->codec_id = codec->id;
    codecContext->codec_type = AVMEDIA_TYPE_VIDEO;
    codecContext->pix_fmt = AV_PIX_FMT_YUV420P;
    codecContext->width = width;
    codecContext->height = height;
    // 码率
    codecContext->bit_rate = 144 * 1024;
    // i帧间隔
    codecContext->gop_size = 20;
    // 量化
    codecContext->qmin = 10;
    codecContext->qmax = 51;
    // 两个非B帧之间的最大B帧数
    codecContext->max_b_frames = 3;
    // 时间基 1/25 秒
    codecContext->time_base = AVRational{1, fps};
    codecContext->framerate = AVRational{fps, 1};
    // codecContext->thread_count = 4;
    if (formatContext->oformat->flags & AVFMT_GLOBALHEADER) {
        codecContext->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
    }
    if (codecContext->codec_id == AV_CODEC_ID_H264) {
        // 编码速度和质量的平衡
        // "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo"
        av_dict_set(&codec_dict, "preset", "veryfast", 0);
        av_dict_set(&codec_dict, "tune", "zerolatency", 0);
    }

    // 打印封装格式信息
    av_dump_format(formatContext, 0, path, 1);

    // 初始化编码器
    if (avcodec_open2(codecContext, codec, &codec_dict) < 0) {
        return -1;
    }
    // new一个流并挂到fmt_ctx名下,调用avformat_free_context时会释放该流
    stream = avformat_new_stream(formatContext, nullptr);
    if (stream == nullptr) {
        return -1;
    }
    // 获取源图像字节大小,1byte内存对齐 yuv420P YYYYUV
    int pic_size = av_image_get_buffer_size(codecContext->pix_fmt,
                                            codecContext->width,
                                            codecContext->height, 1);
    // 创建缓冲区
    pic_buf = (uint8_t *) (av_malloc(static_cast<size_t>(pic_size)));
    // 创建编码数据
    frame = av_frame_alloc();
    frame->format = codecContext->pix_fmt;
    frame->width = codecContext->width;
    frame->height = codecContext->height;
    // 格式化缓冲区内存
    // dst_data: 格式化通道如rgb三通道
    av_image_fill_arrays(frame->data, frame->linesize, pic_buf,
                         codecContext->pix_fmt,
                         codecContext->width, codecContext->height, 1);

    // 创建packet存储编码后的数据
    packet = av_packet_alloc();
    av_new_packet(packet, pic_size);
    // 复制编码配置到码流配置
    avcodec_parameters_from_context(stream->codecpar, codecContext);
    // 打开输出流
    ret = avio_open(&formatContext->pb, path, AVIO_FLAG_WRITE);
    if (ret < 0) {
        char *err = av_err2str(ret);
        LOGE("打开输出流失败, err:%s", err);
        // 回调错误到java层
        callbackError(env, PublishError::CONNECT_ERROR);
        return -1;
    }
    // 回调状态到java层
    callbackState(env, PublishState::CONNECTED);
    LOGE("打开输出流成功");
    // 写视频文件头
    ret = avformat_write_header(formatContext, nullptr);
    if (ret == 0) {
        callbackState(env, PublishState::START);
    } else {
        callbackError(env, PublishError::UNKNOW);
        return ret;
    }
    is_publish = true;
    return ret;
}

编码线程

void Publisher::encodeRun() {
    JNIEnv *env = nullptr;
    // 绑定当前线程的jni实例,用于回调
    int ret = vm->AttachCurrentThread(&env, nullptr);
    ret = initPublish(env);
    initing = false;
    if (ret == 0) {
        while (running.load()) {
            unsigned char *buffer = nullptr;
            buffer = dataPool.pop();
            if (buffer) {
                int32_t ysize = width * height;
                int32_t usize = (width / 2) * (height / 2);
                const uint8_t *sy = buffer;
                const uint8_t *su = buffer + ysize;
                const uint8_t *sv = buffer + ysize + usize;
                
                uint8_t *ty = pic_buf;
                uint8_t *tu = pic_buf + ysize;
                uint8_t *tv = pic_buf + ysize + usize;

                // 旋转,如果摄像头画面不是正确朝上的,要根据旋转角度将画面数据旋转
                libyuv::I420Rotate(sy, height, su, height >> 1, sv, height >> 1,
                                   ty, width, tu, width >> 1, tv, width>> 1,
                                   height, width, (libyuv::RotationMode) orientation);
                frame->data[0] = ty;
                frame->data[1] = tu;
                frame->data[2] = tv;

                chrono::system_clock::time_point start = chrono::system_clock::now();
                // 编码一帧
                encodeFrame(frame);
                chrono::system_clock::time_point finish = chrono::system_clock::now();
                LOGE("encode time: %lf",
                     chrono::duration_cast<chrono::duration<double, ratio<1, 1000>>>(
                             finish - start).count());
                delete[] buffer;
            }
        }
    }
    vm->DetachCurrentThread();
    destroyPublish();
}

编码每一帧数据然后放入线程池发送
这里要了解一下FFmpeg编解码中时间戳的概念,它定义了pts、dts用来标识视频帧,pts表示解码后的视频帧什么时候被显示出来,dts则表示packet在什么时候开始送入解码器中进行解码。
视频编码中由于B帧的存在,pts和dts并不一定一致,B帧可能需要依赖后一帧的数据来补全自身,此时它的dts < pts,即需要后解码先显示。
详细可以看看这篇文章深入理解pts,dts,time_base

int Publisher::encodeFrame(AVFrame *frame) {
    if (frame) {
        // frame pts 为帧当前帧数
        frame->pts = index;
    }
    // 发送一帧到编码器
    int ret = avcodec_send_frame(codecContext, frame);
    if (ret == AVERROR(EAGAIN)) {
        ret = 0;
    } else if (ret != 0) {
        LOGE("encode error code: %d, msg:%s", ret, av_err2str(ret));
        return -1;
    }
    while (ret >= 0) {
        // 读编码完成的数据 某些解码器可能会消耗部分数据包而不返回任何输出,因此需要在循环中调用此函数,直到它返回EAGAIN
        ret = avcodec_receive_packet(codecContext, packet);
        if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
            // 读完一帧
            index++;
            av_packet_unref(packet);
            return 0;
        } else if (ret < 0) {
            LOGE("ENCODE ERROR CODE %d", ret);
            av_packet_unref(packet);
            return -1;
        }
        packet->stream_index = stream->index;
        if (frame) {
            // flv mp4一般时间基为 1/1000
            // 将codec的pts转换为mux层的pts
            int64_t pts = av_rescale_q_rnd(packet->pts, codecContext->time_base, stream->time_base, AV_ROUND_NEAR_INF);
            int64_t dts = av_rescale_q_rnd(packet->dts, codecContext->time_base, stream->time_base, AV_ROUND_NEAR_INF);
            packet->pts = pts;
            packet->dts = dts;
            // 表示当前帧的持续时间, 25帧 1000/25 = 40
            packet->duration = (stream->time_base.den) / ((stream->time_base.num) * fps);
            packet->pos = -1;
        }
        int64_t frame_index = index;
        AVRational time_base = formatContext->streams[0]->time_base; //{ 1, 1000 };
        LOGI("Send frame index:%lld,pts:%lld,dts:%lld,duration:%lld,time_base:%d,%d,size:%d",
             (int64_t) frame_index,
             (int64_t) packet->pts,
             (int64_t) packet->dts,
             (int64_t) packet->duration,
             time_base.num, time_base.den,
             packet->size);
        long start = clock();
        LOGE("start write a frame");
        // 将解码完的数据包写入输出
        int code = av_interleaved_write_frame(formatContext, packet);
        long end = clock();
        LOGE("send time: %ld", end - start);
        if (code != 0) {
            LOGE("av_interleaved_write_frame failed");
        }
        av_packet_unref(packet);
    }
    return 0;
}

结束时写文件尾,回收资源

int Publisher::destroyPublish() {
    LOGE("destroyPublish");
    index = 0;
    if (is_publish.load()) {
        int ret = encodeFrame(nullptr);
        if (ret == 0) {
            // 写文件尾
            av_write_trailer(formatContext);
        }
    }
    is_publish = false;
    running = false;
    if (pic_buf) {
        av_free(pic_buf);
        pic_buf = nullptr;
    }
    if (packet) {
        av_packet_free(&packet);
        packet = nullptr;
    }
    if (frame) {
        av_frame_free(&frame);
        frame = nullptr;
    }
    if (codec_dict) {
        av_dict_free(&codec_dict);
    }
    if (formatContext) {
        avio_close(formatContext->pb);
        avformat_free_context(formatContext);
        formatContext = nullptr;
    }
    return 0;
}

自此整个流程已经走完,整体代码并不完整,有一些细节上的东西限于篇幅没有贴出来,可以到demo里面去看。

使用ffplay看看推流效果

推流成功以后可以使用ffplay验证效果,到官网下载编译好的windows平台可执行文件

cmd到bin目录下面执行ffplay xxxx(流媒体地址)即可

下一篇来做一个简单的播放器