본문 바로가기
안드로이드/코틀린

media3의 exoPlayer - ComposeUI

by 나이아카 2025. 4. 16.
반응형

 이번에 회사에서 exoPlayer2를 쓰고 있다가 media3로의 전환을 시도했는데요. 처음에는 exoPlayer 자체가 레거시한 코드라고 생각해서 media3에 있는 exoPlayer도 쓰지 않을 예정이었습니다만, 잘 찾아보니 exoPlayer의 코드와 media3에서 제공해주는 코드 중 중복되는 것들이 많아 이를 한 번에 관리하고자 주체를 media3 라이브러리로 옮긴 것에 불과해 사용해도 문제가 없겠다는 판단을 하게 되었습니다.

 그래서 exoPlayer는 그대로 사용하되, 라이브러리만 exoPlayer2에서 media3로 이전해왔습니다. 하지만 기존에 Java로 Activity가 구성되어 있어 이왕 하는 김에 Compose UI로 이전해보고자 ExoPlayer를 구성했는데, 이것 참 ExoPlayer를 부착할 PlayerView가 Compose 용으로 없더라구요. 그래서 AndroidView로 구현하게 되었는데, 그에 따른 문제가 발생해서 기록용으로 이 글을 작성하게 되었네요. 그렇지만 이번 글에서는 마이그레이션에 관한 문제는 다루지 않을 예정입니다. 디테일 한 부분들이 많이 바뀌었는데, 아무래도 이는 따로 확인하는 게 더 깔끔할 것 같다는 생각이 들어서요.

 아, 들어가기에 앞서 제가 사용한 media3는 

implementation 'androidx.media3:media3-exoplayer-dash:1.3.1'
implementation 'androidx.media3:media3-exoplayer:1.3.1'
implementation 'androidx.media3:media3-ui:1.3.1'

 위와 같습니다. 현재(25년 4월 15일 기준) media3는 1.6.0 버전이 최신이지만, 이는 android 15가 target이 되지 않으면 사용할 수 없습니다. 아직 프로젝트의 Android 버전이 14인 targetSDK가 34이므로 가능한 최대치인 1.3.1버전으로 진행하였습니다.


 오늘은 저 답지 않게 먼저 코드부터 살펴보겠습니다.

@Composable
fun VideoStreamingScreen(
    modifier: Modifier,
    viewModel: VideoStreamingViewModel = hiltViewModel<VideoStreamingViewModelImpl>()
) {
    val player by viewModel.player.collectAsState()
    val isLoading by viewModel.isLoading.collectAsState()
    val context = LocalContext.current

    LaunchedEffect(Unit) {
        viewModel.initializePlayer()
    }
    DisposableEffect(Unit) {
        onDispose {
            viewModel.releasePlayer()
        }
    }

    Box(
        modifier = modifier
            .fillMaxSize()
            .background(Color.Black)
    ) {
        if(player != null) {
            AndroidView(
                factory = {
                    PlayerView(context).apply {
                        useController = true
                    }
                },
                update = { playerView ->
                    playerView.player = player
                    playerView.requestFocus()
                },
                modifier = Modifier.fillMaxSize()
            )
        }

        if (isLoading) {
        	// 이 코드는 프로젝트에서 커스텀으로 사용하는 프로그래스 바
            AppCircularProgressIndicator(modifier = Modifier.fillMaxSize())
        }
    }
}

@Composable
@Preview
fun VideoStreamingScreenPreview() {
    VideoStreamingScreen(modifier = Modifier.background(color = Color.Black))
}

 ExoPlayer를 ComposeUI에서 구성하기 위한 코드는 굉장히 간단합니다(정확히는 UI를 간단하게 구성했습니다). 먼저 맨 위에 collect하고 있는 player는 ExoPlayer class로 기존 exoPlayer 라이브러리에서 사용하던 것과 동일합니다. exoPlayer를 구성하기 위해 캐싱 처리, trackSelector, render 설정 처리, url을 통해 비디오 등록, 시작시 자동 재생 설정과 같은 비디오 설정을 이 클래스에서 처리할 수 있습니다. 이 클래스를 PlayerView의 player 변수에 넣어주면 설정 및 비디오 등록이 완료됩니다.

 PlayerView는 ExoPlayer의 재생시 UI를 담당하며 기본적으로 surfaceView를 이용해 비디오를 렌더링합니다. 여기서는 동영상 재생에 필요한 컨트롤러 처리등 UI에 관한 내용을 처리할 수 있습니다. 커스터마이징 하지 않는다면 크게 옵션을 건드릴 일은 없을 것 같습니다.

