영상통화 앱 (WebRTC, 내비게이션, 아고라 API)

잠만보·2024년 9월 6일

사전지식

1. 카메라 플러그인

플러터 공식 플러그인인 camera 플러그인을 사용해 카메라를 실행하는 방법을 배워보자

camera 플러그인 설치

flutter pub add camera

위의 명령어를 터미널에 입력하여 카메라 패키지를 설치한다.

간단한 카메라 예시 코드

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';

late List<CameraDescription> _cameras;

Future<void> main() async {
  // 1. Flutter 앱이 실행될 준비가 됬는지 확인
  WidgetsFlutterBinding.ensureInitialized();

  // 2. 핸드폰에 있는 카메라들 가져오기
  _cameras = await availableCameras();
  runApp(const CameraApp());
}

class CameraApp extends StatefulWidget {
  const CameraApp({super.key});

  @override
  State<CameraApp> createState() => _CameraAppState();
}

class _CameraAppState extends State<CameraApp> {
  // 3. 카메라를 제어할 수 있는 컨트롤러 선언
  late CameraController controller;

  @override
  void initState() {
    super.initState();

    initializeCamera();
  }

  initializeCamera() async {
    try {
      // 4. 가장 첫 번째 카메라로 카메라 설정하기
      controller = CameraController(_cameras[0], ResolutionPreset.max);
      
      // 5. 카메라 초기화
      await controller.initialize();
      
      setState(() {});
    } catch (e) {
      // 에러 출력
      if (e is CameraException) {
        switch (e.code) {
          case 'CameraAccessDenied':
            print('유저가 카메라 엑세스를 거부함');
            break;
          default:
            print("Handle other errors");
            break;
        }
      }
    }
  }

  // 소멸자
  @override
  void dispose() {
    controller.dispose(); // 컨트롤러 삭제
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 6. 카메라 초기화 상태 확인
    if (!controller.value.isInitialized) {
      return Container();
    }
    return MaterialApp(
      // 7. 카메라 보여주기
      home: CameraPreview(controller),
    );
  }
}
  1. CameraController 의 첫 매개변수는 사용할 카메라를 입력하게 된다.
    두번째 매개변수는 해상도를 설정한다.

7 CameraPreview 위젯을 사용하면 카메라를 화면에 보여줄 수 있다.
첫번째 매개변수에 CameraController 를 입력해줘야 한다.

실행화면


이런 기본 화면이 나온다

ResolutionPreset 표

2. WebRTC

영상통화 기능을 구현하려면 영상과 음성 정보를 저장하고 전송하기, 클라이언트 간의 연결하기 등 다양한 기능을 구현해야 한다.

시간이 오래 걸리니 웹브라우저 기반으로 통신하는 WebRTC API를 사용해보자.
WebRTC에서는 음성통화, 영상통화, P2P 파일 공유기능을 제공한다.

WebRTC를 사용하려면 클라이언트 말고 중계용 서버가 필요한데 구현에 시간이 걸리니 아고라 서버를 대신 이용하겠다.

클라이언트 서버 간의 통신흐름

  1. WebRTC를 사용할 클라이언트들은 서로에게 연결할 수 있는 공개 IP 등의 정보를 서버에 전송하고 상대의 연결 정보를 받아온다.

  2. 서버에서 받아온 정보를 기반으로 내 영상 및 음성을 공유하고 상대의 영상 및 음성 정보를 이용한다.

3. 내비게이션

네비게이션은 플러터에서 화면을 이동할 때 사용하는 클래스이다.

네비게이션은 스택 구조로 설계되어 있다.

네비게이션 스택 구조

플러터에서는 네비게이션 스택의 가장 위에 위치한 위젯을 화면으로 보여준다.

사전 준비

1. 아고라에서 필요한 상수값 가져오기

회원가입 후 AppID 와 ChannelName, TempToken 을 발급 받아서 const 파일에 저장해둔다.

2. pubspec.yaml 설정

