Android端的实现

在 Android 端,我们将按以下几个步骤实现 WebRTC 一对一通信:1)申请权限;2)引入 WebRTC 库;3)构造 PeerConnectionFactory;4)创建音视频源;5)视频采集;6)视频渲染;7)创建 PeerConnection;8)建立信令系统。

申请权限

我们使用 WebRTC Native 开发音视频互动应用时,需要申请一些访问硬件的权限。比如想让对方看到你的视频,就要用摄像头采集视频数据;想让对方听到你的声音,就要用录音设备采集声音。在 Android 端,所有需要访问设备的应用程序都需要在使用时申请权限,以保证用户知道哪些应用在访问其设备。对于 WebRTC 通信应用来说,至少要申请以下三种权限:

  • CAMERA 权限,用于采集视频数据。

  • RECORD_AUDIO 权限,用于采集音频数据。

  • INTERNET 权限,用于通过网卡传输媒体数据。

除此之外,在 Android 端申请权限还分为静态权限申请和动态权限申请。

如何在 Android 端申请静态权限,如代码 8.1 所示。当你在 Android Studio 中创建好 一个 Android 项目后,只要在项目中的 AndroidManifest.xml 文件中增加以下代码,就完成了静态权限的申请。

代码8.1 申请静态权限
...
<uses-feature
    android:name="android.hardware.camera" />
<uses-feature
    android:glEsVersion="0x00020000"
    android:required="true" />
...
<uses-permission
    android:name="android.permission.CAMERA" />
<uses-permission
    android:name="android.permission.RECORD_AUDIO" />
<uses-permission
    android:name="android.permission.INTERNET" />
...

随着 Android 的发展,对安全性要求越来越高。现在开发 Android 应用时,除了申请静态权限外,还需要申请动态权限。也就是在执行 Android 应用程序时,调用如代码 8.2 所示的 API。

代码8.2 申请动态权限接口
void requestPermissions(String [] permissions ,int requestCode);

申请动态权限看似简单,但写好它并不容易。如果我们自己处理的话,对各种情况都要考虑到,需要写不少代码。作为一名合格的程序员,我们一定要秉承 “简单就是美” 的原则,能少写就少写,能不写就不写。

Android 官方给我们提供了一个非常好用的用于申请动态权限的库,即 EasyPermissions。有了这个库,我们就不用考虑申请动态权限的众多细节了。

EasyPermissions 库使用起来特别方便,只要在 MainActivity 文件的 onCreate() 方法中调用 EasyPermissions 库的 requestPermissions() 方法即可,当然同时还要实现 onRequestPermissionsResult() 回调方法,这样就完成了动态权限的申请,如代码 8.3 所示。

代码8.3 通过EasyPermissions申请权限
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ...

    String[] perms = {
        Manifest.permission.CAMERA,
        Manifest.permission.RECORD_AUDIO
    };

    if (!EasyPermissions.hasPermissions(this, perms)) {
        EasyPermissions.requestPermissions(
            this,
            "Need permissions for camera & microphone",
            0,
            perms
        );
    }
}

@Override
public void onRequestPermissionsResult(
    int requestCode,
    String[] permissions,
    int[] grantResults
) {
    super.onRequestPermissionsResult(
        requestCode,
        permissions,
        grantResults
    );

    EasyPermissions.onRequestPermissionsResult(
        requestCode,
        permissions,
        grantResults,
        this
    );
}

通过上面的代码,我们将权限申请好了。接下来开始做第二步,看看在 Android 下如何引入 WebRTC 库。

引入WebRTC库

在 Android 端通过 WebRTC Native 方式开发音视频通信程序时,需要引入两个比较重要的库:一个是 WebRTC;另一个是 socket.io 库,用来与信令服务器通信。

要引入 WebRTC 库,需要在 Android Studio 中 Module 级别的 build.gradle 文件中增加以下代码,如代码 8.4 所示。

代码8.4 引入WebRTC库
dependencies {
    ...
    implementation 'org.webrtc:google-webrtc:1.0.+'
    ...
}