위 코드는 보편적으로 큰 문제가 없습니다. 하지만 만약 가로로 긴 영상을 세로모드로 틀었을 경우(반대의 경우에서도 문제가 발생할 수 있습니다), 정상적인 구성이라면 default resizeMode인

AspectRatioFrameLayout.RESIZE_MODE_FIT

에 맞춰 상하단에 여백이 남고 가로가 가득 찬 상태에서 비율에 맞게 구성되어야 하지만, 실제로는 동영상의 크기가 화면 밖으로 튀어나가거나 잘리는 경우가 발생합니다(이 문제때문에 이 글을 쓰기 시작했죠). 이를 해결하기 위해서 가장 쉬운 방법은

showController()
hideController()

를 연달아 factory에서 호출해 뷰가 생성되는 시점에 내부적으로 requestFocus를 주는 것입니다. 이러면 컨트롤러가 나오고 들어가는 시점에 맞춰 PlayerView 내부적으로 child View들을 갱신하므로 화면의 사이즈를 정상적으로 판단해 보여주게 됩니다. 그러나 이를 사용하게 되면 잠시 컨트롤러가 반짝이며 생성되었다 사라지기 때문에 어색함을 느낄 수 있습니다. 그래서 show와 hide 사이에 delay를 1초 정도 두어 최초 생성시 컨트롤러가 보였다가 사라지는 형태로 하면 UX적인 어색함은 줄일 수 있습니다.

 하지만 근본적인 원인은 해결된 것이 아니라서 어쨌든 ComposeUI에 PlayerView를 작업하고 싶다면 해결하는 방법이 있긴 합니다(다른 방안도 분명히! 근본적으로 있을 것 같은데... 많은 시도를 해본 것 같지만 결국  이 방법을 사용하게 되었습니다.)

<androidx.media3.ui.PlayerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:resize_mode="fit"
    app:surface_type="texture_view" />

 view_exo_player.xml을 추가했습니다.

AndroidView(
    factory = { context ->
        LayoutInflater.from(context)
            .inflate(R.layout.view_exo_player, null, false) as PlayerView
    },
    update = { playerView ->
        playerView.player = player
    },
    modifier = Modifier.fillMaxSize()
)

 그리고 안드로이드 뷰를 위와 같이 변경했습니다. PlayerView의 kotlin 코드로는 surfaceType을 설정할 수 없어 xml에서 설정한 후, 이를 inflate해서 AndroidView로 사용하는 방식입니다. surface_type을 제거하면 똑같은 문제가 발생할 수 있습니다. 여기서 surface_type을 설정하는 이유는 textureView와 SurfaceView의 렌더링 방식이 달라, ComposeUI에서 AndroidView로 UI를 전달할 때 surfaceView의 스케일 사이즈를 변경할 때 오류가 발생할 수 있기 때문입니다.

 이는 위에서 설명했던 것처럼 컨트롤러를 show하는 것으로 맞출 수 있는 것으로 보아 내부 코드에서 호출하는 requestFocus와 requestLayout으로 해결할 수 있는 것으로 보이지만, 외부에서 내부 childView의 UI 업데이트를 호출하는 것이 어렵기 때문에, TextureView를 통해 스케일링을 해주는 것이 가장 간단한 방법입니다(이외에도 동영상의 사이즈를 surfaceView에 직접 맞추는 방법도 있습니다만, 이는 수동으로 영상을 맞추는 일이라 선호하지 않아 최대한 배제하고 생각하였습니다).

 

 ViewModel 코드는 아래와 같이 구성할 예정이라 interface를 구성해두었습니다.

interface VideoStreamingViewModel {

    val isLoading: StateFlow<Boolean>
    val player: StateFlow<ExoPlayer?>

    fun initializePlayer()
    fun releasePlayer()
}

 

 구현부는 아래와 같습니다.

private val videoUrl: String? = savedStateHandle[VideoStreamingArgs.VIDEO_PLAYER_URL]

