Skip to content

✏️ MoveMove Tech 조준장

JunJangE edited this page Dec 15, 2023 · 26 revisions

조준장 기술 블로그

MediaPlayer와 ExoPlayer를 비교해보자!

MediaPlayer와 ExoPlayer를 비교해보자!

MediaPlayer와 ExoPlayer는 안드로이드에서 미디어 재생을 위한 두 가지 주요 라이브러리이다.

MediaPlayer란?

MediaPlayer는 Android에서 제공되는 내장 미디어 재생 라이브러리로, 간단한 미디어 재생을 위한 사용이 간편한 특징을 갖고 있다. 주로 HTTP나 RTSP와 같은 특정 스트리밍 프로토콜을 지원하며, 기본적인 미디어 포맷에 대한 재생에 용이하다. 그러나 스트리밍 처리가 제한적이며, 고급 기능이나 커스터마이즈 옵션은 부족하다. UI 구현은 간단하나, 오디오 포커싱 및 다중 트랙과 같은 고급 오디오 처리에는 제한적이다.

ExoPlayer란?

ExoPlayer는 Google에서 개발하고 유지보수하는 강력한 미디어 재생 라이브러리이다. 다양한 미디어 포맷과 스트리밍 프로토콜을 지원하며, 고급 기능과 커스터마이즈 옵션을 풍부하게 제공한다. HTTP, DASH, SmoothStreaming, HLS 등 다양한 스트리밍 프로토콜을 처리할 수 있으며, 동적인 네트워크 조건에서의 적응적인 스트리밍과 오류 처리, 버퍼링 관리를 자동으로 수행한다. 고급 기능들은 다중 트랙, 서브타이틀, 재생 품질 제어 등이 포함되어 있다.

어떤 것을 선택해야 할까?

미디어 재생에 있어서 단순한 요구사항을 충족하는 경우에는 MediaPlayer를 사용할 수 있지만, 스트리밍 서비스를 사용하는 경우나 더 복잡하고 고급 기능이 필요한 경우에는 ExoPlayer를 선택하는 것이 더 적합할 수 있다.

따라서 이번 프로젝트에서는 HLS 스트리밍 프로토콜 처리와 고급 기능을 활용하기 위해 ExoPlayer를 채택하였다.

비디오 스트리밍을 최적화 해보자!

비디오 스트리밍을 최적화 해보자!

서버로부터 받아 온 영상을 재생하기 위해 Media3 ExoPlayer 를 사용하고 있다.

이유는 스트리밍 형식 (.m3u8) 파일에 대한 처리가 간편하기 때문이다.

다만, 이를 재생하는 PlayerViewJetpack Compose 와 통합이 안 되어서, AndroidView 라는 Composable 을 통해 UI 를 구성하여야 한다.

이 과정에서, VerticalPagerExoPlayerPlayerView 를 함께 사용하여 각 페이지마다 새로운 ExoPlayer를 생성하고 있는데, 이로 인해 스크롤 중에 많은 비디오를 스트리밍할 때 영상이 끊기고 서비스가 느려지는 현상이 나타났다. 곧 최적화에 대한 고민을 시작하게 됐다.


이슈는 무엇인가?

  • ExoPlayerAndroidViewRecomposition 에 대해 안전한가?

  • 만약 그렇다면, DisposableEffect { }key 인자로 넘기지 않아도 되는 것이 아닌가?

  • ExoPlayer 는 매 페이지마다 서로 다른 주소값을 가지는 별개의 객체로 생성되는가?

  • 만약 그렇다면, 매 페이지마다 새로운 객체를 생성하지 않을 방법은 없는가?


ExoPlayer를 하나만 생성하고 재구성하는 방식

장점

  • 메모리 사용량이 감소하고 성능이 향상될 수 있다.
  • ExoPlayer를 생성하고 초기화하는 비용이 줄어들어 스무스한 스크롤링이 가능하다.

단점

  • ExoPlayer의 상태를 재설정해야 하므로 스크롤 중에 영상이 재생 중일 때 일시 정지되거나 재시작될 수 있다.
  • 영상 간 전환 시 초기화 과정에서 로딩 시간이 발생할 수 있다.

