搭建WebRTC视频会议应用系列3:Android端

在本文中,我们将编写一个简单、功能齐全的 WebRTC 会议 Android 应用程序。我们将通过创建对等连接、添加媒体轨道、信令、SDP 交换和 ICE 协商来设置会议应用程序。

在该系列的第 2 部分中,我们构建了一个可在浏览器中运行的 WebRTC 会议应用程序。在本文中,我们将为 Android 构建一个与第 2 部分类似的应用程序。屏幕截图如下:

搭建WebRTC视频会议应用系列3:Android端
WebRTC 会议应用程序

开始编码

我们将按照以下步骤创建会议应用程序。

  • 为应用程序创建布局
  • 创建 PeerConnection 对象
  • 添加视频和音频轨道
  • 信令
  • SDP 交换
  • ICE 协商

1. 创建布局

我们将在用户界面中使用以下组件

  • remoteView – 显示远程对等点视频的视频元素
  • localView – 视频元素,用于显示从摄像头捕捉到的视频。我们将在本文稍后部分添加 remoteView 和 localView 作为视频轨道的接收器。
  • meetingId – 用于共享 ID 的文本分组,两个人可以通过它加入通话。这将用于生成信号中使用的 MQTT 主题
  • role – 一个下拉菜单,用于帮助发送信号以建立 WebRTC 连接。角色可以是发起方或接收方。

2. 创建 RTCPeerConnection 对象

现在布局已经准备就绪,我们将开始实际使用 WebRTC API。RTCPeerConnection 是其中的第一个,它提供了在两个对等方之间流式传输媒体的核心 API。将有两个 RTCPeerConnection 对象,每个对等设备中都有一个,它们将在它们之间建立连接,并将媒体轨迹从一个对等设备流向另一个对等设备。

搭建WebRTC视频会议应用系列2:Web端实现一个会议应用程序
使用 PeerConnection API 连接的对等点

在 Android 中创建 PeerConnection 对象不像在 Web 中那么简单。我们首先需要初始化并创建 PeerConnectionFactory,然后从中创建一个新的 PeerConnection 对象。PeerConnectionFactory 对象随后将用于创建视频和音频轨道。首先在 build.gradle 中添加 WebRTC 依赖关系:

implementation 'io.getstream:stream-webrtc-android:1.0.2'

然后创建 PeerConnectionFactory,如下所示:

// Initialize PeerConnectionFactory
val pcfOptions = PeerConnectionFactory.InitializationOptions.builder(context)
    .setEnableInternalTracer(true)
    .createInitializationOptions()
PeerConnectionFactory.initialize(options)

// Create PeerConnectionFactory
val eglBase = EglBase.create()
val peerConnFactory = PeerConnectionFactory.builder()
    .setAudioDeviceModule(createJavaAudioDevice(context))
    .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase?.eglBaseContext))
    .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase?.eglBaseContext, true, true))
    .setOptions(PeerConnectionFactory.Options().apply {
        disableNetworkMonitor = true
    })
    .createPeerConnectionFactory()

在这里,eglBase 用于共享渲染和处理视频数据所需的资源。现在我们已经创建了 PeerConnectionFactory,可以从中创建一个新的 PeerConnection 对象。

val iceBuilder = PeerConnection.IceServer.builder("stun:stun.l.google.com:19302")
iceBuilder.setTlsCertPolicy(PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK)
val iceServers = arrayListOf(iceBuilder.createIceServer())

val rtcConfig = PeerConnection.RTCConfiguration(iceServers)
rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED
rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE
rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
rtcConfig.keyType = PeerConnection.KeyType.ECDSA
rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN

peerConnection = peerConnFactory?.createPeerConnection(rtcConfig, object : PeerConnection.Observer{}

在这里,我们首先创建一个 ICE 服务器列表,然后将其传入 RTCConfiguration 对象,再传入 PeerConnectionFactory 的 createPeerConnection 方法。ICE 服务器用于在运行于两台不同计算机的对等设备之间建立 P2P 连接,本文稍后将对此进行说明。在上述代码中,我们还在 RTCConfiguration 对象中传递了一些配置,我们将在以后的文章中讨论这些配置。此外,我们还需要实现 PeerConnection.Observer 接口中的方法,这将在本文稍后部分完成。现在我们有了 PeerConnection 对象,让我们添加需要与其他对等设备共享的媒体。

3. 为 RTCPeerConnection 添加媒体轨道

3.1 添加视频

创建和添加视频流需要几个步骤:

  • 创建视频源
  • 创建视频捕获器并初始化
  • 从视频源创建视频并渲染本地视频
  • 将轨道添加到 PC 对象

视频源、捕获器和轨道各自承担不同的责任。视频捕获器对象封装了用于捕获视频的 Camera2 Android API。视频源接口将捕获的视频作为帧输入 WebRTC 框架。视频轨道包含用于渲染本地和远程对等视频的 API。

以下是完成这 4 个步骤的代码:

// 1. Create video source
val localVideoSource = peerConnFactory?.createVideoSource(false)

// 2. Create video capturer and initialize
val enumerator = Camera2Enumerator(context)
val deviceNames = enumerator.deviceNames

// Try to get front facing camera. If it's not available, get any camera
for (deviceName in deviceNames) {
    if (enumerator.isFrontFacing(deviceName)) {
        videoCapturer = enumerator.createCapturer(deviceName, null)   
    }
}

if(videoCapturer == null) {
    for (deviceName in deviceNames) {
        if (!enumerator.isFrontFacing(deviceName)) {
            videoCapturer = enumerator.createCapturer(deviceName, null)
        }
    }
}

val surfaceTextureHelper = SurfaceTextureHelper.create(Thread.currentThread().name, eglBase.eglBaseContext)
videoCapturer?.initialize(surfaceTextureHelper, context, localVideoSource?.capturerObserver)
videoCapturer?.startCapture(480, 640, 30)

// 3. Create video track and render local video
localVideoTrack = peerConnFactory?.createVideoTrack("Video", localVideoSource)
localVideoTrack.addSink(localView)  // localView comes from layout

// 4. Add track to PC object created before
peerConnection?.addTrack(localVideoTrack)

3.2 添加音频

共享音频数据比共享视频更简单。我们只需创建音源和音轨。WebRTC 将在内部处理音频的采集和播放。下面是相关代码:

// 1. Create audio source
val audioSource = peerConnFactory?.createAudioSource(MediaConstraints())

// 2. Create audio track from audio source
val audioTrack = peerConnFactory?.createAudioTrack("Audio", audioSource)
audioTrack?.setEnabled(true)

// 3. Add the track to peerConnection
peerConnection?.addTrack(audioTrack)

3.3 监听远程轨道

完成以上两个步骤后,我们就成功添加了本地视频和音频轨道。但我们还必须监听远程同行添加的轨道。为此,我们需要实现传递给 createPeerConnection 方法的 PeerConnection.Observer 的 onTrack 方法。请注意,我们只是通过添加 sink 来处理视频数据。音频数据由 WebRTC 自动处理。

override fun onTrack(transceiver: RtpTransceiver?) {
    super.onTrack(transceiver)
    if (transceiver?.receiver?.track() is VideoTrack) {
        remoteVideoTrack = transceiver.receiver.track() as VideoTrack
        remoteView?.let {  // remoteView comes from the layout
            remoteVideoTrack?.addSink(it)
        }
    }
}

4. 信令

在设置会议应用程序的下两个步骤中,我们需要在两个对等方之间交换设置信息。这种交换连接相关信息的过程在 WebRTC 领域称为信令。由于对等方之间的连接尚未建立,我们需要在带外进行信令处理。在进入 SDP 和 ICE 之前,让我们先简单讨论一下信令架构。

本文将使用 MQTT 协议进行信令传送。但我们也可以使用任何其他协议/机制来代替 MQTT。信令所需的一切就是能够快速、可靠地向对方发送信息。我之所以选择 MQTT,是因为它是一个简单轻量级的发布-子协议,而且有一个免费托管的 HiveMQ 服务器,我可以毫不费力地使用它。

MQTT 使用发布子模型在客户端之间交换消息。客户端使用的主题是在发布或订阅后即时创建的。首先,在 HTML 文件中加入 MQTT 库,如下所示:

implementation 'com.hivemq:hivemq-mqtt-client:1.2.1'

加入程序库后,我们与 MQTT 代理建立连接,并订阅主题,如下所示:

val client = MqttClient.builder()
    .identifier(clientId)  // clientID is a random UUID
    .serverHost("mqtt-dashboard.com")
    .serverPort(1883)
    .useMqttVersion5()
    .buildAsync()

val connFeature = client.connectWith().cleanStart(true).send()
connFeature.whenComplete { _, throwable ->
    if (throwable != null) {
        Log.e(TAG, "Connection Error: ${throwable.cause}", )
    }
    
    else {
        // If we wanted to send any messages before connection, they will be queued in pendingMessages and will be sent after connected
        pendingMessage.forEach { message ->
            publishInternal(message)
        }
        pendingMessage.clear()
    }
}

// Subscribe to topic
val topic = "webrtc/conference/$meetingId"  //meetingID is entered by user
client.subscribeWith()
    .topicFilter(topic)
    .send()

在上面的代码中,我们在主题名称中包含了会议 ID,这样就能正确连接两个想要相互连接的参与者。让我们看看在 MQTT 主题上发布消息的代码段。

private fun sendMqttMessage(payload: JSONObject) {
    payload.put("role", role)
    mqtt.publish(payload.toString())
}

private fun publish(message: String) {
    client.publishWith()
        .topic(topic)
        .payload(message.toByteArray())
        .send()
        .whenComplete { publishResult, throwable ->
            if (throwable != null) Log.e(TAG, "publish error: $throwable")
        }
}

我们使用同一个主题来发布会议两个参与者的信息。由于两个对等方都会发布和订阅同一个主题,因此区分来自另一个对等方的消息至关重要。为此,我们为每条 MQTT 消息附加了 self 角色。接收消息时,我们会检查消息中的角色是否与 self 角色匹配,如果匹配则忽略消息,如下所示。

val mqttMessageListener : (String, String) -> Unit =  { _, payload ->
    val message = JSONObject(payload)
    val role = message.get("role") as String
    val type = message.get("type") as String
    if (role != this.role)  {
        if (type == "sdp") handleSdpMessage(message)
        else if (type == "ice") handleICEMessage(message)
    }
}

client.publishes(MqttGlobalPublishFilter.ALL) { message ->
    val payload = Charsets.UTF_8.decode(message.payload.get()).toString()
    Log.i(TAG, "Received Message: $payload")
    mqttMessageListener(topic, payload)
}

5. SDP 交换

SDP 交换是连接建立过程的第一步。SDP 交换过程包括从发起方对等端生成一个要约,接收方对等端接收该要约并生成一个应答,然后将应答发送回发起方对等端。由于这种要约-应答协议,我们需要使用角色下拉元素来区分发起方和接收方。

搭建WebRTC视频会议应用系列2:Web端实现一个会议应用程序
SDP 交换

发起方对等端通过创建 SDP 要约并通过信令信道将其传送给对方,从而开始信令过程。这里需要注意的一点是,每个对等端都将自己的 SDP 设置为本地描述,将从另一个对等端收到的 SDP 设置为远程描述。这需要根据对等方的能力协商媒体协议。

if(role == "Initiator") {
    peerConnection?.createOffer(object : SdpObserver {
        override fun onCreateSuccess(sdp: SessionDescription?) {
            peerConnection?.setLocalDescription(dummySdpObserver, sdp)
            val payload = JSONObject()
            payload.put("type", "sdp")
            payload.put("sdp", sdp?.description)
            sendMqttMessage(payload)
        }

        override fun onSetSuccess() {
            Log.i(TAG, "onSetSuccess: ")
        }

        override fun onCreateFailure(error: String?) {
            Log.e(TAG, "onCreateFailure: $error", )
        }

        override fun onSetFailure(error: String?) {
            Log.e(TAG, "onSetFailure: $error", )
        }

    }, createSDPConstraints())
}

SDP 信息包含有关添加了哪些媒体轨道以及支持哪些协议来传输媒体的信息。例如,SRTP 用于传输音频和视频,而 SCTP 用于传输其他数据。SDP 还包含进一步配置这些协议的各种参数。

接收方对等体收到 SDP 消息后,会将 SDP 设置为远程描述,并生成 SDP 应答。生成的 SDP 答案被设置为接收方对等体的本地描述并传回给发起方对等体,发起方对等体将答案设置为远程描述。以下代码处理通过信令接收到的 SDP 信息。

if(role == "Initiator") {
    val sessionDescription = SessionDescription(SessionDescription.Type.ANSWER, message.get("sdp") as String)
    peerConnection?.setRemoteDescription(dummySdpObserver, sessionDescription)
} 

else if (role == "Receiver") {
    val sessionDescription = SessionDescription(SessionDescription.Type.OFFER, message.get("sdp") as String)
    peerConnection?.setRemoteDescription(dummySdpObserver, sessionDescription)
    peerConnection?.createAnswer(object : SdpObserver {
        override fun onCreateSuccess(sdp: SessionDescription?) {
            peerConnection?.setLocalDescription(dummySdpObserver, sdp)
            val payload = JSONObject()
            payload.put("type", "sdp")
            payload.put("sdp", sdp?.description)

            sendMqttMessage(payload)
        }

        override fun onSetSuccess() {
            TODO("Not yet implemented")
        }

        override fun onCreateFailure(error: String?) {
            TODO("Not yet implemented")
        }

        override fun onSetFailure(error: String?) {
            TODO("Not yet implemented")
        }

    }, createSDPConstraints())
}

这里有一点很重要,因为我们使用 MQTT 作为信令传输,所以接收方应首先启动并订阅 MQTT 主题,然后等待启动方的消息。如果发起方先启动,由于接收方仍未连接,SDP 消息就会丢失,导致 SDP 协商失败。

6. ICE 协商和建立连接

添加本地描述后,连接对象开始收集 ICE 候选者。候选 ICE 包含节点如何在网络中到达连接对象的信息。每个 ICE 候选对象都是一种可能的连接方式,但并不保证一定能建立连接。候选对象在两个对等节点上收集,并应与另一个对等节点交换。交换后,将对所有候选配对(每个对等端各一个)进行检查,以找到两个对等端都能相互连接的配对。

创建 PeerConnection 对象时使用的 PeerConnection.Observer 对象具有 onIceCandidate 方法,WebRTC 每次收集 ICE 候选对象时都会调用该方法。让我们实现该方法,将收集到的候选 ICE 发送给远程对等设备。

override fun onIceCandidate(ice: IceCandidate?) {
    val payload = JSONObject()
    payload.put("type", "ice")
    payload.put("ice", ice)
    payload.put("sdpMid", ice?.sdpMid)
    payload.put("sdpMLineIndex", ice?.sdpMLineIndex)
    payload.put("description", ice?.sdp)

    sendMqttMessage(payload)
}

远程对等方收到 ICE 候选后,将其添加到对等方连接中,如下所示:

private fun handleICEMessage(message: JSONObject) {
    val sdpMid = message.get("sdpMid") as String
    val sdpMLineIndex = message.get("sdpMLineIndex") as Int
    val iceDescription = message.get("description") as String
    val ice = IceCandidate(sdpMid, sdpMLineIndex, iceDescription)

    peerConnection?.addIceCandidate(ice)
}

将所有内容整合在一起

这就是一个正常运行的 Android webRTC 会议应用程序所需的全部代码。将目前编写的代码安装到两台安卓设备上并启动应用程序。进入设置,授予所需的权限(我们没有处理动态权限,因此需要从设置中授予权限)。在两台设备中输入相同的会议 ID。选择一台设备作为接收方,另一台作为发起方。首先启动接收器,然后启动启动器。这样,你就拥有了自己的会议应用程序。

本文的源代码可以在https://github.com/bharath-kotha/WebRTC-Articles/tree/master/webrtc-android-1找到。

作者:Bharath Kotha

本文来自作者投稿,版权归原作者所有。如需转载,请注明出处:https://www.nxrte.com/jishu/webrtc/32724.html

(0)

相关推荐

发表回复

登录后才能评论