private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

private val _player: MutableStateFlow<ExoPlayer?> = MutableStateFlow(null)
override val player: StateFlow<ExoPlayer?> = _player.asStateFlow()

override fun releasePlayer() {
    _player.value?.release()
    _player.value = null
}

override fun initializePlayer() {
	val renderersFactory = DefaultRenderersFactory(context)
    	.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
	viewModelScope.launch {
    	_isLoading.emit(true)
        ExoPlayer
            .Builder(context, renderersFactory)
            .setTrackSelector(DefaultTrackSelector(context))
            .build()
            .appy {
            	videoUrl?.let { setMediaItem(MediaItem.fromUri(Uri.parse(it))) }
                prepare()
                playWhenReady = true // 자동 재생
            }
        _isLoading.emit(false)
    }
}

 전체 코드는 좀 더 여러가지가 있으나, 필수적인 부분만 가져왔습니다. 커스텀을 위해 필요한 부분을 전부 가져오게 되면 그 코드를 이해하는데 불필요한 시간을 추가로 소모할 수 있으니까요. 여기서 release는 Player를 제거하는 용도로, initialize는 초기화하는 코드로 이해하고 사용하면 됩니다!

 하나씩 살펴보겠습니다. 먼저 videoUrl은 ExoPlayer 재생을 위한 url입니다. 스트리밍용이기 때문에 외부에서 가져오는 데이터가 될 것입니다(저희는 서버에 저장되어 있죠).

 player의 경우 위에서 설명했던 ExoPlayer Class이며 여러가지 설정을 조합합니다. 여기서는 prepare를 통해 비디오를 등록하고 playWhenReady를 통해 자동 재생 여부를 설정하는 정도만 처리했습니다. 물론 CacheDataSource.Factory를 통해 캐싱처리 설정이 가능하고 DefaultRenderersFactory와 같은 클래스로 렌더링 설정도 가능합니다.

 release를 통해 꼭 앱이 종료될 때 release 처리를 해주어야 메모리 누수가 일어나는 것을 방지할 수 있습니다. 동영상의 경우 주로 용량이 크기 때문에 다른 코드들과 달리 이런 부분들을 조금 신경써줘야 합니다.


 여러가지 이유로 이제 왠만하면 ComposeUI로 작업을 진행하려고 하는데, 굉장히 에러사항이 많은 것을 느낍니다. 버전에 따라 호환되지 않는 부분이나, 아직도 xml에서 되는 것들 중 Compose에서 지원되지 않아 구 버전의 코드를 사용해야 한다던가하는... 혹은 라이브러리가 ComposeUI와 100% 호환되지 않아서 생기는 문제 등... UI를 구성하기 위해 참 좋은 방법이라고 생각은 하는데 중간중간 터져나오는 스트레스를 감당하는 건 좀 어려운 것 같습니다.

 물론 실력 좋은 사람들은 내부 코드를 분석해서 어찌저찌 해결하긴 하는 것 같고, 저 역시도 예전에는 내부 코드까지 뜯어서 분석하는 건 피하려고 했으나 요즘은 어쩔 수 없이 내부 코드를 확인하고 문제되는 부분을 여러가지 방법을 통해(이게 해결되기 전까지는 자괴감이 크게 들다가 해결하고 나면 또 스텝업한 거 같고 그런 프로세스를 가지고 있긴 한데...) 해결하게 되었습니다.

 언어고 라이브러리고 사람이 개발하는 거다 보니 버그가 없을 수 없고(없을 수도 있겠지만) 고려하지 못한 부분들도 있을 수 있는 것은 이해가 되지만 막상 그 부분이 제 프로젝트와 연관이 될 때 한숨이 나오는 건 어쩔 수 없네요. 하지만 이 똑똑한 사람들도 결국 실수를 하는데, 저라고 뭐 안하겠습니까~ 하는 편안 마음을 지니려고 노력하고 있습니다.

 하여튼 잡설이 길었는데, xml 없이 PlayerView class를 조절하는 것만으로 버그가 없도록 처리하는 코드를 틈틈히 테스트해봐야겠습니다. 만약 테스트가 성공적이라면 여기에 추가적인 코드가 올라올 수 있겠죠!

댓글