通过上面的代码,我们就在 Android 项目中引入了 WebRTC 库。接下来 socket.io 库的引入也是如此,如代码 8.5 所示。

代码8.5 引入socket.io库
dependencies {
    ...
    implementation 'io.socket:socket.io-client:1.0.0'
    ...
}

然后再引入前面介绍的 EasyPermissions 库,这样我们在 Android 项目中就引入了三个库,真正的代码应该写成如代码 8.6 所示的样子。

代码8.6 引入三个库
dependencies {
    ...
    implementation 'io.socket:socket.io-client:1.0.0'
    implementation 'org.webrtc:google-webrtc:1.0.+'
    implementation 'pub.devrel:easypermissions:1.1.3'
    ...
}

我们需要的库全部引入进来之后,接下来就可以利用 WebRTC 库开发 Android 实时互动应用程序。

构造PeerConnectionFactory

要在 Android 端使用 Native 方式开发 WebRTC 实时互动应用程序,第一步就是要构造核心对象 PeerConnectionFactory,有了它才能开展后续的工作。在 WebRTC 中大量使用了设计模式,核心对象 PeerConnectionFactory 是由构建者模式构建出来的,而它本身使用的又是工厂模式,所以熟悉设计模式对于理解和使用 WebRTC 会有特别大的帮助。PeerConnectionFactory 对象的构造如代码 8.7 所示。

代码8.7 构造PeerConnectionFactory
PeerConnectionFactory.Builder builder =
    PeerConnectionFactory.builder()
        .setOptions(options)
        .setAudioDeviceModule(adm)
        .setVideoEncoderFactory(encoderFactory)
        .setVideoDecoderFactory(decoderFactory);

return builder.createPeerConnectionFactory();

通过上面的代码可以看到,在构造 PeerConnectionFactory 对象时,可以为它设置一些选项,如是否开启 DTLS 等;还可以为它指定音视频的编解码器,如 VP8/VP9、H264 等。这也是 WebRTC 要使用构建者模式来构造 PeerConnectionFactory 的原因。读者现在应该知道更换 WebRTC 引擎的编解码器该从哪里设置了。

创建音视频源

有了 PeerConnectionFactory 对象,就可以利用它来创建数据源(音频源/视频源)。实际上,数据源是 WebRTC 抽象出来的一个对象,主要是让上层逻辑与底层的音视频设备之间解耦。数据源可以从不同的音视频设备中获取数据,并将数据输出给上层的 Track。创建音视频源如代码 8.8 所示。

代码8.8 Android端创建音视频源
VideoSource videoSource =
    mPeerConnectionFactory.createVideoSource(false);
mVideoTrack = mPeerConnectionFactory
    .createVideoTrack(
        VIDEO_TRACK_ID,
        videoSource
    );

AudioSource audioSource = mPeerConnectionFactory
    .createAudioSource(new MediaConstraints());
mAudioTrack = mPeerConnectionFactory
    .createAudioTrack(
        AUDIO_TRACK_ID,
        audioSource
    );

在上面代码中可以看到,PeerConnectionFactory 对象首先创建了音频数据源 Audio Source 和视频数据源 VideoSource,然后又创建了 AudioTrack/VideoTrack 对象,并让 AudioTrack/VideoTrack 与对应的 AudioSource/VideoSource 进行了绑定,相当于为 AudioSource/VideoSource 指定了输出。

需要注意的是:对于音频来说,在创建 AudioSource 时就开始从默认的音频设备捕获音频数据了;而对于视频来说,还需要指定采集视频数据的设备,然后使用观察者模式从指定设备中获取数据。

视频采集

在 Android 系统下有两种 Camera:一种称为 Camera1,是一种比较老的采集视频数据的方式;另一种称为 Camera2,是一种新的采集视频方法。它们之间的最大区别是 Camera1 使用同步方式调用 API,而 Camera2 使用异步方式调用 API,所以 Camera2 比 Camera1 更高效。

