RTMP 直播推流 Demo(一)—— 项目配置与视频预览

音视频编解码系列目录:

Android 音视频基础知识
Android 音视频播放器 Demo(一)------ 视频解码与渲染
Android 音视频播放器 Demo(二)------ 音频解码与音视频同步
RTMP 直播推流 Demo(一)------ 项目配置与视频预览
RTMP 直播推流 Demo(二)------ 音频推流与视频推流

前面的视频播放器 Demo 是在拉流端进行音视频解码,接下来介绍的 RTMP 直播推流的 Demo 是推流端进行音视频编码。Android 设备作为推流端将摄像头拍摄的图像上传至服务器,在 PC 端通过 FFmpeg 提供的 ffplay 工具或者 EVPlayer 拉流播放视频。

1、项目结构

首先来看直播架构示意图:

主要有三个角色:

  1. 推流端:安卓设备,使用摄像头采集图像,麦克风采集声音,通过 RTMP 协议将音视频流传输到服务器上
  2. 服务器:一般是 NGINX 服务器,需要进行 RTMP 的相关配置以接收推流端的数据
  3. 拉流端:可以是移动设备也可以是 PC,能播放 RTMP 流即可。后续演示时会在 PC 端通过 FFmpeg 提供的 ffplay 工具拉流

除了上述三个重要角色,还会有房间服务模块,服务器的管理与 Web 播放就是通过 HTTP 协议了:

2、开源库的使用与项目配置

在推流过程中,我们会使用几个开源库:

  1. 服务器端:NGINX 服务器需要下载 NGINX 源码,在 Linux 环境编译并启动。此外,还需要支持 RTMP 通信的 RTMP 模块编译进 NGINX 中
  2. Android 推流端:需要三个开源库:
    • 视频编码需要 x264
    • 音频编码需要 faac
    • RTMP 通信需要 RTMPDump

我们首先来看服务器如何配置。

编译环境:Alibaba Cloud Linux 3,NDK 17,NGINX 1.18,RTMP Module 1.2.1,RTMPDump 2.3,FFmpeg 4.2.2。

2.1 配置 NGINX 服务器

下载源码

需要下载 NGINX 源码以及 RTMP 模块源码。先下载 NGINX 源码并解压:

shell 复制代码
wget https://nginx.org/download/nginx-1.18.0.tar.gz
tar -xvf nginx-1.18.0.tar.gz

然后下载 NGINX RTMP 模块并解压得到 nginx-rtmp-module-1.2.1 目录:

shell 复制代码
wget https://codeload.github.com/arut/nginx-rtmp-module/tar.gz/v1.2.1
tar xvf v1.2.1

编译 NGINX 源码

进入 NGINX 根目录,运行脚本进行编译:

shell 复制代码
./configure --prefix=./output --add-module=../nginx-rtmp-module-1.2.1

参数说明:

  • --prefix 指定编译产物的输出目录,./output 表示在当前目录的 output 文件夹下,如果该目录不存在会自动创建
  • --add-module 指定添加一个模块,这里我们添加的是 rtmp-module,在上级目录的 nginx-rtmp-module-1.2.1 文件夹下

由于 NGINX 依赖 gcc、PCRE、OpenSSL、zlib 这些库,缺少其一编译就会报错,比如缺少 PCRE:

shell 复制代码
checking for PCRE library ... not found
checking for PCRE library in /usr/local/ ... not found
checking for PCRE library in /usr/include/pcre/ ... not found
checking for PCRE library in /usr/pkg/ ... not found
checking for PCRE library in /opt/local/ ... not found

./configure: error: the HTTP rewrite module requires the PCRE library.
You can either disable the module by using --without-http_rewrite_module
option, or install the PCRE library into the system, or build the PCRE library
statically from the source with nginx by using --with-pcre=<path> option.

此时需要安装 PCRE:

shell 复制代码
yum install -y pcre pcre-devel

依赖库的具体安装方法可以参考以下文章:

编译成功之后,会有类似的输出:

当然目前并不会在 nginx-1.18.0 目录下生成 output 目录以及可执行文件,需要在执行完安装命令之后才能看见该文件夹。

安装 NGINX

接着安装 NGINX:

shell 复制代码
make && make install

报错:

shell 复制代码
cc1: all warnings being treated as errors
make[1]: *** [objs/Makefile:1339: objs/addon/nginx-rtmp-module-1.2.1/ngx_rtmp_eval.o] Error 1
make[1]: Leaving directory '/root/AndroidNDK/nginx-1.18.0'
make: *** [Makefile:8: build] Error 2

原因是将警告当成了错误处理,需要修改 /nginx-1.18.0/objs/Makefile 的编译参数:

