音视频编解码系列目录:

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

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

1、项目结构

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

2024-1-4.直播推流示意图

主要有三个角色:

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

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

2024-1-4.直播推流服务器搭建

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 源码并解压:

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 目录:

wget https://codeload.github.com/arut/nginx-rtmp-module/tar.gz/v1.2.1
tar xvf v1.2.1

编译 NGINX 源码

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

./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:

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:

yum install -y pcre pcre-devel

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

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

2024-1-5.NGINX编译成功

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

安装 NGINX

接着安装 NGINX:

make && make install

报错:

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 的编译参数:

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

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

配置 NGINX 服务器

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

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 服务器再重新启动它才可使修改生效:

    [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 启动服务器:

./output/sbin/nginx

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

# 通过该命令查询到占用 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。成功访问的页面如下:

2024-1-5.NGINX成功访问

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

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

  • 如果使用的云服务器,还需要配置服务器的安全组,把 1935 和 8081 端口打开:
    2024-2-26.阿里云配置开放端口

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

    [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:

    ps -ef | grep nginx
    ps aux | grep "nginx: worker process" | awk '{print $1}'
    

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

    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,了解如何编译。关键信息如下:

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_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,如果不添加该参数编译会报错:

[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:

#ifdef CRYPTO
    .
    .
    .
#include <openssl/ssl.h>
#include <openssl/sha.h>
#include <openssl/hmac.h>
#include <openssl/rc4.h>
    .
    .
    .
#endif

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

#if !defined(NO_CRYPTO) && !defined(CRYPTO)
#define CRYPTO
#endif

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

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

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:

# git clone https://code.videolan.org/videolan/x264.git

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

#!/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 中添加相关配置:

# 添加头文件
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 架构过滤参数:

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 版本:

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

解压后编写脚本:

#!/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:

# 添加 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、实现思路

整体思路如下:

2024-2-27.直播推流端结构图

摄像头采集视频数据进行视频编码封装进 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 服务器完成推流

上层的结构图如下:

2024-1-5.推流思路图

各部分职责:

  • 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 初始化

初始化代码如下:

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 上进行预览,并开启预览
	// 开启预览
	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 的大小暂时先不动(感兴趣可自行实现)。

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

	/**
     * 从摄像头支持的宽高参数中选取与预览界面宽高差值最小的参数,并将其作为预览界面宽高
     */
    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 设备的摄像头是横向摆放的:

2024-1-9.Android摄像头横放示意图

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

	// 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 的编码方式有关。先看下面这幅图:

3.2.5.1-RGB与YUV内存对比

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

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

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

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

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

	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 层编码发送,至于原因前面已经提过了:

	/**
     * 对摄像头采集到的数据旋转 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 再重启预览:

	fun switchCamera() {
        // 切换摄像头 ID 再重启预览
        mCameraId = if (mCameraId == Camera.CameraInfo.CAMERA_FACING_BACK) {
            Camera.CameraInfo.CAMERA_FACING_FRONT
        } else {
            Camera.CameraInfo.CAMERA_FACING_BACK
        }
        stopPreview()
        startPreview()
    }
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