默认情况下,应该尽量使用 Camera2 来采集视频数据。但如果有些机型不支持 Camera2,就只能选择使用 Camera1 了。我们看一下 WebRTC 是如何选择使用哪种 Camera 采集视频数据的,可参见代码 8.9。

代码8.9 Android端视频采集
private VideoCapturer createVideoCapturer() {
    if (Camera2Enumerator.isSupported(this)) {
        return createCameraCapturer(new Camera2Enumerator(this));
    } else {
        return createCameraCapturer(new Camera1Enumerator(true));
    }
}

上述代码非常简单,它通过Camera2Enumerator类判断该机型是否支持Camera2。如果支持Camera2,它会将Camera2Enumerator对象传给代码8.10 中的createCameraCap turer()函数;否则将Camera1Enumerator对象传给createCameraCapturer()函数。

这里需要注意的是,Camera1和Camera2并不是指具体的设备,而是指控制摄像头的系统。千万不要把Camera1和Camera2与前置摄像头或后置摄像头混到一起。

一般情况下,移动端都有两个摄像头,即前置摄像头和后置摄像头。所以在做移动端音视频应用开发时,要选择默认使用的摄像头。通常情况下我们把前置摄像头作为默认摄像头。

在 WebRTC 中提供了非常方便的获取视频设备的类,即 CameraEnumerator 类。通过该类对象,可以很容易地获得 Android 系统上所有的摄像头,而且还能通过 CameraEnumerator 的 isFrontFacing() 方法检测出该摄像头是前置摄像头还是后置摄像头,具体参见代码 8.10。

代码8.10 查找摄像头
private VideoCapturer createCameraCapturer(CameraEnumerator enumerator) {
    final String[] deviceNames = enumerator.getDeviceNames();

    // 首先,试着找到前置摄像头
    for (String deviceName : deviceNames) {
        if (enumerator.isFrontFacing(deviceName)) {
            VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);
            if (videoCapturer != null) {
                return videoCapturer;
            }
        }
    }

    // 未找到前置摄像头
    // 尝试寻找其他摄像头
    for (String deviceName : deviceNames) {
        if (!enumerator.isFrontFacing(deviceName)) {
            VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);
            if (videoCapturer != null) {
                return videoCapturer;
            }
        }
    }

    return null;
}

上面是 createCameraCapturer() 函数的实现,这个函数代码看上去比较多,但其逻辑却非常简单。首先获得 Android 系统下的所有摄像头设备;然后对设备进行遍历,查找到第一个前置摄像头后将其作为默认摄像头。如果没有找到前置摄像头,则选择第一个后置摄像头作为默认摄像头。

目前 VideoSource 与 VideoTrack 已经关联在一起了,且 VideoCapturer 也创建好了。接下来,我们只要将 VideoCapturer 与 VideoSource 再次关联到一起,VideoTrack 就可以从设备上获取到源源不断的音视频数据了。在 Android 端,VideoCapturer 与 VideoSource 是如何关联到一起的呢?可参见代码 8.11。

代码8.11 关联VideoCapturer与VideoSource
SurfaceTextureHelper surfaceTextureHelper =
    SurfaceTextureHelper.create("CaptureThread", mRootEglBase.getEglBaseContext());

mVideoCapturer.initialize(
    surfaceTextureHelper,
    getApplicationContext(),
    mVideoCapturer.getCapturerObserver()
);

mVideoTrack.setEnabled(true);

如上面代码所示,VideoCapturer 与 VideoSource 是通过 VideoCapturer 的 initialize() 函数关联到一起的。VideoCapturer 的 initialize() 函数需要三个参数:第一个参数是 SurfaceTextureHelper。在 Android 系统中,必须为 Camera 设置一个 Surface,这样它才能开启摄像头,并从摄像头中获取视频数据。VideoCapturer 就是利用 SurfaceTextureHelper 来获取 Surface 的,这也是 VideoCapturer 需要 SurfaceTextureHelper 的原因。第二个参数是 ApplicationContext,用于获取与应用相关的数据。第三个参数是 CapturerObserver,其作用是 Capturer 的观察者,VideoSource 可以通过它从 Capturer 获取视频数据。