makefile 复制代码
# 去掉下面的 -Werror 选项
CFLAGS =  -pipe  -O -W -Wall -Wpointer-arith -Wno-unused-parameter -Werror -g

再次执行安装命令可以成功安装。

配置 NGINX 服务器

成功安装 NGINX 服务器后需要对其进行配置,修改 /nginx-1.18.0/output/conf/nginx.conf 文件:

nginx 复制代码
user  root; # 指定 root 权限,否则可能会因权限不足而启动失败
worker_processes  1; # 工作在哪个进程

#error_log  logs/error.log; # 错误日志
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;

events {
    worker_connections  1024; # 支持最大的直播人数
}

# 对 RTMP 协议的配置
rtmp {
    server {
        listen 1935; # 1935 端口
        application myapp {
            live on; # 打开直播
            drop_idle_publisher 5s; # 闲置 5s 后断开连接
        }
    }
}

# 对 HTTP 协议的配置
http {
    server {
        listen  8081;
        location /stat {
            rtmp_stat all;
            rtmp_stat_stylesheet stat.xsl;
        }
        location /stat.xsl {
            root /root/AndroidNDK/nginx-rtmp-module-1.2.1/;
        }
        location control {
            rtmp_control all;
        }
        location /rtmp_publisher {
            root /root/AndroidNDK/nginx-rtmp-module-1.2.1/test;
        }
        location / {
            root /root/AndroidNDK/nginx-rtmp-module-1.2.1/test/www;
        }
    }
}

nginx.conf 是使用 NGINX 自定义的语法 Nginx Configuration Language 编写的,并不属于任何传统的编程语言。

配置时需要注意几点:

  • location 标签内 root 后面配置的路径要换成你实际的路径,比如你的 nginx-rtmp-module-1.2.1 文件夹的绝对路径是 /root/AndroidNDK/nginx-rtmp-module-1.2.1/,那么你配置的 root 后面就要跟这个路径,而不是我给出的 /root/nginx-rtmp-module-1.2.1/

  • 如果你因为配置错误而修改了 nginx.conf 文件,并且 NGINX 服务器已经启动了,那么你需要先停掉 NGINX 服务器再重新启动它才可使修改生效:

    shell 复制代码
    [root@frank nginx-1.18.0]# ./output/sbin/nginx -s stop
    [root@frank nginx-1.18.0]# ./output/sbin/nginx

启动 NGINX 服务器

在 NGINX 根目录 nginx-1.18.0 下执行可执行文件 nginx 启动服务器:

如果显示 8081 端口被占用了,可以 kill 掉占用 8081 端口的进程:

shell 复制代码
# 通过该命令查询到占用 8081 端口的进程号为 28764
netstat -tunlp|grep 8081
# kill 掉 28764 号进程解除 8081 端口的占用
kill -9 28764

这时候去访问 NGINX 服务器地址。如果你使用的是云服务器,那么就访问服务器的公网 IP + 端口号。例如我的 Linux 服务器公网 IP 为 118.24.126.13,那么你就去访问 118.24.126.13:8081;如果你是在本地 Linux 虚拟机上搭建的服务器,那么就访问本地服务器地址,如 192.168.31.39:8081。成功访问的页面如下:

由于环境不同,配置复杂可能还会有各种各样的问题,这里我再列举一些问题和解决方法:

  • 主机能 ping 通虚拟机,但是虚拟机 ping 不到主机:参考Ubuntu虚拟机无法ping通windows,反之可以的解决办法

  • 如果使用的云服务器,还需要配置服务器的安全组,把 1935 和 8081 端口打开:

  • 假如在配置脚本时忘记在第一行指定 user root,访问后台页面时可能会显示 nginx 403 forbid。查看 nginx-1.18.0/output/logs/error.log 发现是权限问题:

    shell 复制代码
    [error] 3848#0: *1 open() "/root/nginx-rtmp-module-1.2.1/stat.xsl" failed (13: Permission denied), client: x.x.x.x, server: , request: "GET /stat.xsl HTTP/1.1", host: "118.24.126.13:8081", referrer: "http://118.24.126.13:8081/stat"
    [error] 3848#0: *1 open() "/root/nginx-rtmp-module-1.2.1/test/www/favicon.ico" failed (13: Permission denied), client: x.x.x.x, server: , request: "GET /favicon.ico HTTP/1.1", host: "118.24.126.13:8081", referrer: "http://118.24.126.13:8081/stat"

    通过命令查看哪些用户运行了 NGINX:

    shell 复制代码
    ps -ef | grep nginx
    ps aux | grep "nginx: worker process" | awk '{print $1}'

    以上两个命令运行其一即可,得到的结果是 root 和 nobody:

    shell 复制代码
    root      3896     1  0 15:56 ?        00:00:00 nginx: master process ./bin/sbin/nginx
    nobody      3898  3896  0 15:56 ?        00:00:00 nginx: worker process
    root      4068  4036  0 17:55 pts/2    00:00:00 grep --color=auto nginx

    由于所有命令都是在 root 用户下进行的,因此需要在脚本中指定 user 为 root