cupertino_icons: ^1.0.8
  agora_rtc_engine: 6.2.4
  permission_handler: 11.0.1

  assets:
    - asset/img/

3. 네이티브 설정하기

안드로이드 권한 추가

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.example.video_call">
    <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.RECORD_AUDIO"/>
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.BULETOOTH"/>
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WAKE_LOCK"/>
    <uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE" tools:ignore="ProtectedPermissions"/>

    
    .
    .
    .
    
</manifest>

안드로이드 컴파일 SDK버젼 변경

android/app/build.gradle

android {
    namespace = "com.example.video_call"
    compileSdkVersion 33
    ndkVersion = flutter.ndkVersion

ios 권한 추가

<dict>
    <key>NSCameraUsageDescription</key>
    <string>카메라 사용을 허가해주세요.</string>
    <key>NSMicrophoneUsageDescription</key>
    <string>마이크 사용을 허가해주세요.</string>

    .
    .
    .
</dict>

레이아웃 구상하기

홈 스크린 위젯

로고, 이미지, 참여버튼 3개의 위젯으로 구성된다.

캠 스크린 위젯

좌측 상단에 상대방의 카메라 화면이 있고, 나의 캠 화면이 가운데 크게 나오고, 하단에나가기 버튼이 있다.

구현하기

1. 홈 스크린 위젯 구현하기

import 'package:flutter/material.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.blue[100]!, // 배경색
      body: SafeArea(
          child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            Expanded(child: _Logo()), // 1. 로고
            Expanded(child: _Image()), // 2. 중앙 이미지
            Expanded(child: _EntryButton()), // 3. 통화 시작 버튼
          ],
        ),
      )),
    );
  }
}

배경색은 파란색으로 해주고
Column의 children 으로 로고, 중앙 이미지, 통화시작 버튼을 배치한다.

로고 위젯 만들기

// HomeScreen 위젯 아래에 생성
class _Logo extends StatelessWidget {
  const _Logo({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        decoration: BoxDecoration(
          color: Colors.blue,
          borderRadius: BorderRadius.circular(16.0), // 둥근 모서리
          boxShadow: [ // 1. 그림자 추가
            BoxShadow(
              color: Colors.blue[300]!,
              blurRadius: 12.0,
              spreadRadius: 2.0,
            ),
          ],
        ),
        child: Padding(
          padding: EdgeInsets.all(16.0),
          child: Row(
            mainAxisSize: MainAxisSize.min, // 주축의 최소 크기 정하기
            children: [
              Icon( // 캠코더 아이콘
                Icons.videocam,
                color: Colors.white,
                size: 40.0,
              ),
              SizedBox(
                width: 12.0,
              ),
              Text( // 앱 이름
                'LIVE',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 30.0,
                  letterSpacing: 4.0, // 글자 간격
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

Container 위젯의 boxShadow 매개변수
boxShadow 매개변수에는 List로 BoxShadow 클래스를 제공할 수 있다.
그림자로 적용할 색상을 color 매개변수로 제공해주고
blurRadius에 흐림 정도, spreadRadius에 퍼짐 정도를 double 값으로 입력할 수 있다.

Image 위젯 구현하기

class _Image extends StatelessWidget {
  const _Image({super.key});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Image.asset(
        'asset/img/home_img.png',
      ),
    );
  }
}

Center 위젯으로 이미지를 중앙에 배치해준다.

화상 통화 입장 버튼 구현하기

class _EntryButton extends StatelessWidget {
  const _EntryButton({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.end,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ElevatedButton(
          onPressed: () {},
          child: Text('입장하기'),
        ),
      ],
    );
  }
}

홈 스크린 실행화면

2. 캠 스크린 위젯 구현하기

캠 스크린 위젯 기본 레이아웃

import 'package:flutter/material.dart';

class CamScreen extends StatefulWidget {
  const CamScreen({super.key});

  @override
  State<CamScreen> createState() => _CamScreenState();
}

class _CamScreenState extends State<CamScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('LIVE'),
      ),
      body: Center(
        child: Text('Cam Screen'),
      ),
    );
  }
}

