Flutter WebRtc는 2가지의 dependencies가 필요합니다.
flutter_webrtc
와 socket_io_client
입니다.
pubspec.yaml에 아래 2가지를 추가한 후 Pub get을 합니다.
...
flutter_webrtc: ^0.9.18
socket_io_client: ^2.0.0
반드시 socket.io을 사용해야 하는 것은 아닙니다. socket.io은 peer끼리 sdp정보와 ice를 교환할때 사용하는
시그널링 서버 역할을 하는데, 웹서버의 request/response를 이용해서도 구현이 가능합니다.
dependencies에 flutter_webrtc를 사용하려면 안드로이드의 minSdkVersion을
최소 21로 변경해주어야 합니다. android/app/build.gradle에서 다음과 같이 변경해줍니다.
minSdkVersion 21
또한 webrtc는 네트워크는 물론, 실제 디바이스의 camera와 audio를 사용하기 때문에
안드로이드의 android/app/src/main/AndroidManifest.xml 에 해당 기능을 추가해주어야 합니다.
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
여기까지 준비가 되었다면 WebRTC에 필요한 (로컬)서버를 구현해야 합니다.
socket.io를 통해 flutter와 통신을 할 것이기 때문에 node.js로 다음과 같이 구현하여 줍니다.
아래는 server.js 본문입니다.
const app = require('express')();
const {Server} = require('socket.io');
async function server(){
const http = require('http').createServer(app);
const io = new Server(http, {transports: ['websocket']});
const roomName = 'room123456';
io.on('connection', (socket) => {
socket.on('join', () =>{
socket.join(roomName);
socket.to(roomName).emit('joined');
});
socket.on('offer', (offer) => {
socket.to(roomName).emit('offer', offer);
});
socket.on('answer', (answer) => {
socket.to(roomName).emit('answer', answer);
});
socket.on('ice', (ice) => {
socket.to(roomName).emit('ice', ice);
});
});
http.listen( 3000, () => console.log('server open!!'));
}
server();
express서버를 사용하였고, socket.io을 생성해줍니다.
roomName은 임의로 지어줍니다. RM과 같이 뉴욕이나 서울, 파리 등의 방을 구성하려면
이 부분을 추가로 작업해주면 됩니다.
이 서버에 들어오는 client는 socket.io에 rootName로 할당된 곳에 접속하게 됩니다.
(roomName).emit은 방에 있는 모든 사람에게 broadcast하게 되는데, 이 때 자신은 제외하고 보내게 됩니다.
join, offer, answer, ice 까지 이 모든 신호가 모두 동일한 방식으로 broadcast 된다는 것을 알 수 있습니다.
플러터의 내부 로직을 보기 전에 시그널링 서버와 어떻게 동작하는지 간단하게 살펴보겠습니다.
이제 Flutter에서 어떻게 webrtc가 구현되는지 살펴보도록 하겠습니다.
먼저 socket.io를 이용하여 로컬 서버에 연결을 하고 WebRTC에 필요한 변수들을 준비합니다.
RTCVideoRenderer 타입의 localRenderer와 RemoteRenderer를 선언하고
localRenderer에 들어갈 MediaStream과 PeerConnection도 준비시켜 줍니다.
late IO.Socket socket;
late RTCVideoRenderer _localRenderer;
late RTCVideoRenderer _remoteRenderer;
MediaStream? _localStream;
RTCPeerConnection? pc;
initState 함수에서 3가지 일을 합니다.
1. Renderer Initialize
2. Connection Socket
3. Join Room
localRenderer와 remoteRenderer를 생성하고 초기화 해줍니다.
_localRenderer = RTCVideoRenderer();
_remoteRenderer = RTCVideoRenderer();
await _localRenderer.initialize();
await _remoteRenderer.initialize();
socket.io를 통하여 시그널링 서버에 연결하고, 서버에서 받는 신호들을 이벤트 리스너로 감지하여
로직을 처리해 줍니다.
Future connectSocket() async {
log.add('연결요청!');
socket = IO.io(
'http://172.20.10.4:3000',
IO.OptionBuilder().setTransports(['websocket']).build(),
);
initializeSocketListeners();
}
void initializeSocketListeners() {
socket.onConnect((data) {
log.add('연결 완료! $data');
});
socket.on('joined', (data) {
log.add(': socket--joined / $data');
onReceiveJoined();
});
socket.on('offer', (data) async {
log.add(': listener--offer');
onReceiveOffer(jsonDecode(data));
});
socket.on('answer', (data) {
log.add(' : socket--answer');
onReceiveAnswer(jsonDecode(data));
});
socket.on('ice', (data) {
log.add(': socket--ice');
onReceiveIce(jsonDecode(data));
});
}
Renderer initialize와 시그널링 서버에 접속이 되었다면 이제 시그널링 서버의 방에 접속하게 됩니다.
방에 접속하기 전에 내부의 PeerConnection을 생성하게 됩니다.
final config = {
'iceServers': [
{"url": "stun:stun.l.google.com:19302"},
]
};
final sdpConstraints = {
'mandatory': {
'OfferToReceiveAudio': true,
'OfferToReceiveVideo': true,
},
'optional': []
};
pc = await createPeerConnection(config, sdpConstraints);
그리고 client의 카메라를 열어 줍니다. Helper.openCamera는 WebRTC의 getUserMedia()를 내부적으로 실행시키게 되며
MediaStream을 반환하게 되는데, 이것을 localStream에 할당하여 localRenderer의 srcObject에 들어가게 됩니다.
Helper.openCamera를 실행하게 되면서 카메라 및 오디오 허용을 할 것인지 디바이스에서 묻게 됩니다.
final mediaConstraints = {
'audio': false,
'video': {'facingMode': 'user'}
};
_localStream = await Helper.openCamera(mediaConstraints);
_localStream!.getTracks().forEach((track) {
pc!.addTrack(track, _localStream!);
});
_localRenderer.srcObject = _localStream;
PeerConnection에서 Ice가 발생했을때를 리스너로 등록하여 받아줍니다.
WebRTC의 개념상 서로의 SDP를 교환한 후 ice를 발생시켜 주고 받는다고 되어 있지만
실제로는 자신의 SDP를 생성하는 시점에서부터 ice를 만들어서 보내는 것으로 로그를 통해 확인되었습니다.
PeerConnection에서 상대방의 Stream을 받는 리스너도 등록해 줍니다.
이 이후에 시그널링 서버에 join을 보내게 됩니다. 위의 시퀀스 다이어그램에서도 나와있지만
PeerA의 시점에서는 방에 아무도 없기 때문에 아무런 반응이 없습니다.
pc!.onIceCandidate = (ice) {
onIceGenerated(ice);
};
pc!.onAddStream = (stream) {
_remoteRenderer.srcObject = stream;
};
socket.emit('join');
Peer A가 방에 접속된 상태에서 PeerB가 여기까지 프로세스를 진행하게 되면
Peer A는 누군가 방에 들어왔다는 신호를 받고 offer를 만들어주게 됩니다.
이 offer를 Local Description에 저장한 후에 시그널링 서버를 통해 Peer B에게 전달하게 됩니다.
void onReceiveJoined() {
_sendOffer();
}
Future _sendOffer() async {
log.add('send offer');
RTCSessionDescription offer = await pc!.createOffer();
pc!.setLocalDescription(offer);
log.localSdp = offer.toMap().toString();
socket.emit('offer', jsonEncode(offer.toMap()));
}
Peer B는 Peer A로부터 Offer를 받고 그것을 RemoteDescription에 저장합니다.
그리고 Answer를 생성한 후 Local Description에 저장합니다.
이 Answer를 시그널링 서버를 통해 Peer A에게 전달하게 됩니다.
Future<void> onReceiveOffer(data) async {
final offer = RTCSessionDescription(data['sdp'], data['type']);
pc!.setRemoteDescription(offer);
final answer = await pc!.createAnswer();
pc!.setLocalDescription(answer);
_sendAnswer(answer);
}
Future _sendAnswer(answer) async {
log.add(': send answer');
socket.emit('answer', jsonEncode(answer.toMap()));
log.localSdp = answer.toMap().toString();
}
Peer A는 보내진 Answer를 받아 Remote Description에 저장합니다.
Future onReceiveAnswer(data) async {
log.add(' --got answer');
setState(() {});
final answer = RTCSessionDescription(data['sdp'], data['type']);
pc!.setRemoteDescription(answer);
}
그리고 Peer A와 Peer B가 시그널링 서버를 통해 ice정보를 주고 받습니다.
Future onIceGenerated(RTCIceCandidate ice) async {
log.add('send ice ');
setState(() {});
socket.emit('ice', jsonEncode(ice.toMap()));
log.sendIceList.add(ice.toMap().toString());
}
Future onReceiveIce(data) async {
log.add(' --got ice');
setState(() {});
final ice = RTCIceCandidate(
data['candidate'],
data['sdpMid'],
data['sdpMLineIndex'],
);
pc!.addCandidate(ice);
}
어느 정도 정보를 주고 받은 후 PeerConnection 에서 시그널링 서버가 아닌 Peer 간 연결을 하여 영상 대화를 주고 받게 됩니다.
지금까지 진행했던 것들을 시퀀스 다이어그램으로 다시 한번 살펴보도록 하겠습니다.
WebRTC org https://webrtc.org/
webrtc wiki https://en.wikipedia.org/wiki/WebRTC
SDP에 대해서 https://datatracker.ietf.org/doc/html/draft-nandakumar-rtcweb-sdp-08
gs-샤피라이브 https://gsretail.tistory.com/14
whatsapp clone youtube
https://www.youtube.com/watch?v=TQ7n9p8aWpQ
Socket.IO https://socket.io/docs/v4/
WebRTC 개념 https://inspirit941.tistory.com/346
안녕하세요?
좋은글 감사드립니다.
혹시 iOS쪽도 구현을 하셨을까해서 도움좀 구하고자 문의 드립니다.
해당 rtc를 flutter로 구현후 iOS에서 TestFlight 같은 내부 배포시 WebRTC framework 관련 dSYM이 uuid와 함께 누락되었다는 경고가 발생하는데 혹시 관련해서 동일한 경험이나 해결방법이 있으셨다면 도움좀 부탁드리고자 댓글 남깁니다.
새해복 많이 받으세요.