当一切准备就绪后,最后还要调用 VideoCapturer 对象的 startCapture() 方法,这样 Camera 才会真正地开始工作,具体代码参见代码 8.12。

代码8.12 打开摄像头
@Override
protected void onResume() {
    super.onResume();
    mVideoCapturer.startCapture(VIDEO_RESOLUTION_WIDTH, VIDEO_RESOLUTION_HEIGHT, VIDEO_FPS);
}

上面代码中关键的只有一行,即 startCapture() 函数。该函数需要三个参数,采集的视频宽度、高度以及帧率。需要注意的是,采集的分辨率一定要符合 16:9/9:16/4:3/3:4这样的比例,否则在渲染时很可能会出现问题,如绿边等。另外,对于实时通信场景来说,一般帧率都不会设置得太高,通常 15 帧就可以满足大部分的需求。

现在视频数据流从 Capturer 到 Track 的流转全部打通了,接下来需要考虑的问题是,VideoTrack 将得到的视频数据如何展示出来呢?

视频渲染

在 Android 端,WebRTC Native 使用 OpenGL ES 进行视频渲染。OpenGL ES 渲染视频的基本步骤为:先将视频从主内存中复制到 GPU 上,然后在 GPU 上通过 OpenGLES 管道渲染到 GPU 的内存中,之后输出给显卡并最终显示在手机屏幕上。

按照上面的步骤使用 OpenGL ES 渲染视频,实现起来还是很麻烦的,会涉及矩阵变化、OpenGL 编程等知识。不过 WebRTC 已经封装好了相应的控件,我们直接使用它们就可以。

在 Android 端,WebRTC 是基于 SurfaceView 封装的视频控件,称为 SurfaceViewRenderer。我们开发的音视频应用程序至少需要两个 SurfaceViewRenderer:一个用于显示本地视频,另一个用于显示远端视频,具体参见代码 8.13。

代码8.13 创建View
<org.webrtc.SurfaceViewRenderer
    android:id="@+id/LocalSurfaceView"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center" />

<org.webrtc.SurfaceViewRenderer
    android:id="@+id/RemoteSurfaceView"
    android:layout_width="120dp"
    android:layout_height="160dp"
    android:layout_gravity="top|end"
    android:layout_margin="16dp" />

上面代码中定义的两个 SurfaceViewRenderer 中,第一个用于显示本地视频,其宽高与手机屏幕大小一样;第二个用于显示远端视频,其宽高为 120×160。此外,由于第二个 SurfaceViewRenderer 的 layout_gravity 属性被设置为 “top”,所以它可以悬浮在本地视频之上显示。

当然,只定义显示视频的 View 还不够,还需要对这两个 View 做一些其他设置,比如视频的填充模式、是否开启硬件拉伸加速等,如代码 8.14 所示。

代码8.14 初始化View
mLocalSurfaceView.init(
    mRootEglBase.getEglBaseContext(),
    null
);
mLocalSurfaceView.setScalingType(
    RendererCommon.ScalingType.SCALE_ASPECT_FILL
);
mLocalSurfaceView.setMirror(true);
mLocalSurfaceView.setEnableHardwareScaler(false);

在上面的代码中,第 2 行代码的含义是使用EGL初始化SurfaceViewRenderer。EGL是OpenGL ES与SurfaceViewRenderer之间的桥梁,它可以调用OpenGL ES渲染视频,再将结果显示到SurfaceViewRenderer上。第4行代码用于设置图像的填充模式。SCALE_ASPECT_FILL模式表示将视频按比例填充到View中。第6行代码让视频图像按纵轴反转显示。之所以这样做,是因为采集的视频图像与我们眼睛看到的内容正好相反。第7行代码用于设置是否打开硬件视频拉伸功能。代码中设置为不打开硬件视频拉伸功能。由于远端视频的View与本地视频View设置的参数是一样的,这里就不再赘述了。

View设置好后,接下来只要将前面准备好的视频与View关联到一起,就可以在View中看到视频的内容了,如代码8.15所示。