HomeScreenElevatedButton 을 클릭하면 CamScreen 으로 화면이 넘어가게 해야 한다.
HomeScreen 에서 Navigatior 클래스를 이용해서 CamScreen 으로 이동하게끔 해보겠다.

@override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.end,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ElevatedButton(
          onPressed: () {
      //================================================================
            Navigator.of(context).push( // 1. 영상통화 스크린으로 이동
              MaterialPageRoute(
                builder: (_) => CamScreen(), // 새로운 화면으로 사용하고 싶은 위젯을 반환하는 콜백
              ),
            );
          },
     //==================================================================
          child: Text('입장하기'),
        ),
      ],
    );
  }
}
  1. 테마를 이용할 때 Theme.of(context)를 사용했던 것 처럼 Navigator.of(context)를 실행해서 위젯 트리의 가장 가까이에 있는 Navigator를 가져온다.

  2. push() 함수를 이용하면 새로운 화면으로 이동할 수 있으며 매개변수로 MaterialPageRoute 클래스builder() 함수새로운 화면으로 사용하고 싶은 위젯을 반환하는 함수를 입력하면 된다.

버튼을 누르면 이동이 잘 된다.

CamScreen 위젯 에서 화상 통화 기능 구현하기

화상 통화를 하려면 카메라와 마이크 권한이 필요한데, init() 이라는 함수를 만들어서 화상통화에 필요한 권한을 받아보겠다.

class _CamScreenState extends State<CamScreen> {
  Future<bool> init() async { // 1. 권한 관련 작업 모두 실행
    final resp = await [Permission.camera, Permission.microphone].request();

    final cameraPermission = resp[Permission.camera];
    final micPermission = resp[Permission.microphone];

    if (cameraPermission != PermissionStatus.granted ||
        micPermission != PermissionStatus.granted) {
      throw '카메라 또는 마이크 권한이 없습니다.';
    }
    return true;
  }
  1. 권한을 가져오는 작업은 비동기 프로그래밍이 필요하다.
    권한을 잘 가져오면 true 를 리턴하고 아니면 에러메시지를 리턴하는 로직을 작성한다.

FutureBuilder 위젯으로 init() 함수 사용하기

build() 함수는 위젯이 생성되면 그 즉시 실행된다.
그러나 카메라 마이크 권한이 있을때와 없을 떄 보여주는 화면이 달라야 한다.

문제는 init() 함수비동기 함수라 언제 끝날지 알 수가 없다.

따라서 FutureBuilder 위젯을 사용한다.

FutureBuilder 위젯
Future를 반환하는 함수의 결과에 따라 위젯을 렌더링할 때 사용한다.
FutureBuilder의 future 매개변수Future 값을 반환하는 함수를 넣어주고, builder 매개변수Future 값에 따라 다르게 렌더링해주고 싶은 로직을 작성해주면 된다.

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('LIVE'),
      ),
      body: FutureBuilder( // 1. Future 값을 기반으로 위젯 렌더링
        future: init(),
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          if (snapshot.hasError) { // 2. Future 실행 후 에러가 있을 때
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }
          if (!snapshot.data) { // 3. Future 실행 후 아직 데이터가 없을 때 (로딩중)
            return Center(
              child: CircularProgressIndicator(),
            );
          }

          return Center( // 4. 나머지 상황에 권한 있음을 표시
            child: Text('권한이 있습니다!'),
          );
        },
      ),
    );
  }
}
  1. builder() 함수BuildContextAsyncSnapshot 을 제공해준다.

