본문 바로가기

프로그래밍/WebRTC

WebRTC iOS 기본 플로우 코드

관련 프로퍼티

// RTCPeerConnection
// factory를 생성한 후에 factory에서 peer connection을 생성하는 구조
// factory 생성시에는 인코더, 디코더 필요
var factory:RTCPeerConnectionFactory
var peerConnection:RTCPeerConnection


// encoder, decoder
var encoderFactory:RTCDefaultVideoEncoderFactory
var decoderFactory:RTCDefaultVideoDecoderFactory

// delegate
var delegate:RTCPeerConnectionDelegate

// 카메라 캡처
var localVideoCapturer:RTCCameraVideoCapturer
// var localVideoCapturer:RTCFileVideoCapturer

// 미디어 스트림
var localStream:RTCMediaStream

// 로컬 트랙
var localVideoTrack:RTCVideoTrack
var localAudioTrack:RTCAudioTrack

// 원격지 트랙
var remoteVideoTrack:RTCVideoTrack
var remoteAudioTrack:RTCAudioTrack

 

Factory 생성

// 인코더, 디코더 생성
encoderFactory = RTCDefaultVideoEncoderFactory()
decoderFactory = RTCDefaultVideoDecoderFactory()

// 지원코덱
// RTCDefaultVideoEncoderFactory.supportedCodecs() 를 호출하면
// 지원하는 코덱의 정보를 RTCVideoCodecInfo 배열로 리턴한다.
for codecInfo in RTCDefaultVideoEncoderFactory.supportedCodecs() {
    if( codecInfo.name.elementsEqual( "H264" ) {
        encoderFactory.preferredCodec = codecInfo
        break
    }
}

// 인코더, 디코더를 사용해 PeerConnectionFactory 생성
factory = RTCPeerConnectionFactory( encoderFactory:encoderFactory, decoderFactory: decoderFactory)

 

PeerConnection 생성

// Ice 서버정보 배열
let iceServers:[RTCIceServer]
iceServers = [RTCIceServer.init( urlStrings: ["stun:stun.l.google.com:19302"])]

// connection constraints
let constraints:RTCMediaConstraints
let options:[String:String] = [
    "DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue,
    kRTCMediaConstraintsMaxWidth:"640",
    kRTCMediaConstraintsMaxHeight:"480",
    kRTCMediaConstraintsMaxFrameRate:"30"
]
constraints = RTCMediaConstraints.init( mandatoryConstraints: nil, optionalConstraints: options )
    

// config 설정
let config = RTCConfiguration()
config.iceServers = iceServers
config.iceTransportPolicy = .all
config.rtcpMuxPolicy = .negotiate
config.continualGatheringPolicy = .gatherContinually
config.bundlePolicy = .maxBundle


// connection 생성
peerConnection = factory.peerConnection( with: config, constraints: constraints, delegate: delegate)

 

 

 

로컬 미디어 스트림 생성

// 미디어 스트림 생성
let localStream:RTCMediaStream
localStream = (factory.mediaStream(withStreamId: "media"))

// 비디오 소스
var videoSource:RTCVideoSource = factory.videoSource()

// 카메라 캡처
#if TARGET_OS_SIMULATOR
localVideoCapturer = RTCFileVideoCapturer( delegate: videoSource )
#else
localVideoCapturer = RTCCameraVideoCapturer( delegate: videoSource )
#endif

// 로컬 트랙
localVideoTrack = factory.videoTrack( with: videoSource, trackId: "video0")
localAudioTrack = factory.audioTrack( withTrackId: "audio0" )


// View 생성
#if arch(arm64)
let rtcView:RTCMTLVideoView( frame: someYourView.bounds )
rtcView.videoContentMode = .scaleAspectFill
#else
let rtcView:RTCEAGLVideoView = RTCEAGLVideoView( frame: someYourView.bounds)
#endif

// 기존뷰, 트랙에 추가
someYourView.addSubview( rtcView )
localVideoTrack.add( rtcView )


// 로컬 스트림에 트랙 추가
localStream.addVideoTrack( localVideoTrack )
localStream.addAudioTrack( localAudioTrack )


// 커넥션에 스트림 추가
peerConnection.add( localStream )



// 데이터 채널 필요시
let config = RTCDataChannelCoinfiguration()
guard let dataChannel = self.peerConnection.dataChannel( forLabel: "DataCh", configuration: config) else {
    return nil
}
dataChannel.delegate = self

 

 

오디오세션 설정

let rtcAudioSession = RTCAudioSession.sharedInstance()

rtcAudioSession.lockForConfiguration()
do {
    try rtcAudioSession.setCategory( AVAudioSession.Category.playAndRecord.rawValue )
    try rtcAudioSession.setMode( AVAudioSession.Mode.voiceChat.rawValue )
} catch let error {

}
rtcAudioSession.unlockForConfiguration()

 

 

시그널링을 위한 메시지 처리 : 시그널링은 규격이 따로 없으므로 서로 연결을 알고 데이터를 주고 받을 수 있는 형태로 임의 구성한다. candidate 정보나 sdp 정보를 주고 받을 수 있으면 된다.
 

 

 

 

 

Offer

시그널링 서버는 연결되어 있을테지만, 아직 피어간에 연결된 상태는 아니며, 내 접속 정보나 미디어 정보가 상대방에게 전달되어야 한다. 우선 내 정보를 획득하기 위해 offer 를 호출한다.(두 피어가 모두 offer를 보내는 것은 아니고, 서비스에 따라 한 피어가 offer를 먼저 보내도록 결정하면 된다.)

offer 를 호출하면 sdp를 생성하고 로컬 디스크립션으로 설정한다.

var mandatoryConstraints:[String:String] = [
    kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue,
    kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue
]
let constraints = RTCMediaConstraints( mandatoryConstraints: mandatoryConstraints, optionalConstraints:nil)

// offer
peerConnection.offer( for: mandatoryConstraints, completionHandler: { (sdp:RTCSessionDescription?, e:Error?) in
    
    guard let sdp = sdp else {
    	return
    }
    
    self.peerConnection.setLocalDescription( sdp, completionHandler: { (error) in
    	// 시그널링 서버를 통해 sdp 전달
        
    })
    
    
})

 

 

Answer

한쪽이 offer 를 전달해 오면 setRemoteDescription() 으로 상대방의 정보를 설정한다. 상대방에게 내 정보를 전달하기 위해 offer를 받은 피어는 answer 를 생성하고, 시그널링 서버를 통해 상대방에게 sdp를 전달한다.  

// 원격지에서 시그널링서버를 통해 전달된 sdp를 설정
// 보통 json과 같은 string 형태로 전달된 sdp에서 RTCSessionDescription 생성
let remoteSdp = RTCSessionDescription( type: RTCSdpType.offer, sdp: jsonString)

// 해당 sdp를 설정 : set remote description
peerConnection.setRemoteDescription( remoteSdp, completionHandler: {(e:Error?) in
    
    // 응답을 위한 answer sdp 생성하기
    var mandatoryConstraints:[String:String] = [
        kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue,
        kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue
    ]
    let constraints = RTCMediaConstraints( mandatoryConstraints: mandatoryConstraints, optionalConstraints:nil )
    peerConnection.answer( for: constraints, completionHandler: { (sdp:RTCSessionDescription?, e:Error?) in
    
    	// 시그널링 서버를 통해 sdp 전달
    })
})

offer 를 보낸 피어는 answer 를 받을 테니 최종적으로 setRemoteDescription() 을 해주면 sdp 교환은 완료된다. sdp 교환은 일단 상호 미디어와 송수신 설정을 위한 부분이고, 아직 p2p로 연결된 상황은 아니다.

 

RTCPeerConnectionDelegate

PeerConnection이 생성되면 미디어는 offer/answer를 통해 각각 설정이 이루어지는데, 미디어를 제외한 네트워크 정보는 별도로 제공해야 한다. 이 네트웍 정보들을 통해 상호간에 p2p로 연결할 ip, port, protocol을 결정하게 된다.  해당 이벤트는 PeerConnection 생성시 전달한 RTCPeerConnectionDelegate에서 처리한다.
위에 offer 로 sdp 를 만들고, setLocalDescription 을 호출하면, delegate에 대략 아래와 같은 순서로 이벤트가 전달된다.

시그널링 상태 변경 -> ice 수집 상태 변경 -> ice candidate 생성

ice candidate가 네트워크 정보인데, 이 정보를 시그널링 서버를 통해 상대방에게 전달해 주어야 한다.
(ice candidate는 가능한 네트워크가 모두 나타나므로 여러개가 생성된다.)

 

IceCandidate

// 새로운 ice 후보 발견
// -(void)peerConnection:(RTCPeerConnection *)peerConnection didGenerateIceCandidate:(RTCIceCandidate *)candidate;
public func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate ) {
	
	let candidate = [
    	"candidate":candidate.sdp,
        "sdpMid":candidate.sdpMid,
        "sdpMLineIndex":candidate.sdpMLineIndex
    ] as [String: Any]
    
    // 위 문자열을 json string 으로 변환 후 시그널링 서버를 통해 전달
}

 

