在 Java 中使用 WebRTC 传输视频——准备工作

引言

最近一段时间的主要工作内容是开发一个远程控制手机的功能,其中音视频传输的部分是采用WebRTC技术来进行的,而我们的手机都是通过与其直接连接的Agent服务器进行管理,Agent服务是Java写的,现在市面上又没有合适的Java版WebRTC库,所以我就基于Google开源代码,写了一个JNI调用WebRTC Native的库。之前的一篇文章,我主要讲了讲我是怎么编译WebRTC的。这篇文章,我就来分享一下我是怎么在Java中使用WebRTC的,以及我根据业务需要对WebRTC的一些改动。其他在 Java 中使用 WebRTC 的经验均收录于<在 Java 中使用 WebRTC>中,对这个方向感兴趣的同学可以翻阅一下。

Native APIs介绍

如果您也要进行和我类似的工作,我觉得最主要的还是要先熟悉整个Native APIs的使用流程,梳理一下,你就会发现整个使用过程其实非常简单,也就八个大步骤。接下来我会先简单介绍这八个主要步骤,然后再针对每一个步骤,详细的介绍我是怎么做的。
Native APIs
Native APIs使用流程:

  1. 通过Native APIs创建三个WebRTC工作的线程:Worker Thread,Network Thread,Signaling Thread
    • 如果您像我一样需要自定义的音频采集模块以及自定义的编解码实现的话,也需要在这一步将其初始化。
  2. 创建PeerConnectionFactory,这个工厂是所有后续工作的源头,无论是连接,还是音视频采集都需要由它来创建。
  3. 创建PeerConnection,在这个过程中您可以设置连接的一些参数,比如ICE Server用哪个,网络TCP/UDP策略是怎样的。
    • 如果您像我一样需要对端口的使用进行一些限制的话,需要指定自定义PortAllocator
  4. 创建Audio/VideoSource,创建AudioSource时可以指定一些采集参数,VideoSource需要一个VideoCapturer对象作为参数。
    • 如果您想我一样需要自己提供视频图像的话,就要实现一个自定义的VideoCapturer
  5. 以上一步创建的Audio/VideoSource作为参数,创建AudioTrackInterface,这个对象代表了Audio/Video的采集过程
  6. 创建MediaStreamInterface并将前一步创建的Audio/VideoTrack添加进去,这个对象代表了传输通道
  7. 将上一步创建的MediaStream添加到第三步创建的PeerConnection中
  8. PeerConnection通过Observer以回调的形式通知使用者,当前的连接状态等。我们需要通过各类回调以及PeerConnection的API,来完成与另一个连接者之间的SDP和ICE Candidate的交换。

这八个步骤中,前两个是Native APIs这里特有的内容,其后的这些步骤基本上和Web中对WebRTC的使用流程相似。我当时就是在这些Native特有的内容上遇到了很多坑,接下来就让我详细的介绍一下我是如何在Java服务中通过Native APIs和其他客户端建立起连接吧。

JNI Vs JNA

大家应该都知道,要想在Java中调用C++的代码,需要使用JNI或者JNA技术,那么它们两个有什么不同呢?在我们这个场景中应该使用哪一个呢?
JNI-Usage
上图就是JNI的使用方式,从图中可以看到使用步骤非常多,很繁琐。我们先要在Java代码里定义好接口,然后通过工具生成对应的C语言头文件,接着再用C语言实现这些接口并编译成共享库,最终在JVM中Load该库,从而达到调用C语言代码的目的。
JNA-Usage
而JNA相对来说就简单了许多,我们不需要重写我们的动态链接库文件,而是有直接调用的API,大大简化了我们的工作量。看似JNA好像完胜JNI,这部分工作非JNA莫属了。但是在我的这个场景中,JNA有几个致命的问题,以至于我只能用JNI。
为什么不用JNA

  1. JNA只能实现Java访问C函数,而我们在使用PeerConnection相关的APIs时,很多都是以Observer的形式回调的,这就需要C代码回调Java的ObserverWrapper。
  2. JNA技术比使用JNI技术调用动态链接库会有些微的性能损失,虽然我不确定这个损失有多大,但是考虑到我们需要从Java传输每帧的图像给C,这个过程我们希望是越快越好。

好了,既然我们已经确定要使用JNI技术了,就让我来介绍一下我具体是怎么做的吧。

代码结构

Java代码结构