AsyncSnapshot더 알아보기
AsyncSnapshotfutuer 매개변수에 입력한 함수의 결과값 및 에러를 제공하는 역할을 하고, 추가적으로 비동기 함수의 진행 상황도 알 수 있다.
AsyncSnapshot 에서 제공하는 값이 변경될 때 마다 builder() 함수가 재실행된다.
//===========다양한 게터들============//
AsyncSnapshothasError 게터는 현재 실행한 비동기 함수에서 에러가 있는지 bool 값으로 반환해준다.
AsyncSnapshothasData 게터는 현재 실행한 비동기 함수에서 반환받은 데이터가 있는지 확인할 수 있다.

아고라 API 활성화하기

class _CamScreenState extends State<CamScreen> {
  RtcEngine? engine; // 아고라 엔진을 저장할 변수
  int? uid; // 내 ID
  int? otherUid; // 상대방 ID

  Future<bool> init() async {
    final resp = await [Permission.camera, Permission.microphone].request();

    final cameraPermission = resp[Permission.camera];
    final micPermission = resp[Permission.microphone];

    if (cameraPermission != PermissionStatus.granted ||
        micPermission != PermissionStatus.granted) {
      throw '카메라 또는 마이크 권한이 없습니다.';
    }
//===============================================
    if (engine == null) {
      // 1. 엔진이 정의되어 있지 않으면 새로 정의하기
      engine = createAgoraRtcEngine();

      // 아고라 엔진 초기화
      await engine!.initialize(
        // 초기화할 때 사용할 설정 제공
        RtcEngineContext(
          appId: APP_ID, // 미리 저장한 APP_ID 입력

          // 라이브 동영상 송출에 최적화하기
          channelProfile: ChannelProfileType.channelProfileLiveBroadcasting,
        ),
      );

      engine!.registerEventHandler(
        // 2. 아고라 엔진에서 받을 수 있는 이벤트 값들 등록
        RtcEngineEventHandler(
          // 3. 본인이 채널에 접속하는 이벤트
          onJoinChannelSuccess: (RtcConnection connection, int elapsed) {
            print('채널에 입장했습니다. uid : ${connection.localUid}');
            setState(() {
              this.uid = connection.localUid;
            });
          },
          // 4. 본인이 채널에서 퇴장하는 이벤트
          onLeaveChannel: (RtcConnection connection, RtcStats stats) {
            print('채널 퇴장');
            setState(() {
              uid = null;
            });
          },
          // 5. 상대가 채널에 접속하는 이벤트
          onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) {
            print('상대가 채널에 입장했습니다. uid : $remoteUid}');
            setState(() {
              otherUid = remoteUid;
            });
          },
          // 6. 상대가 체널에서 퇴장하는 이벤트
          onUserOffline: (RtcConnection connection, int remoteUid,
              UserOfflineReasonType reason) {
            print('상대가 채널에서 나갔습니다. uid : $uid');
            setState(() {
              otherUid = null;
            });
          },
        ),
      );
// 엔진으로 영상을 송출하겠다고 설정
      await engine!.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
      await engine!.enableVideo(); // 7. 동영상 기능 활성화
      await engine!.startPreview(); // 카메라를 이용해 동영상을 화면에 실행
	
		// 8. 채널 들어가기
      await engine!.joinChannel(
        token: TEMP_TOKEN,
        channelId: CHANNEL_NAME,
        uid: 0,
        options: ChannelMediaOptions(), // 영상과 관련해서 여러가지 설정을 할 수 있음, 현재프로젝트에서는 쓰지 않음
      );
    }
    return true;
  }
  
  .
  .
  .
  
}

