音视频编解码系列目录:
Android 音视频基础知识
Android 音视频播放器 Demo(一)------ 视频解码与渲染
Android 音视频播放器 Demo(二)------ 音频解码与音视频同步
RTMP 直播推流 Demo(一)------ 项目配置与视频预览
RTMP 直播推流 Demo(二)------ 音频推流与视频推流
前面的视频播放器 Demo 是在拉流端进行音视频解码,接下来介绍的 RTMP 直播推流的 Demo 是推流端进行音视频编码。Android 设备作为推流端将摄像头拍摄的图像上传至服务器,在 PC 端通过 FFmpeg 提供的 ffplay 工具或者 EVPlayer 拉流播放视频。
1、项目结构
首先来看直播架构示意图:
主要有三个角色:
- 推流端:安卓设备,使用摄像头采集图像,麦克风采集声音,通过 RTMP 协议将音视频流传输到服务器上
- 服务器:一般是 NGINX 服务器,需要进行 RTMP 的相关配置以接收推流端的数据
- 拉流端:可以是移动设备也可以是 PC,能播放 RTMP 流即可。后续演示时会在 PC 端通过 FFmpeg 提供的 ffplay 工具拉流
除了上述三个重要角色,还会有房间服务模块,服务器的管理与 Web 播放就是通过 HTTP 协议了:
2、开源库的使用与项目配置
在推流过程中,我们会使用几个开源库:
- 服务器端:NGINX 服务器需要下载 NGINX 源码,在 Linux 环境编译并启动。此外,还需要支持 RTMP 通信的 RTMP 模块编译进 NGINX 中
- 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
依赖库的具体安装方法可以参考以下文章:
- Centos:centos7安装Nginx
- Ubuntu:ubuntu下安装nginx时依赖库zlib,pcre,openssl安装方法
编译成功之后,会有类似的输出:
当然目前并不会在 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:
shellps -ef | grep nginx ps aux | grep "nginx: worker process" | awk '{print $1}'
以上两个命令运行其一即可,得到的结果是 root 和 nobody:
shellroot 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()
}