代码8.15 视频与View绑定
mVideoTrack.addSink(mLocalSurfaceView);

上面代码的含义就是将 mLocalSurfaceView 设置为 VideoTrack 的输出。前面已经介绍过,WebRTC 通过 Capturer 采集到视频数据后,会交给 VideoSource,VideoSource 作为 VideoTrack 的源又会将数据转发给 VideoTrack。而将 View 设置为 VideoTrack 的输出后,最终视频就会在 View 中展示出来。这就是媒体数据流转的整个过程。

通过以上讲解,相信读者应该对 WebRTC 如何采集数据、如何渲染数据有了基本的认识。实际上,对于远端来说,它与本地视频的渲染及显示是类似的,只不过数据源是从网络获取的。下面我们就来看一下如何获取远端媒体数据或将数据发送给远端。

创建PeerConnection

要想从远端获取数据或将数据发送给远端,首先要创建 PeerConnection 对象。该对象类似于一个超级 Socket,为通信双方提供了网络通道,所有媒体数据的传输都是由它来完成的。下面我们看看在 WebRTC Native 中如何创建 PeerConnection 对象,如代码 8.16 所示。

代码8.16 创建PeerConnection
PeerConnection.RTCConfiguration rtcConfig =
    new PeerConnection.RTCConfiguration(iceServers);

PeerConnection connection =
    mPeerConnectionFactory.createPeerConnection(
        rtcConfig,
        mPeerConnectionObserver
    );

connection.addTrack(mVideoTrack, mediaStreamLabels);
connection.addTrack(mAudioTrack, mediaStreamLabels);

从上面的代码中可以看到,PeerConnection 对象也是由 PeerConnectionFactory 对象创建出来的。仔细观察还会发现 PeerConnection 对象与 JS 中的 RTCPeerConnection 对象很类似:首先,它也需要一个 rtcConfig 参数,用于指明 ICE Server 的地址,这样它才能使用 ICE 机制建立连接;其次,PeerConnection 对象也有 addTrack() 方法,用于将音视频轨添加到 PeerConnection 对象中,这样才能将本地媒体数据发送给远端。

当然它们之间也有很明显的区别,尤其是在事件处理的实现上。对于 JS 中的 RTCPeerConnection 对象来说,可以直接实现用 onXXX() 方法来处理其事件,如实现用 onicecandidate 处理 Candidate 事件、用 ontrack 处理 Track 事件等。但在 WebRTC Native 中,处理事件是通过观察者模式来实现的,如代码 8.17 所示。

代码8.17 设置观察者模式
mPeerConnectionObserver = new PeerConnection.Observer() {
    @Override
    public void onIceCandidate(IceCandidate iceCandidate) {
        // Your implementation here
    }

    @Override
    public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
        // Your implementation here
    }

    // Other overridden methods
};

上面代码是 PeerConnection 观察者对象的实现。该对象中的 onIceCandidate 方法与 RTCPeerConnection 中的 onicecandidate 方法对应,onAddTrack 方法与 ontrack 方法对应。观察者对象创建好后,作为 PeerConnectionFactory.createPeerConnection() 方法的第二个参数,最终才能将 PeerConnection 对象创建出来。

创建好 PeerConnection 对象后,就可以参照第 5 章中描述的步骤实现一对一通信了,如进行媒体协商(需要信令服务器的配合)、交换 Candidate 等,最终实现双方媒体数据的互通。

建立信令系统

在整个 WebRTC 双方交互的过程中,其业务逻辑的核心就是信令,所有的模块都是通过信令串联起来的。Android 端通过 WebRTC Native 实现音视频互动也不例外。

为了与 JS 端互通,Android 端必须使用与 JS 端一样的信令系统。这套系统是由信令、信令状态机构成的,具体内容可参见第 5 章 5.6 节中的内容。

在 Android 端,我们仍然使用 socket.io 库与之前搭建的信令服务器互联。由于 socket.io 是跨平台的,所以无论是在 JS 中还是在 Android 中,它都可以让客户端与服务器相连,非常方便。