2.2 RTMPDump 编译与配置

RTMP 是一个协议,而 RTMPDump 是处理 RTMP 协议数据的开源库:

  • RTMP(Real Time Messaging Protocol),实时消息传输协议,是基于 TCP 的应用层协议
  • RTMPDump 是用 C 语言开发的处理 RTMP 流媒体的开源工具包。它能够单独使用进行 RTMP 的通信, 也可以集成到 FFmpeg 中通过 FFmpeg 接口来使用 RTMPDump。它封装了 Socket 建立 TCP 通信,实现了 RTMP 数据的收发。借助 RTMPDump 可以通过调用 C 的 API 的方式实现推流与拉流,而无需考虑 RTMP 底层细节(类似于 OkHttp 库与 HTTP 协议的关系)

由于 RTMPDump 的源码并不多,并且我们会对其源码稍加修改,因此就不在 Linux 服务器编译出它的库之后再放入 AS 中使用,而是直接放入 AS 中编译。

首先,在 RTMPDump 的官网找到下载页面,下载最新的 2.3 版本 rtmpdump-2.3.tgz,解压后会看到一个 librtmp 目录。先查看该目录下的 Makefile,了解如何编译。关键信息如下:

shell 复制代码
OBJS=rtmp.o log.o amf.o hashswf.o parseurl.o

librtmp.a: $(OBJS)

log.o: log.c log.h Makefile
rtmp.o: rtmp.c rtmp.h rtmp_sys.h handshake.h dh.h log.h amf.h Makefile
amf.o: amf.c amf.h bytes.h log.h Makefile
hashswf.o: hashswf.c http.h rtmp.h rtmp_sys.h Makefile
parseurl.o: parseurl.c rtmp.h rtmp_sys.h log.h Makefile

要编译出 librtmp.a 这个静态库,需要 OBJS 变量定义的几个目标文件,而编译目标文件所需的源文件也在后续给出了。因此,我们将 librtmp 目录下的这些文件,拷贝到 AS 项目的 /src/main/cpp/librtmp 下,并新建 CMakeLists.txt 用来编译静态库:

cmake 复制代码
cmake_minimum_required(VERSION 3.22.1)

# 将源文件定义为 rtmp_src 变量
file(GLOB rtmp_src *.c)
# 用 C 不是 C++ 了,因为 RTMP 是用 C 写的
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DNO_CRYPTO")
# 声明如下源文件编译出来的库文件名称为 librtmp.a
add_library(rtmp STATIC ${rtmp_src})

我们注意到在 set 命令中通过 -D 参数声明了一个宏 NO_CRYPTO,如果不添加该参数编译会报错:

shell 复制代码
[1/1] Re-running CMake...
-- Configuring done
-- Generating done
-- Build files have been written to: F:/Code/Android/VideoLive/app/.externalNativeBuild/cmake/debug/x86_64
[1/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/log.c.o
[2/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/hashswf.c.o
[3/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/rtmp.c.o
[4/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/amf.c.o
[5/8] Building C object src/main/cpp/librtmp/CMakeFiles/rtmp.dir/parseurl.c.o
...
src/main/cpp/librtmp/CMakeFiles/rtmp.dir/hashswf.c.o   -c F:\Code\Android\VideoLive\app\src\main\cpp\librtmp\hashswf.c
F:\Code\Android\VideoLive\app\src\main\cpp\librtmp\hashswf.c:56:10: fatal error: 'openssl/ssl.h' file not found

#include <openssl/ssl.h>

         ^~~~~~~~~~~~~~~

1 error generated.

意思是在编译 hashswf.c 文件时,找不到 openssl/ssl.h 文件。实际上是因为我们没有引入 openssl 工具包。openssl 是用来进行数据加密的,加密意味着耗时,由于视频直播对时效性要求高,因此我们暂时不考虑引入 openssl。那如何规避掉编译错误呢?

我们先来看报错的 hashswf.c:

c 复制代码
#ifdef CRYPTO
    .
    .
    .
#include <openssl/ssl.h>
#include <openssl/sha.h>
#include <openssl/hmac.h>
#include <openssl/rc4.h>
    .
    .
    .
#endif

它只有在定义了 CRYPTO 这个宏的情况下才会导入 openssl,而 CRYPTO 是在 rtmp.h 中定义的:

c 复制代码
#if !defined(NO_CRYPTO) && !defined(CRYPTO)
#define CRYPTO
#endif

就是没有定义 NO_CRYPTO 和 CRYPTO 这两个宏时,才会定义 CRYPTO。所以这里才会通过定义 NO_CRYPTO 宏的方式来规避 openssl 的导入。

最后配置 app 模块下的 CMakeLists,将上面的 CMakeLists 嵌套进来:

cmake 复制代码
cmake_minimum_required(VERSION 3.22.1)

project("pusher")

# 添加 librtmp 目录进来
add_subdirectory(librtmp)

# 包含 librtmp 目录,这样导入其文件时就可以不再用""而是用<>
# 使用<>可以避免要导入的文件路径过深而需要写出一长串路径,直接写最终文件名即可
include_directories(librtmp)

add_library( 
        pusher
        SHARED
        native-lib.cpp)

find_library( 
        log-lib
        log)

target_link_libraries( 
        pusher
        rtmp # 添加 RTMP 静态库
        ${log-lib})

2.3 x264 编译与配置

x264 是一个开源的实现了 H.264 协议的视频编码库,提供了 H.264 编码器。它是通过将视频源压缩为 H.264 格式的比特流来实现视频压缩。x264 使用一系列复杂的算法和技术,如运动估计、变换编码、熵编码等,以高效地压缩视频,并提供高质量的图像和视频编码。总的来讲,H.264 是一种视频压缩标准,而 x264 是 H.264 的一个开源实现。

VideoLAN 可以下载 x264 的源码,也可以使用 git:

shell 复制代码
# git clone https://code.videolan.org/videolan/x264.git

接下来使用 NDK 交叉编译 x264 源码,脚本如下:

shell 复制代码
#!/bin/bash

# NDK 根目录
NDK_ROOT=/root/Android/android-ndk-r17c

# 编译产物的输出目录
PREFIX=./android/armeabi-v7a

# 交叉编译工具所在目录
TOOLCHAIN=$NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64

# 编译参数,可以参考 AS 中的 build.ninja 的参数
FLAGS="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=17 -g -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,--noexecstack -Wformat -Werror=format-security  -O0 -fPIC"

# 执行脚本的命令,--disable-cli 表示关闭命令行
./configure \
--prefix=$PREFIX \
--disable-cli \
--enable-static \
--enable-pic \
--host=arm-linux \
--cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \
--sysroot=$NDK_ROOT/platforms/android-17/arch-arm \
--extra-cflags="$FLAGS"

make clean
make install

在指定的编译产物目录 /android/armeabi-v7a 下会生成两个目录 include 和 lib,分别包含头文件和静态库文件,直接将include 目录拷贝到项目的 src/main/cpp/libx264 下,将 lib 内的静态库文件 libx264.a 拷贝到 src/main/cpp/libx264/libs/armeabi-v7a 下。然后在顶级的 CMakeLIsts.txt 中添加相关配置:

cmake 复制代码
# 添加头文件
include_directories(src/main/cpp/include)

# 添加编译库文件,实际上 CMAKE_CXX_FLAGS 这个编译参数会被传到 build.ninja 的 FLAGS 中
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/src/main/cpp/libs/${ANDROID_ABI}")

target_link_libraries( 
        native-lib
        rtmp
        ${log-lib}
        x264 # 链接到目标库
)

最后在 build.gradle 中配置 CPU 架构过滤参数:

groovy 复制代码
android {
    defaultConfig {
        externalNativeBuild {
            cmake {
                // 添加这句,这样 CMake 只会编译 armeabi-v7a 架构的库,而不编译 x86 和其他的库
                // CPU 是哪个架构就只配置那个架构,这样可以避免 APK 打入不使用的库而增大体积
                abiFilters "armeabi-v7a"
            }
        }
        ndk {
            // 控制 ndk 只编译 armeabi-v7a 的库,这个也必须配置,否则
            // 在 System.loadLibrary() 时会因为找不到库而崩溃
            abiFilters "armeabi-v7a"
        }
    }
}

2.4 faac 编译与配置

faac 的 GitHub 主页上可以下载当下最新的 1.30 版本,如果想使用过往版本,可以在 SourceForge 的 faac 主页下载想要的版本。比如下载 1.29 版本:

shell 复制代码
[root@frank ~]# wget https://zenlayer.dl.sourceforge.net/project/faac/faac-src/faac-1.29/faac-1.29.9.2.tar.gz

解压后编写脚本:

shell 复制代码
#!/bin/bash
PREFIX=`pwd`/android/armeabi-v7a
NDK_ROOT=/root/AndroidNDK/android-ndk-r17c
TOOLCHAIN=$NDK_ROOT/toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64
CROSS_COMPILE=$TOOLCHAIN/bin/arm-linux-androideabi

FLAGS="-isysroot $NDK_ROOT/sysroot -isystem $NDK_ROOT/sysroot/usr/include/arm-linux-androideabi -D__ANDROID_API__=17 -g -DANDROID -ffunction-sections -funwind-tables -fstack-protector-strong -no-canonical-prefixes -march=armv7-a -mfloat-abi=softfp -mfpu=vfpv3-d16 -mthumb -Wa,--noexecstack -Wformat -Werror=format-security -std=c++11  -O0  -fPIC"

export CC="$CROSS_COMPILE-gcc --sysroot=$NDK_ROOT/platforms/android-17/arch-arm"
export CFLAGS="$FLAGS"


./configure \
--prefix=$PREFIX \
--host=arm-linux \
--with-pic \
--enable-shared=no

make clean
make install

将编译产物中 include 目录下的两个头文件以及 lib 目录下的 libfaac.a 静态库拷贝到 AS 中并配置 CMakeList:

cmake 复制代码
# 添加 faac 头文件
include_directories(libfaac/include)

# 添加 faac 静态库文件路径
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -L${CMAKE_SOURCE_DIR}/libfaac/libs/${CMAKE_ANDROID_ARCH_ABI}")

target_link_libraries(
        pusher
        rtmp # 添加 RTMP 静态库
        x264 # 链接 x 264
        faac # 链接 faac
        ${log-lib})

至此,所有第三方库导入完毕,准备工作完成。

3、实现思路

整体思路如下:

摄像头采集视频数据进行视频编码封装进 RTMP 包中,最后通过 RTMPDump 的 RTMP_SendPacket() 将视频包发送给服务器,音频也是类似的过程。

从代码分层的角度看上图,信息采集是在上层完成的,编码与推流是在 Native 层完成的:

按照从上到下的顺序:

  • Activity 通过 LivePusher 控制 VideoChannel 采集视频、AudioChannel 采集音频
  • Channel 采集到每一帧数据后,都调用 LivePusher 的 Native 方法将数据交给 Native 层
  • Native 层的入口 native-lib 将视频帧交给 VideoChannel 进行视频编码,将音频帧交给 AudioChannel 进行音频编码,编码后的数据转换成 RTMPPacket 存入 RTMPPacket 队列中
  • native-lib 负责连接 RTMP 服务器,并从 RTMPPacket 队列中取出 RTMPPacket 发送给 RTMP 服务器完成推流

上层的结构图如下:

各部分职责:

  • LivePusher 作为推流功能的入口,控制负责视频的 VideoChannel 和负责音频的 AudioChannel,同时还会定义 Native 方法作为与 Native 层交互的入口
  • VideoChannel 控制 CameraHelper 驱动摄像头采集视频图像,将采集到的图像显示在预览界面的同时,还要经由 LivePusher 传递给 Native 层进行编码
  • AudioChannel 使用 AudioRecord 读取麦克风的录音数据,也是经由 LivePusher 调用 Native 方法传给 Native 层编码发送

4、视频预览

采集视频数据传给底层进行编码之前,需要先实现视频预览,效果如下:

Android 系统提供了 Camera、Camera2 以及封装了 Camera2 的 Jetpack CameraX 来操控摄像头,我们以 Camera 为例,来看 CameraHelper 的实现。

4.1 初始化

初始化代码如下:

kotlin 复制代码
class CameraHelper(
    private var mActivity: Activity,
    private var mCameraId: Int,
    private var mHeight: Int,
    private var mWidth: Int
) : SurfaceHolder.Callback {
    private lateinit var mSurfaceHolder: SurfaceHolder
    
    /**
     * 我们需要监听 Surface 的变化,比如当 Surface 销毁时停止 Camera
     * 的预览,当 Surface 大小发生变化时,重启 Camera 的预览
     */
    fun setPreviewDisplay(surfaceHolder: SurfaceHolder) {
        mSurfaceHolder = surfaceHolder
        // 添加监听 Surface 变化的回调
        mSurfaceHolder.addCallback(this)
    }
    
    // SurfaceHolder.Callback start
    override fun surfaceCreated(holder: SurfaceHolder) {
        // 在 SurfaceView 创建成功后开启预览才有意义,但是因为还有切换前后摄像头
        // 的操作,切换不会回调本方法,因此将开启预览的逻辑都放到 surfaceChanged() 中
        // startPreview()
    }

    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
        // 除了 SurfaceView 的创建,还会有切换前后摄像头的操作,surfaceChanged()
        // 在两种情况下都会被回调,因此在这个回调方法中开启/关闭预览
        stopPreview()
        startPreview()
    }

    override fun surfaceDestroyed(holder: SurfaceHolder) {
        stopPreview()
    }
    // SurfaceHolder.Callback end
}

简单解释一下各项参数:

  • 需要通过 Activity 获取到手机旋转的方向,以便对摄像头采集到的数据做出相应的旋转
  • CameraId 用来指明当前使用前置还是后置摄像头
  • 宽高是用户希望使用的摄像参数,该参数会传给 Camera,但是由于不同厂商的摄像头具有不同的参数规格,因此 Camera 最终使用的宽高参数很可能与传入的不同,只是接近而已
  • 我们使用 SurfaceView 展现预览画面,那么就需要获取 SurfaceHolder,一方面是监听 SurfaceView 尺寸的变化,当发生变化时,需要重新开启预览;另一方面,Camera 提供了 setPreviewDisplay() 可以传入 SurfaceHolder 直接将拍摄到的画面显示在对应的 SurfaceView 上

4.2 开启预览与结束预览

主要操作包括:

  • 根据传入的 CameraId,即前置还是后置摄像头,打开该摄像头获取到 Camera 对象
  • 设置 Camera 参数,包括预览格式、宽高、旋转角度等
  • 设置使用缓冲区进行预览回调,并指定该缓冲区
  • 设置在 SurfaceHolder 持有的 SurfaceView 上进行预览,并开启预览
kotlin 复制代码
	// 开启预览
	fun startPreview() {
        // 1.打开 Camera
        mCamera = Camera.open(mCameraId)
        if (mCamera == null) {
            Log.d(TAG, "Open camera failed.")
            return
        }

        // 2.设置 Camera 参数
        val cameraParam = mCamera?.parameters
        // 2.1 设置预览格式为 NV21
        cameraParam?.previewFormat = ImageFormat.NV21
        // 2.2 设置预览界面的宽高
        setPreviewSize(cameraParam)
        // 2.3 设置预览画面需要旋转的角度和方向
        setPreviewOrientation(cameraParam)
        // 2.4 更新 Camera 参数
        mCamera?.parameters = cameraParam

        // 3.Camera 数据设置
        // 3.1 Camera 采集的是 NV21 格式的数据,其占用空间为总像素的 3/2,
        // mBuffer 用于保存预览数据,mBytes 用于保存推流到服务器上的数据
        mBuffer = ByteArray(mWidth * mHeight * 3 / 2)
        mBytes = ByteArray(mBuffer.size)
        // 3.2 设置预览回调缓冲区,将 Camera 采集的数据存入 mBuffer
        mCamera?.setPreviewCallbackWithBuffer(this)
        mCamera?.addCallbackBuffer(mBuffer)

        // 4.开启预览
        mCamera?.setPreviewDisplay(mSurfaceHolder)
        mCamera?.startPreview()
    }

	// 结束预览
	private fun stopPreview() {
        // 设置预览回调为空并停止预览
        mCamera?.setPreviewCallback(null)
        mCamera?.stopPreview()
        // 释放 mCamera 并置为空
        mCamera?.release()
        mCamera = null
    }

该方法内有一些需要解释的内容,在下面几个小节中讲解。

设置预览界面宽高

手机摄像头的宽高参数是有很多规格的,不同的厂商之间规格也都不同。当然,选择不同的宽高参数时,看到的预览画面的尺寸也不同:

严格来说,我们需要通过 setPreviewSize() 设置摄像头的拍摄所使用的参数,并且随之改变预览画面。但是当前我们仅实现设置摄像头参数,预览画面的 SurfaceView 的大小暂时先不动(感兴趣可自行实现)。

在设置摄像头宽高时,由于摄像头可能不支持与传入的宽高一模一样的规格,因此我们要先获取摄像头支持的拍摄规格,再选择与要求的宽高最相近的规格:

kotlin 复制代码
	/**
     * 从摄像头支持的宽高参数中选取与预览界面宽高差值最小的参数,并将其作为预览界面宽高
     */
    private fun setPreviewSize(cameraParam: Camera.Parameters?) {
        if (cameraParam == null) {
            return
        }

        // 获取摄像头支持的宽高参数
        val supportedPreviewSizes = cameraParam.supportedPreviewSizes
        var selectedSize = supportedPreviewSizes[0]
        val iterator = supportedPreviewSizes.iterator()
        var tempValue: Int
        var minValue = Integer.MAX_VALUE
        var tempSize: Camera.Size
        // 遍历找到与 mWidth 和 mHeight 最接近的规格
        while (iterator.hasNext()) {
            tempSize = iterator.next()
            tempValue = abs(tempSize.width * tempSize.height - mWidth * mHeight)
            if (tempValue < minValue) {
                minValue = tempValue
                selectedSize = tempSize
            }
        }
        // 将选定的宽高保存到成员变量和 cameraParam 中
        mWidth = selectedSize.width
        mHeight = selectedSize.height
        cameraParam.setPreviewSize(mWidth, mHeight)
    }

设置预览画面的旋转角度

为什么要对预览界面的数据进行旋转?因为 Android 设备的摄像头是横向摆放的:

如你所见,摄像头是相对于设备顺时针旋转了 90° 放置的,它输出的图像需要顺时针旋转 90° 才与手机摆放的方向相同。所以当手机竖直正向摆放时,你需要将摄像头采集到的像素矩阵顺时针旋转 90° 才能得到正常的视频。参考代码如下:

kotlin 复制代码
	// SurfaceView 的宽高发生变化时,需要通知 Native 层重新初始化编码器的
	interface OnSurfaceSizeChangedListener {
        fun onSizeChanged(width: Int, height: Int)
    }
	
	private var mOrientation = 0
	private var mOnSurfaceSizeChangedListener: OnSurfaceSizeChangedListener? = null

	/**
     * 根据当前手机的旋转角度调整预览界面的旋转角度,保证预览画面跟随手机的旋转
     * 而旋转,主要参考 Camera#setDisplayOrientation 注释给出的参考代码
     */
    private fun setPreviewOrientation(cameraParam: Camera.Parameters?) {
        mOrientation = mActivity.windowManager.defaultDisplay.orientation
        val degree = when (mOrientation) {
            Surface.ROTATION_0 -> {
                mOnSurfaceSizeChangedListener?.onSizeChanged(mHeight, mWidth)
                0
            }

            // 横屏,左边是头部,home 键在右边
            Surface.ROTATION_90 -> {
                mOnSurfaceSizeChangedListener?.onSizeChanged(mWidth, mHeight)
                90
            }

            Surface.ROTATION_180 -> {
                mOnSurfaceSizeChangedListener?.onSizeChanged(mHeight, mWidth)
                180
            }

            // 横屏,头部在右边,home 在左边
            Surface.ROTATION_270 -> {
                mOnSurfaceSizeChangedListener?.onSizeChanged(mWidth, mHeight)
                270
            }

            else -> 0
        }

        // 获取 CameraInfo 以便后续从中获取前后置摄像头
        val cameraInfo = Camera.CameraInfo()
        Camera.getCameraInfo(mCameraId, cameraInfo)
        // 根据 degree 计算预览界面需要旋转的角度
        var result: Int
        if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) {
            // 前置摄像头,需要做镜像转换
            result = (cameraInfo.orientation + degree) % 360
            result = (360 - result) % 360
        } else {
            // 后置摄像头
            result = (cameraInfo.orientation - degree + 360) % 360
        }
        mCamera?.setDisplayOrientation(result)
    }

当然,以上仅是对预览画面进行了旋转,要传递给 Native 进行编码的数据 mBytes 还没有做旋转处理,我们下一节再说。

mBuffer 与 mBytes

为什么 mBuffer 的大小是 mWidth * mHeight * 3 / 2,这与 YUV 的编码方式有关。先看下面这幅图:

YUV 编码中,每个像素点都有一个 Y 分量,UV 分量则是 4 个像素点共用一个,也就是说,在一个 Width * Height 的像素矩阵中,Y 分量的个数就是 Width * Height,而 UV 分量分别为 Width * Height / 4,那么 YUV 分量总计就是 Width * Height * 3 / 2

再来解释 mBuffer 是如何接收到数据的。注意 setPreviewDisplay() 内的这段代码:

kotlin 复制代码
	fun setPreviewDisplay(surfaceHolder: SurfaceHolder) {
        ...
		// 3.2 设置预览回调缓冲区,将 Camera 采集的数据存入 mBuffer
        mCamera?.addCallbackBuffer(mBuffer)
        mCamera?.setPreviewCallbackWithBuffer(this)
        ...
    }

首先,addCallbackBuffer() 会将 mBuffer 添加到一个预览回调缓冲队列中,当视频帧到来时,如果队列中有这个 mBuffer,就会把视频帧的数据保存到 mBuffer 中并将其从队列中移除。

其次,CameraHelper 设置了一个预览回调,当摄像头采集到一帧画面时,就通过 Camera.PreviewCallback 接口的 onPreviewFrame() 把数据传给我们:

kotlin 复制代码
	interface OnPreviewListener {
        fun onPreviewFrame(data: ByteArray)
    }

	private var mOnPreviewListener: OnPreviewListener? = null
	
	override fun onPreviewFrame(data: ByteArray?, camera: Camera?) {
        if (data == null) {
            Log.d(TAG, "onPreviewFrame: data 为空,直接返回")
            return
        }
        
        // 将传给服务器的图像数据旋转 90° 放入 mBytes 中
        if (mOrientation == Surface.ROTATION_0) {
            rotate90(data)
        }

        // 将页面数据回调给 VideoChannel,再传给 LivePusher 的 native 方法
        mOnPreviewListener?.onPreviewFrame(mBytes)
        // 再次将 mBuffer 添加到预览回调缓冲队列中,当有回调数据后就会填入 mBuffer
        mCamera?.addCallbackBuffer(mBuffer)
    }

在这里,将摄像头采集到的每一帧视频旋转 90° 赋值给 mBytes,再回调给 VideoChannel 传给 Native 层编码发送,至于原因前面已经提过了:

kotlin 复制代码
	/**
     * 对摄像头采集到的数据旋转 90° 后才是调正的图像,
     * 后置摄像头数据需要顺时针旋转 90°,而前置需要逆时针旋转 90°
     */
    private fun rotate90(data: ByteArray) {
        var index = 0;
        val ySize = mWidth * mHeight
        val uvHeight = mHeight / 2

        if (mCameraId == Camera.CameraInfo.CAMERA_FACING_BACK) {
            // 后置,先旋转 y,再旋转 uv,旋转后的数据存入 mBytes 中
            for (i in 0 until mWidth) {
                for (j in mHeight - 1 downTo 0) {
                    mBytes[index++] = data[j * mWidth + i]
                }
            }
            // 拷贝 uv,还是 NV21 格式
            for (i in 0 until mWidth step 2) {
                for (j in uvHeight - 1 downTo 0) {
                    // v
                    mBytes[index++] = data[ySize + j * mWidth + i]
                    // u
                    mBytes[index++] = data[ySize + j * mWidth + i + 1]
                }
            }
        } else {
            // 前置
            for (i in 0 until mWidth) {
                var nPos = mWidth - 1
                for (j in 0 until mHeight) {
                    mBytes[index++] = data[nPos - i]
                    nPos += mWidth
                }
            }
            // u v
            for (i in 0 until mWidth step 2) {
                var pos = ySize + mWidth - 1
                for (j in 0 until uvHeight) {
                    mBytes[index++] = data[pos - i - 1]
                    mBytes[index++] = data[pos - i]
                    pos += mWidth
                }
            }
        }
    }

4.3 前后置摄像头切换

切换 CameraId 再重启预览:

kotlin 复制代码
	fun switchCamera() {
        // 切换摄像头 ID 再重启预览
        mCameraId = if (mCameraId == Camera.CameraInfo.CAMERA_FACING_BACK) {
            Camera.CameraInfo.CAMERA_FACING_FRONT
        } else {
            Camera.CameraInfo.CAMERA_FACING_BACK
        }
        stopPreview()
        startPreview()
    }
相关推荐
网络研究院1 小时前
Android 安卓内存安全漏洞数量大幅下降的原因
android·安全·编程·安卓·内存·漏洞·技术
凉亭下1 小时前
android navigation 用法详细使用
android
小比卡丘4 小时前
C语言进阶版第17课—自定义类型:联合和枚举
android·java·c语言
芯橦5 小时前
【瑞昱RTL8763E】音频
单片机·嵌入式硬件·mcu·物联网·音视频·visual studio code·智能手表
前行的小黑炭5 小时前
一篇搞定Android 实现扫码支付:如何对接海外的第三方支付;项目中的真实经验分享;如何高效对接,高效开发
android
9527华安6 小时前
FPGA实现PCIE视频采集转HDMI输出,基于XDMA中断架构,提供3套工程源码和技术支持
fpga开发·音视频·pcie·xdma·ov5640·hdmi
落落落sss6 小时前
MybatisPlus
android·java·开发语言·spring·tomcat·rabbitmq·mybatis
代码敲上天.7 小时前
数据库语句优化
android·数据库·adb
GEEKVIP9 小时前
手机使用技巧:8 个 Android 锁屏移除工具 [解锁 Android]
android·macos·ios·智能手机·电脑·手机·iphone
speop9 小时前
【笔记】I/O总结王道强化视频笔记
笔记·音视频