JNI-Structure

  1. script/build-header-files.sh: 根据我写的Java接口,生成对应C语言头文件的脚本。

    1
    2
    3
    4
    5
    #!/usr/bin/env bash
    ls -l ../path/to/rtc4j/core| grep ^- | awk '{print $9}' |
    sed 's/.class//g'|
    sed 's/^/package.name.of.core.&/g'|
    xargs javah -classpath ../target/classes -d ../../cpp/src/jni/
  2. src/XXX/core/: 这个包下就是这个库的核心部分,主要包含了音频采集器,视频采集器,连接过程中需要用到的各种回调接口,WebRTC核心类的Wrapper:

    • RTC -> webrtc::PeerConnectionFactoryInterface
    • PeerConnection -> webrtc::PeerConnectionInterface
    • DataChannel -> webrtc::DataChannelInterface
  3. src/XXX/model/: 定义了核心类中使用到的POJO对象

  4. src/XXX/utils/: 实现了不同平台下在Java端加载Shared Lib的过程

C++代码结构

C++这边的代码结构也比较简单,基本上和Java的接口是一一对应的。
JNI-Structure

  1. src/jni/: 由Java接口自动生成出来的C语言头文件,和Java相关的类型工具包
  2. src/media/: 音视频采集相关类,自定义编码相关类
    • 音频部分实现了一个自定义的AudioDeviceModule,在创建PeerConnectionFactory的时候将其注入
    • 视频部分实现了一个自定义的VideoCapturer,在创建VideoSource的时候将其注入
    • H264的视频编解码使用了FFMPEG中提供的libx264以及h264_nvenc(英伟达加速),这部分代码在创建PeerConnectionFactory的时候将其注入
  3. src/rtc/: 各个Java Wrapper接口的实现类
  4. src/rtc/network: 这里面定义了我自己的SocketFactory,通过它达到了限制端口的目的,这部分在创建PeerConnection的时候将其注入

Java代码相对来说都比较简单,就是给Native APIs做个壳儿,C++也有不少代码就是对更下层WebRTC lib的简单封装,这些部分我就一笔带过了,着重来讲一下这里比较难啃的骨头。

在C++中引入需要的库

整个C++项目我是基于CMake搭建的,其中使用到了libwebrtcFFMPEG(用于视频编码),libjpeg-turbo(用于将JavaVideoCapturer中获取的图片转码成YUV), CMake文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cmake_minimum_required(VERSION 3.8)
project(rtc)
set(CMAKE_CXX_STANDARD 11)

if (APPLE)
set(CMAKE_CXX_FLAGS "-fno-rtti -pthread") #WebRTC库用到的FLAGS
elseif (UNIX)
#除了前两个-fno-rtti -pthread,其他都是FFMPEG需要使用到的FLAGS
set(CMAKE_CXX_FLAGS "-fno-rtti -pthread -lva -lva-drm -lva-x11 -llzma -lX11 -lz -ldl -ltheoraenc -ltheoradec")
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-Bsymbolic")
endif()

include(./CMakeModules/FindFFMPEG.cmake) #引入FFMPEG
include(./CMakeModules/FindLibJpegTurbo.cmake) #引入Jpeg-Turbo

if (CMAKE_SYSTEM_NAME MATCHES "Linux") #C++代码中用于区分系统环境使用到属性
set_property(DIRECTORY APPEND PROPERTY COMPILE_DEFINITIONS WEBRTC_LINUX)
elseif(CMAKE_SYSTEM_NAME MATCHES "Darwin")
set_property(DIRECTORY APPEND PROPERTY COMPILE_DEFINITIONS WEBRTC_MAC)
endif()

find_package(LibWebRTC REQUIRED) #引入WebRTC
find_package(JNI REQUIRED) #引入JNI
include_directories(${Java_INCLUDE_PATH}) #JNI头文件
include_directories(${Java_INCLUDE_PATH2}) #JNI头文件
include(${LIBWEBRTC_USE_FILE}) #WebRTC头文件
include_directories("src")
include_directories(${CMAKE_CURRENT_BINARY_DIR})
include_directories(${TURBO_INCLUDE_DIRS}) #Jpeg-Turbo头文件

file(GLOB_RECURSE SOURCES *.cpp) #需要编译的内容
file(GLOB_RECURSE HEADERS *.h) #需要编译的内容头文件

add_library(rtc SHARED ${SOURCES} ${HEADERS}) #编译共享库
target_include_directories(rtc PRIVATE ${TURBO_INCLUDE_DIRS} ${FFMPEG_INCLUDE_DIRS})
target_link_libraries(rtc PRIVATE ${TURBO_LIBRARIES} ${FFMPEG_LIBRARIES} ${LIBWEBRTC_LIBRARIES}) #链接共享库

引入这些库的时候也踩了不少坑,尤其是使用FFMPEG的时候,下面简单分享一下。