각각의 영상에 대해 새로운 ExoPlayer를 생성하는 방식

장점

  • 각 영상에 대해 별도의 ExoPlayer를 사용하므로 독립적인 재생이 가능하다. 스크롤 중에 다른 영상으로 이동해도 해당 영상은 중단되지 않는다.
  • 영상 간 전환 시 초기화에 따른 로딩 시간이 없다.

단점

  • 메모리 사용량이 증가할 수 있다.
  • 여러 개의 ExoPlayer 인스턴스를 유지하기 때문에 메모리 소비가 높아질 수 있다.

결론적으로..

메모리 사용량을 최소화하고자 한다면 하나의 ExoPlayer를 재사용하는 방식이 유리할 수 있다.

사용자가 스크롤 중에 영상 전환이 자주 발생하거나 사용자 경험에 민감한 경우, 각각의 영상에 대해 새로운 ExoPlayer를 생성하는 방식이 더 적합할 수 있다.

3개의 ExoPlayer

위에서 언급한 두 가지 핵심 이점을 동시에 얻기 위해 비디오 스트리밍 최적화를 수행했다. 이 최적화는 3개의 ExoPlayer 인스턴스를 사전에 생성하고 재활용하는 방식으로 진행되었다. 3개의 ExoPlayer를 통해 이전 화면, 현재 화면, 그리고 다음 화면의 영상을 효율적으로 처리하면서 시스템 리소스를 효과적으로 활용할 수 있었다.