시그널링 서버로부터 전달된 IceCandidate 를 추가

// json스트링을 파싱
let jsonObject = 파싱루틴
let candidate = RTCIceCandidate( 
                  sdp: jsonObject["candidate"] as! String, 
                  sdpMLineIndex: jsonObject["sdpMLineIndex"] as! Int32, 
                  sdpMid: jsonObject["sdpMid"] as! String)
peerConnection.add( candidate )

상호간에 ice candidate를 주고 받고 설정하면, p2p 연결이 이루어지게 된다.

 

ice 연결되면 캡처 시작 및 데이터 전송

// ice 연결상태 변경
// -(void)peerConnection:(RTCPeerConnection *)peerConnection didChangeIceConnectionState:(RTCIceConnectionState)newState;
public func peerConnection(_ peerConnection: RTCPeerCopnnection, didChange newState: RTCIceConnectionState ) {
    if( newState == RTCIceConnectionState.connected {

        // 로컬 비디오 캡처를 시작한다
        if let capturer = self.localVideoCapturer {
        	// 캡처 장치 설정 및 캡처 시작
        }
        
    } else if ( newState == .failed {
    
    } else if ( newState == .disconnected {
    
    } else if ( newState == .completed ) {
    
    } else if ( newState == .closed ) {
    
    }
}

 

 

 

캡처 장치 설정 및 캡처 시작

각 플랫폼별로 독립적으로 구성된 부분으로 캡처 장치를 설정하고 RTCCameraCapturer 로 장치 정보와 포맷을 전달한다

// 전면, 후면 위치 선택
let position = AVCaptureDevice.Position.front;
            
// 캡처 장치 검색
let captureDevice:AVCaptureDevice
for device:AVCaptureDevice in AVCaptureDevice.devices(for: AVMediaType.video) {
    if( device.position == position ) {
        captureDevice = device
        break;
    }
}

// 포맷 설정
let targetWidth = Int32( 640 )
let targetHeight = Int32( 480 )
var selectedFormat:AVCaptureDevice.Format
var currentDiff = INT_MAX

let formats = RTCCameraVideoCapturer.supportedFormats( for: captureDevice )
for format in formats {
	let dimension:CMVideoDimensions = CMVideoFormatDescriptionGetDimensions( format.formatDescription )
    let pixelFormat:FourCharCode = CMForamtDescriptionGetMediaSubType( format.formatDescription )
    let diff = abs(targetWidth - dimension.width) + abs( targeHeight - dimension.height)
    if( diff < currentDiff ) {
    	selectedFormat = format
        currentDiff = diff
    } else if( diff == currentDiff && pixelFormat == capturer.perferredOutputPixelFormat()) {
    	selectedFormat = format
    }
})

var maxFramerate:Float64 = 0.0
for fpsRange in selectedFormat.videoSupportedFrameRateRanges {
	maxFramerate = fmax( maxFramerate, fpsRange.maxFrameRate )
}
let fps = Int(maxFramerate)
capturer.startCapture( with: captureDevice, format: selectedFormat, fps: fps )

 

 

RTCDataChannelDelegate

func dataChannelDidChangeState(_ dataChannel: RTCDataChannel ) {
}

func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
}