编译FFMPEG

  1. 在Linux下编译FFMPEG,我主要参考了官方Guide, 但是我们这里需要有一些改动
    a. 如果有enable-shared开关一定要打开,官方Guide中都是disable的
    b. 编译的时候一定要加上“-fPIC”,否则在Linux下链接时会有错误提示。共享对象可能会被不同的进程加载到不同的位置上,如果共享对象中的指令使用了绝对地址、外部模块地址,那么在共享对象被加载时就必须根据相关模块的加载位置对这个地址做调整,也就是修改这些地址,让它在对应进程中能正确访问,而被修改到的段就不能实现多进程共享一份物理内存,它们在每个进程中都必须有一份物理内存的拷贝。fPIC指令就是为了让使用到同一个共享对象的多个进程能尽可能多的共享物理内存,它背后把那些涉及到绝对地址、外部模块地址访问的地方都抽离出来,保证代码段的内容可以多进程相同,实现共享。

    1
    2
    3
    /usr/bin/ld: test.o: relocation R_X86_64_32 against `a local symbol' can not be used when making a shared object; recompile with -fPIC
    test.o: could not read symbols: Bad value
    collect2: ld returned 1 exit status

    c. 如果您也需要Nvidia的支持的话,请参考官方Guide
    d. 最后分享一下我最终编译FFMPEG时使用到的命令

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    PATH="$HOME/bin:$PATH" PKG_CONFIG_PATH="$HOME/ffmpeg_build/lib/pkgconfig" ./configure \
    --prefix="$HOME/ffmpeg_build" \
    --pkg-config-flags="--static" \
    --extra-cflags="-I$HOME/ffmpeg_build/include" \
    --extra-ldflags="-L$HOME/ffmpeg_build/lib" \
    --extra-libs=-lpthread \
    --extra-libs=-lm \
    --bindir="$HOME/bin" \
    --enable-gpl \
    --enable-libfdk_aac \
    --enable-libfreetype \
    --enable-libmp3lame \
    --enable-libopus \
    --enable-libvorbis \
    --enable-libvpx \
    --enable-libx264 \
    --enable-libx265 \
    --enable-nonfree \
    --extra-cflags=-I/usr/local/cuda/include/ \
    --extra-ldflags=-L/usr/local/cuda/lib64 \
    --enable-shared \
    --cc="gcc -m64 -fPIC” \
    --enable-nvenc \
    --enable-cuda \
    --enable-cuvid \
    --enable-libnpp
  2. Mac上安装FFMPEG就比较简单粗暴, 一键安装带所有参数的版本

    1
    brew install ffmpeg $(brew options ffmpeg | grep -vE '\s' | grep -- '--with-' | tr '\n' ' ')

安装libjpeg-turbo

因为这个库比简单,我就直接下载了别人编译的版本

引入Turbo和FFMPEG

引入这两个库的方式非常类似,这里我就选取比较简单的FindLibJpegTurbo.cmake作为例子,FFMPEG与其相比就是寻找的下层依赖更多罢了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Try to find the libjpeg-turbo libraries and headers
#
# TURBO_INCLUDE_DIRS
# TURBO_LIBRARIES
# TURBO_FOUND

# Find header files
FIND_PATH(
TURBO_INCLUDE_DIRS turbojpeg.h
/opt/libjpeg-turbo/include/
)

FIND_LIBRARY(
TURBO_LIBRARY
NAMES libturbojpeg.a
PATH /opt/libjpeg-turbo/lib64
)

FIND_LIBRARY(
JPEG_LIBRARY
NAMES libjpeg.a
PATH /opt/libjpeg-turbo/lib64
)


IF (TURBO_LIBRARY)
SET(TURBO_FOUND TRUE)
ENDIF ()

IF (FFMPEG_FOUND AND TURBO_INCLUDE_DIRS)
SET(TURBO_FOUND TRUE)
SET(TURBO_LIBRARIES ${TURBO_LIBRARY} ${JPEG_LIBRARY})
MESSAGE(STATUS "Found Turbo library: ${TURBO_LIBRARIES}, ${TURBO_INCLUDE_DIRS}")
ELSE (FFMPEG_FOUND AND TURBO_INCLUDE_DIRS)
MESSAGE(STATUS "Not found Turbo library")
ENDIF ()

至此,所有准备工作总算是完了,让我们来看看到底是怎么调用Native APIs的吧。

参考内容

[1] JNI的替代者—使用JNA访问Java外部功能接口
[2] Linux共享对象之编译参数fPIC
[3] Android JNI 使用总结
[4] FFmpeg 仓库

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/webrtc/use-in-java/prepare/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。