Android:WebRtc实现多人音视频(中)

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 14 天,点击查看活动详情

上一篇(Android:WebRtc实现多人音视频(上))中我们了解了基于WebRtc音视频的基础流程,这篇我们将用代码来进行详细的实战说明。

在app模块build.gradle中添加依赖:

implementation "org.webrtc:google-webrtc:1.0+"

Socket的使用相信大家都会,只是做一个信令转发这里就不作陈述,因为会涉及多人音视频,所以我们在最初的设计时就要考虑这点,避免之后重构的麻烦。首先初始化工厂,用于创建 PeerConnection

private fun createConnectionFactory(): PeerConnectionFactory {
    // 1. 初始化的方法,必须在开始之前调用
    PeerConnectionFactory.initialize(
        PeerConnectionFactory.InitializationOptions
            .builder(mContext)
            .createInitializationOptions()
    )
    // 2. 设置编解码方式:默认方法
    val encoderFactory = DefaultVideoEncoderFactory(mRootEglBase!!.eglBaseContext,true,true)
    val decoderFactory = DefaultVideoDecoderFactory(mRootEglBase!!.eglBaseContext)
    // 构造Factory
    val audioDeviceModule = JavaAudioDeviceModule.builder(mContext).createAudioDeviceModule()
    val options = PeerConnectionFactory.Options()
    return PeerConnectionFactory.builder()
        .setOptions(options)
        .setAudioDeviceModule(audioDeviceModule)
        .setVideoEncoderFactory(encoderFactory)
        .setVideoDecoderFactory(decoderFactory)
        .createPeerConnectionFactory()
}

创建好PeerConnectionFactory后其实就可以创建本地的音视频流了,也就页面已经具有显示本地视频的功能了。

private fun createLocalStream() {
    localStream = factory?.createLocalMediaStream("ARDAMS")
    // 音频
    audioSource = factory?.createAudioSource(createAudioConstraints())
    localAudioTrack = factory?.createAudioTrack(AUDIO_TRACK_ID, audioSource)
    localStream?.addTrack(localAudioTrack)

    // 视频
    if (!mIsAudioOnly) {
        captureAndroid = createVideoCapture()
        surfaceTextureHelper =SurfaceTextureHelper.create("CaptureThread",mRootEglBase?.eglBaseContext)
        videoSource = captureAndroid?.isScreencast?.let { factory?.createVideoSource(it) }
        captureAndroid?.initialize(surfaceTextureHelper,mContext,videoSource?.capturerObserver)
        captureAndroid?.startCapture(VIDEO_RESOLUTION_WIDTH, VIDEO_RESOLUTION_HEIGHT, FPS)
        val localVideoTrack = factory?.createVideoTrack(VIDEO_TRACK_ID, videoSource)
        localStream?.addTrack(localVideoTrack)
    }
}

接下来我们需要定义一个包含PeerConnection的类,方便根据id找到对应PeerConnection。这里我们也实现了SdpObserver和PeerConnection.Observer接口,用于监听设置sdp是否成功和连接状态,其中onAddStream回调中会返回远端的音视频流信息。

class Peer(
    private val mUserId: String,
    private val pc: PeerConnection?
) : SdpObserver, PeerConnection.Observer {

 //远端音视频流信息
 override fun onAddStream(stream: MediaStream) {
     _remoteStream = stream
 }
    ......
}

这里我们使用SurfaceViewRenderer来接收,配合回调在UI线程中将其显示到页面。

renderer = SurfaceViewRenderer(context)
renderer?.init(mRootEglBase!!.eglBaseContext, object : RendererEvents {
    override fun onFirstFrameRendered() {
        Timber.e("socket createRender onFirstFrameRendered")
    }
    override fun onFrameResolutionChanged(
        videoWidth: Int,
        videoHeight: Int,
        rotation: Int
    ) {
        Timber.e("socket createRender onFrameResolutionChanged")
    }
})
renderer?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
renderer?.setMirror(true)
renderer?.setZOrderMediaOverlay(isOverlay)
sink = ProxyVideoSink()
sink?.setTarget(renderer)
if (_remoteStream != null && _remoteStream!!.videoTracks.size > 0) {
    _remoteStream!!.videoTracks[0].addSink(sink)
}

由于要在公网下实现音视频,PeerConnection的创建需要配置ice,服务端创建stun服务,移动端在这里配置。stun服务是否能够穿透可到官网进行测试。搭建stun/turn服务可参考链接

val ice = IceServer.builder("stun服务地址")
    .createIceServer()