동시에, VideoPlayer에서 사용자의 스크롤 동작을 실시간으로 감지하여 플레이어 동작을 동적으로 조절함으로써 사용자 경험과 전반적인 성능을 향상시켰다. 이런 개선은 사용자가 화면을 스크롤할 때마다 최적화된 동작을 제공함으로써 부드러운 비디오 재생을 확보할 수 있었다.

  val exoPlayerPair = remember {
        Triple(
            ExoPlayer.Builder(context).build(),
            ExoPlayer.Builder(context).build(),
            ExoPlayer.Builder(context).build()
        )
    }
  • 3개의 ExoPlayer 인스턴스를 담는 Triple로, 이들은 이전 화면, 현재 화면, 다음 화면의 비디오 재생을 담당
   VerticalPager(
        modifier = Modifier.fillMaxSize(),
        state = pagerState
    ) { page ->

        val exoPlayer = when (page % 3) {
            0 -> exoPlayerPair.first
            1 -> exoPlayerPair.second
            else -> exoPlayerPair.third
        }

        Box(modifier = Modifier.fillMaxSize()) {
            VideoPlayer(
                exoPlayer = exoPlayer,
                uri = videoUri[page]
            )

          ,,,
        }
  • 내부에서 각 페이지에 대한 비디오 플레이어를 구성하고, 현재 페이지에 따라 해당하는 ExoPlayer 인스턴스를 선택하여 사용
     when (pagerState.currentPage % 3) {
            0 -> {
                exoPlayerPair.first.play()
                exoPlayerPair.second.pause()
                exoPlayerPair.third.pause()
            }

            1 -> {
                exoPlayerPair.first.pause()
                exoPlayerPair.second.play()
                exoPlayerPair.third.pause()
            }

            2 -> {
                exoPlayerPair.first.pause()
                exoPlayerPair.second.pause()
                exoPlayerPair.third.play()
            }
        }
  • 현재 페이지에 해당하는(사용자가 보고있는) ExoPlayer를 제외하고 나머지 ExoPlayer들을 모두 일시 정지
   DisposableEffect(Unit) {
        onDispose {
            exoPlayerPair.first.release()
            exoPlayerPair.second.release()
            exoPlayerPair.third.release()
        }
    }
  • 해당 화면이 사라질 때 ExoPlayer 인스턴스들을 해제
    LaunchedEffect(uri) {
        val dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory()
        val source = HlsMediaSource.Factory(dataSourceFactory)
            .createMediaSource(MediaItem.fromUri(uri))

        exoPlayer.apply {
            setMediaSource(source)
            prepare()
            playWhenReady = true
            videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
            repeatMode = Player.REPEAT_MODE_ONE
        }
    }
  • 비디오 URI가 변경될 때마다 호출되어 ExoPlayer에 새로운 미디어 소스를 설정하고 비디오를 로드

하나의 ExoPlayer에서 여러개의 영상이 동시 실행되는 이슈

테스트 도중 하나의 ExoPlayer에서 여러개의 영상이 동시에 실행되는 이슈를 발견했다.

이유는 다음과 같았다. LaunchedEffect에서 uri가 변경될 때마다 새로운 미디어 소스를 설정하고 ExoPlayer 정보를 초기화하는 부분에서 초기화가 이루어지지 않아, 비디오 정보가 중첩되고 겹치는 문제가 발생한 것이었다.

위 이슈를 해결하기 위해 LaunchedEffect를 DisposeableEffect로 바꾸면서 해결했다.

LaunchedEffect가 아닌 DisposableEffect를 도입하여, 컴포넌트가 처음 구성될 때 또는 uri가 변경될 때마다 새로운 미디어 소스를 설정하고 ExoPlayer를 초기화하도록 구현했다. 또한, DisposableEffect가 해제될 때 onDispose 블록에서 정의한 ExoPlayer를 종료하고 미디어 소스를 지우도록 했다.

   DisposableEffect(uri) {
        val dataSourceFactory: DataSource.Factory = DefaultHttpDataSource.Factory()
        val source = HlsMediaSource.Factory(dataSourceFactory)
            .createMediaSource(MediaItem.fromUri(uri))
        exoPlayer.apply {
            setMediaSource(source)
            prepare()
            playWhenReady = true
            videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
            repeatMode = Player.REPEAT_MODE_ONE
        }

        onDispose {
            exoPlayer.stop()
            exoPlayer.clearMediaItems()
        }
    }
  • DisposableEffect를 사용하여 ExoPlayer를 초기화하고 미디어를 재생

결과적으로 다음과 같이 최적화를 할 수 있었습니다.

왼쪽 : 비디오만큼 엑소플레이 생성하며 비디오 스트리밍 / 오른쪽: 3개의 엑소플레이어를 재활용하여 비디오 스트리밍

안드로이드 스튜디오 Profiler를 통해 개선 전과 후를 비교했습니다.위 그림과 같이 3개의 ExoPlayer를 재활용하여 비디오 스트리밍 하는 것이 CPU 사용량을 최적화하고 메모리 소비를 줄이며 전력 소모를 감소시킨다는 것을 확인할 수 있었습니다.

CPU 사용량을 평균 42%에서 18%로 감소시켜 전체적으로 57% 개선되었습니다.


MVI를 뿌셔보자!

MVI를 뿌셔보자!

남녀노소 안드로이드 팀은 위와 같은 이유와 비디오 스트리밍 및 비디오 업로드 페이지의 특성으로, 사용자의 제스처나 터치가 빈번하게 발생할 수 있다고 생각했다.

MVVM에서는 이 모든 동작을 VM에서 구현하게 되는데, 이때 코드가 복잡해지고 각 기능이 어떻게 동작하는지 쉽게 이해하기 어려울 것으로 판단했다. 이에 반해, MVI에서는 Intent를 사용하여 사용자 작업의 모든 행위를 명시하고 관리함으로써 코드를 간결하게 유지할 수 있다고 생각했다.

이러한 이유로 MVI 디자인 패턴을 도입하여 프로젝트를 진행하게 되었다.

우리는 Compose 기반 Android 앱에서 MVI 아키텍처를 구현하기 위한 유틸리티 함수를 정의했다.

@Composable
inline fun <reified STATE, EVENT, EFFECT> use(
    viewModel: BaseContract<STATE, EVENT, EFFECT>
): StateDispatchEffect<STATE, EVENT, EFFECT> {
    val state by viewModel.state.collectAsStateWithLifecycle()

    val dispatch: (EVENT) -> Unit = { event ->
        viewModel.event(event)
    }

    return StateDispatchEffect(
        state = state,
        effectFlow = viewModel.effect,
        dispatch = dispatch,
    )
}

data class StateDispatchEffect<STATE, EVENT, EFFECT>(
    val state: STATE,
    val dispatch: (EVENT) -> Unit,
    val effectFlow: SharedFlow<EFFECT>,
)

use 함수는 주어진 viewModel을 기반으로 상태(State), 이벤트(Event), 이펙트(Effect)를 다루기 위한 Compose-friendly한 유틸리티 함수이다.

  • STATE는 상태를 나타내는 타입으로, 해당 상태는 viewModel의 state 플로우를 통해 수집된다.
  • EVENT는 사용자의 액션 또는 이벤트를 나타내는 타입입니다. viewModel의 event 메서드를 호출하여 이벤트를 처리한다.
  • EFFECT는 비동기적인 작업의 결과 또는 부가적인 효과를 나타내는 타입으로, viewModel의 effect 플로우를 통해 수집된다.
  • StateDispatchEffect는 use 함수에서 반환하는 데이터 클래스로, 현재 상태, 이벤트 디스패치 함수, 이펙트 플로우를 포함합니다.

이렇게 정의된 use 함수와 StateDispatchEffect 데이터 클래스는 Compose 기반 앱에서 MVI 아키텍처를 쉽게 구현하고 사용할 수 있도록 도와주는 유틸리티이다.

interface BaseContract<STATE, EVENT, EFFECT> {
    val state: StateFlow<STATE>
    val effect: SharedFlow<EFFECT>
    fun event(event: EVENT)
}

위 MVI 아키텍처에서 사용되는 기본적인 계약을 정의한 인터페이스이다. state는 현재 뷰의 상태를 나타내는 StateFlow로, 뷰는 해당 상태를 수신하여 UI를 업데이트한다. effect는 비동기 작업의 결과나 부가적인 효과를 나타내는 SharedFlow로, 뷰는 해당 이펙트를 수신하여 추가적인 작업을 수행한다. event는 사용자의 액션 또는 이벤트를 처리하는 메서드로, 뷰에서 발생한 이벤트를 받아서 처리하고, 필요한 경우 상태를 업데이트하고 이펙트를 발생시킨다.

**이 인터페이스를 구현하는 구현체는 ViewModel로, 상태, 이벤트 처리, 이펙트에 관한 기본적인 계약을 제공하게 된다. ** 이를 통해 일관된 방식으로 MVI 아키텍처를 구현하고 사용할 수 있다.

MVI를 뿌셔보면서,,,

위 설명과 같이 MVI를 뿌셔보았다.

Contract를 통해 Screen Composable에서 사용할 state, event, effect를 명확하게 정의하고, 해당 Contract를 구현하는 ViewModel에서는 비즈니스 로직을 담당하도록 설계했다. Screen에서는 use 함수를 호출하여 UI에서 뷰모델의 상태를 간단히 수신하고, 사용자 액션을 발생시키며, 뷰모델이 생성하는 이펙트를 간편하게 처리할 수 있도록 구성했다.

Screen에서는 주로 상태를 UI에 렌더링하고 사용자 액션을 event로 트리거한다. 이벤트가 발생하면 ViewModel에서 해당 액션이 어떤 것인지 확인하고, 그에 따라 상태를 업데이트하거나 이펙트를 발생시켜 상태 업데이트 이외의 행위를 실행시킨다. 특히, effect의 경우 Screen에서 정의한 LaunchedEffect에서 해당 이펙트를 감시하다가 발생하면 그에 맞는 동작을 수행한다.

이렇게 함으로써 MVI 패턴을 통한 상태 관리와 UI 로직을 효율적으로 다룰 수 있었다. 이 구조는 코드의 가독성과 유지 보수성을 높이며, 각 구성 요소가 명확한 역할을 수행할 수 있도록 도와준다. 개인적으로 상태 중심의 패턴으로 사용자 액션을 처리하고, 그에 따른 상태 변화를 업데이트하는 방식을 선호하여 이 구조는 꽤나 만족스러웠다. 더 나아가 시간이 허락한다면 이 구조를 더 개발하고 개선하는 것이 목표이다.

ExoPlayer Lifecycle를 관리해보자!(feat.DisposableEffect)

ExoPlayer Lifecycle를 관리해보자!(feat.DisposableEffect)

ExoPlayer를 통해 비디오 스트리밍중 백그라운드로 이동하게 되면 ExoPlayer가 파괴되지 않는 이슈를 발견했다.

이슈를 접했을 때 바로 Activity Lifecycle을 활용할 수 있을 것으로 생각했다. 그러나 Compose에서 Lifecycle에 접근하는 방법을 잘 알지 못해 공식 문서를 참고했다. Compose 부수 효과 공식 문서를 보게되면 DisposableEffect를 사용하여 라이프사이클 이벤트를 감지하고 이에 대한 처리를 수행할 수 있다는 것을 알 수 있다. 위 공식 문서를 이해하면 다음과 같다.

DisposableEffect는 Composable이 처음 구성될 때 실행되며, 해당 Composable이 해제될 때 onDispose 블록 안의 작업을 수행할 수 있는 부수 효과이다. LifecycleEventObserver를 정의하여 라이프사이클 이벤트를 관찰하고, 해당 이벤트에 따라 필요한 동작을 수행할 수 있다. Composable이 해제될 때 onDispose가 실행되며, DisposableEffect가 해제될 때 수행할 작업을 정의한다.

위 공식 문서를 읽고 내 코드에 응용하면 다음과 같다.

        DisposableEffect(lifecycleOwner) {
            val observer = LifecycleEventObserver { _, event ->
                if (event == Lifecycle.Event.ON_RESUME) {
                    when (pagerState.settledPage % 3) {
                        0 -> {
                            exoPlayerPair.first.play()
                            exoPlayerPair.second.pause()
                            exoPlayerPair.third.pause()
                        }

                        1 -> {
                            exoPlayerPair.first.pause()
                            exoPlayerPair.second.play()
                            exoPlayerPair.third.pause()
                        }

                        2 -> {
                            exoPlayerPair.first.pause()
                            exoPlayerPair.second.pause()
                            exoPlayerPair.third.play()
                        }
                    }
                } else if (event == Lifecycle.Event.ON_STOP) {
                    exoPlayerPair.first.pause()
                    exoPlayerPair.second.pause()
                    exoPlayerPair.third.pause()
                }
            }

            lifecycleOwner.lifecycle.addObserver(observer)

            onDispose {
                lifecycleOwner.lifecycle.removeObserver(observer)
                exoPlayerPair.first.release()
                exoPlayerPair.second.release()
                exoPlayerPair.third.release()
            }
        }

위의 코드를 보면 DisposableEffect를 사용하여 Composable의 생명주기 이벤트를 관찰하고, 해당 이벤트에 따라 ExoPlayer를 조작하는 부수 효과를 구현한 것이다.

  • DisposableEffect 블록에서는 lifecycleOwner의 라이프사이클 이벤트를 감지한다.
  • ON_RESUME 이벤트가 발생하면 pagerState.settledPage를 기준으로 현재 페이지를 판별하여 ExoPlayer를 제어한다.
  • ON_STOP 이벤트가 발생하면 모든 ExoPlayer를 일시 정지시킨다.
  • onDispose 블록에서는 라이프사이클 옵저버를 제거하고, 모든 ExoPlayer를 해제한다. 쉽게 말해, 해당 코드는 페이지가 백그라운드로 이동하게 되면 ExoPlayer를 일시 정지하고, 페이지가 다시 화면으로 나타날 때 현재 화면에 해당하는 ExoPlayer가 다시 실행한다. 페이지가 화면에서 완전히 사라질 때는 모든 ExoPlayer 자원을 해제하는 역할을 한다.

이렇듯, 위 공식문서를 읽고 Activity Lifecycle과 DisposableEffect를 활용하여 이슈를 해결했다.

👪 남녀노소 Tech Blog

🤔 우리는 왜 이런 선택을 했는가?

🗣 Ground Rule

💃 MoveMove

🌱 Daily Scrum

1주차
2주차
3주차
4주차
5주차
6주차

✏️ MoveMove 개발 일지

김민조

장지호

조준장

장민석

하채리

🐣 개인회고

김민조
장지호
조준장
장민석
하채리

🐥 팀회고

Clone this wiki locally