상대방과 나의 화면 보여주기

  // build() 함수 바로 아래에 작성

  // 1. 내 핸드폰이 찍는 화면 렌더링
  Widget renderSubView() {
    if (uid != null) {
      //AgoraVideoView 위젯을 사용하면
      // 동영상을 화면에 보여주는 위젯을 구현할 수 있다.
      return AgoraVideoView(
        // VideoViewController를 매개변수로 입력해주면
        // 해당 컨트롤러가 제공하는 동영상 정보를 AgoraVideoView 위젯을 통해 보여줄 수 있다.
        controller: VideoViewController(
          rtcEngine: engine!,
          canvas: const VideoCanvas(uid: 0), // VideoCanvas에 0을 입력해서 내 영상을 보여준다.
        ),
      );
    } else {
      // 아직 내가 접속중이지 않으면 로딩창을 보여준다.
      return CircularProgressIndicator();
    }
  }

  // 2. 상대 핸드폰이 찍는 화면 렌더링
  Widget renderMainView() {
    if (otherUid != null) {
      return AgoraVideoView(
        // VideoViewController.remote 생성자를 이용하면 상대방의 동영상을 렌더링할 수 있다.
        controller: VideoViewController.remote(
          rtcEngine: engine!,
          canvas: VideoCanvas(uid: otherUid), // uid에 상대방 아이디를 입력해준다.
          connection: const RtcConnection(channelId: CHANNEL_NAME),
        ),
      );
    } else {
      return Center(
        // 상대방이 아직 채널에 들어오지 않으면 대기 메시지를 렌더링한다.
        child: const Text(
          '다른 사용자가 입장할 때 까지 대기해 주세요.',
          textAlign: TextAlign.center,
        ),
      );
    }
  }

build() 함수에 입력하기

여기 레이아웃처럼 구현하기 위해

Stack 위젯을 활용해서 상대방의 화면 위에 내 화면을 쌓는 방식으로 구현하겠다.

Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('LIVE'),
      ),
      body: FutureBuilder(
        // 1. Future 값을 기반으로 위젯 렌더링
        future: init(),
        builder: (BuildContext context, AsyncSnapshot snapshot) {
          if (snapshot.hasError) {
            // 2. Future 실행 후 에러가 있을 때
            return Center(
              child: Text(snapshot.error.toString()),
            );
          }
          if (!snapshot.hasData) {
            // 3. Future 실행 후 아직 데이터가 없을 때 (로딩중)
            return Center(
              child: CircularProgressIndicator(),
            );
          }
			
          // =========================================
          return Stack(
            children: [
              renderMainView(), // 상대방이 찍는 화면
              Align(
                alignment: Alignment.topLeft,
                child: Container(
                  color: Colors.grey,
                  height: 160,
                  width: 120,
                  child: renderSubView(),
                ),
              )
            ],
          );
          //=============================================
        },
      ),
    );
  }

나가기 버튼 기능 구현하기

Widget build(BuildContext context) {
    
  .
  .
  .

          return Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Expanded(
                child: Stack(
                  children: [
                    renderMainView(),
                    Align(
                      alignment: Alignment.topLeft,
                      child: Container(
                        color: Colors.grey,
                        height: 160,
                        width: 120,
                        child: renderSubView(),
                      ),
                    )
                  ],
                ),
              ),
              Padding(
                padding: EdgeInsets.symmetric(horizontal: 8.0),
                child: ElevatedButton( // 뒤로가기 기능 및 채널 퇴장 기능
                  onPressed: () async {
                    if (engine != null) {
                      await engine!.leaveChannel();
                    }
                    Navigator.of(context).pop();
                  },
                  child: Text('채널 나가기'),
                ),
              ),
            ],
          );
        },
      ),
    );
  }

3. 최종 작동 영상

일단 내 핸드폰이 한개니
https://webdemo.agora.io/ 여기 사이트로 이동해서 ID, TOken, CHannelName 입력하고 테스트를 해보았다.

잘안보여서 추가설명

왼쪽 화면이 데모 테스트 화면이고

오른쪽 화면이 영상통화 앱 화면이다.

지금 왼쪽 화면에서 join 을 누르면 오른쪽 영상통화 앱 화면에 '다른 사용자가 입장할 때 까지 대기해 주세요' 문구가 없어지는걸 보면 잘 통화가 되고 있는 것 같다.

profile
아프지 말자 - (잘못된 정보, 수정 사항 있으면 언제든지 알려주시면 감사하겠습니다!)

0개의 댓글