iceServers.add(ice)

private fun createPeerConnection(): PeerConnection? {
    val rtcConfig = RTCConfiguration(iceServers)
    return mFactory?.createPeerConnection(rtcConfig, this)
}

到此,我们只需按照之前的先后顺序,通过Socket交换信令了。先从A端开始讲,

A端创建并发送offer

//创建offer
peerConnection.createOffer(this, offerOrAnswerConstraint())

//创建MediaConstraints 用于peer创建offer/answer入参
private fun offerOrAnswerConstraint(): MediaConstraints {
        val mediaConstraints = MediaConstraints()
        val keyValuePairs = ArrayList<MediaConstraints.KeyValuePair>()
        keyValuePairs.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
        keyValuePairs.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
        mediaConstraints.mandatory.addAll(keyValuePairs)
        mediaConstraints.optional.add(
            MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")
        )
        return mediaConstraints
    }

//在设置为本地sdp成功后发送offer
override fun onSetSuccess() {
   socket.emit(Tags.REQUEST, jsonString)
}

A端收到B端Answer

//获取answer中用户id用于查找对应peerConnection,之后设置远端sdp
socket.on(Tags.RESPONSE) { args ->
    if (args != null) {
        val data = args[0].toString()
        val bean = gson.fromJson(data, HandleBean::class.java)
        val userId = bean.id
        val sdp = bean.sdp
        val peerConnection = peers[userId]
            peerConnection?.let {
                val sessionDescription = SessionDescription(SessionDescription.Type.ANSWER,sdp)
                it.setRemoteDescription(sessionDescription)
            }
    }
}

//ice交换同时进行
socket.on(Tags.RESPONSE) { args ->
    if (args != null) {
        val data = args[0].toString()
        val bean = gson.fromJson(data, HandleBean::class.java)
        val userId = bean.id
        val peerConnection = peers[userId]
            peerConnection?.let {
                val iceCandidate = IceCandidate(bean.iceId,bean.label,bean.candidate)
                it.addRemoteIceCandidate(iceCandidate)
            }
    }
}

//如果信息无误,onAddStream方法则会触发,拿到远端视频流。

再来说说B端:

B端接收到A端offer,并创建answer

这一步和A端收到B端offer一样,都是将接收的sdp信息设置到远端,唯一不同的是增加了一步创建answer。

socket.on(Tags.RESPONSE) { args ->
    if (args != null) {
        val data = args[0].toString()
        val bean = gson.fromJson(data, HandleBean::class.java)
        val userId = bean.id
        val sdp = bean.sdp
        val peerConnection = peers[userId]
            peerConnection?.let {
                val sessionDescription = SessionDescription(SessionDescription.Type.ANSWER,sdp)
                it.setRemoteDescription(sessionDescription)
                //这步很关键
                it.createAnswer(this, offerOrAnswerConstraint())
            }
    }
}

B端发送answer到A端

//将设置成功后的answer发送给A
override fun onSetSuccess() {
  socket?.emit(Tags.REQUEST, jsonString)
}

//ice交换同时进行
socket.on(Tags.RESPONSE) { args ->
    if (args != null) {
        val data = args[0].toString()
        val bean = gson.fromJson(data, HandleBean::class.java)
        val userId = bean.id
        val peerConnection = peers[userId]
            peerConnection?.let {
                val iceCandidate = IceCandidate(bean.iceId,bean.label,bean.candidate)
                it.addRemoteIceCandidate(iceCandidate)
            }
    }
}

//如果信息无误,onAddStream方法则会触发,拿到远端视频流。

这里再将SessionDescription单独说明下,它内部是由type和description组成的,其中type其实就是offer和answer做区分,description则包含了所有音视频的一些信息。但其type是个枚举类型,传输的时候又是以json形式传输,所以前端那边传输的时候只需将SessionDescription中的sdp信息拿出来即可,移动端通过信令已经知道它的类型,拿到的时候重新组装成SessionDescription再设置到对应方法。否则会出现视频无法连接上的问题。

如何挂断?

//注意这里不能使用peerConnection.dispose(),会直接引起崩溃,具体原因不明。
renderer.release()
peerConnection.close()

总结

实现流程其实也并不复杂,主要是搞清逻辑顺序,先易后难。最开始还是得弄清楚一对一得流程,只要这个过程通了,在此基础上再管理一个peerConnection得管理类,每一个用户对应一个peerConnection建立连接。这篇就到这里,下篇我们再来一个分支,手机使用USB摄像头实现webrtc音视频功能。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 14 天,点击查看活动详情