이번에 회사에서 exoPlayer2를 쓰고 있다가 media3로 전환을 시도해보았습니다. 처음에는 “exoPlayer 자체가 레거시한 코드 아닐까?” 싶어서 media3 안에 있는 exoPlayer도 쓰지 않을 생각이었는데요. 찾아보니 기존 exoPlayer 코드와 media3에서 제공하는 코드 사이에 중복되는 부분이 꽤 있어서, 한 번에 관리하는 관점으로는 media3로 주체를 옮기는 게 더 낫겠다고 판단하게 되었습니다.
그래서 결론적으로는 exoPlayer는 그대로 사용하되, 라이브러리만 exoPlayer2에서 media3로 이전했습니다. 그리고 기존 Activity가 Java로 구성되어 있었던 김에, 이왕 하는 김에 Compose UI로도 함께 옮겨보자고 마음먹었는데요. 막상 해보니 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'
(작성 당시 기준) media3 최신 버전이 더 있었지만, 제 프로젝트 환경(예: SDK 설정/프로젝트 제약)상 적용이 어려워 가능한 범위에서 1.3.1로 진행했습니다.
1) Compose UI에서 PlayerView 붙이기
오늘은 저 답지 않게, 먼저 코드부터 살펴보겠습니다. Compose 쪽에서 PlayerView를 붙이는 코드는 아래처럼 구성했습니다.
@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())
}
}
}
ExoPlayer를 Compose UI에서 구성하기 위한 코드는 생각보다 단순했습니다(정확히는 UI를 단순하게 구성했습니다).
위에서 collect하고 있는 player는 ExoPlayer 클래스이고, 기존 방식과 동일하게 설정을 넣을 수 있습니다.
예를 들어 캐싱 처리, TrackSelector, Render 설정, URL 기반 MediaItem 등록, 자동 재생 등은 이 클래스에서 처리하시면 됩니다.
그리고 그 ExoPlayer 인스턴스를 PlayerView의 player에 넣어주면 기본적인 재생 준비는 끝납니다.
PlayerView는 재생 시 UI를 담당하고, 기본적으로 SurfaceView를 이용해 영상을 렌더링합니다. 여기서는 컨트롤러 처리 등 UI에 관한 내용을 관리할 수 있는데, 커스터마이징하지 않으신다면 크게 옵션을 건드릴 일은 많지 않으실 것 같습니다.
문제: 비율이 맞지 않아서 영상이 튀어나가거나 잘리는 현상
위 코드는 보편적으로 큰 문제가 없어 보이는데요. 다만 가로로 긴 영상을 세로 모드로 틀었을 때(반대 케이스에서도 비슷한 문제가 생길 수 있습니다), 정상이라면 default resizeMode인
AspectRatioFrameLayout.RESIZE_MODE_FIT
기준으로 상/하 여백이 남고 가로가 가득 찬 상태에서 비율을 유지해야 합니다. 그런데 실제로는 동영상 크기가 화면 밖으로 튀어나가거나 잘리는 경우가 발생했습니다. (이 문제 때문에 글을 쓰기 시작했죠.)
참고로 저는 위 코드와 같이 세로 고정 화면 + AndroidView로 PlayerView를 붙인 구성에서, 초기 진입/재생 시작 시점에 이 현상이 확인되었습니다.
임시 방편: show/hideController로 내부 레이아웃 갱신 유도
가장 쉬운 해결(에 가깝게 보이는) 방법은, 뷰가 생성되는 시점에 내부적으로 갱신을 유도하는 것이었습니다. 아래를 factory에서 연달아 호출하면 내부 레이아웃이 한 번 다시 잡히는 느낌이 있었고, 그 덕분에 화면 사이즈가 정상적으로 반영되는 것처럼 보였습니다.
showController()
hideController()
이렇게 하면 컨트롤러가 나오고 들어가는 타이밍에 맞춰 PlayerView 내부 child view들이 갱신되면서, 화면 사이즈를 정상적으로 판단해 표시해주는 것처럼 보였습니다.
다만 이 방법은 컨트롤러가 잠깐 반짝이며 나타났다 사라지는 느낌이 날 수 있어서, UX적으로 어색할 수 있습니다. 그래서 show/hide 사이에 delay를 1초 정도 두어 “처음엔 컨트롤러가 보였다가 사라지는” 형태로 만들면 어색함이 조금 줄어들긴 합니다.
최종 해결책: XML PlayerView + surface_type=texture_view
하지만 위 방식은 근본 원인을 해결한 건 아니어서, “Compose UI에서 PlayerView를 안정적으로 붙이고 싶다” 쪽으로 계속 고민해보았습니다. (다른 방안도 분명히! 근본적으로 있을 것 같은데... 많은 시도를 해본 것 같지만 결국 이 방법을 사용하게 되었습니다.)
먼저 아래처럼 view_exo_player.xml을 하나 추가했습니다.
<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" />
그리고 AndroidView를 아래처럼 “직접 생성”이 아니라 “inflate” 방식으로 바꿨습니다.
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의 렌더링 방식 차이 때문입니다. Compose UI에서 AndroidView로 UI를 전달할 때, SurfaceView의 스케일/사이즈가 바뀌는 과정에서 오류가 발생할 수 있다고 느꼈습니다.
위에서 컨트롤러를 show하는 것으로 맞출 수 있었던 걸 보면, 내부적으로 requestFocus / requestLayout 같은 호출로 해결되는 지점이 있는 것으로 보이긴 합니다. 다만 외부에서 PlayerView 내부 child view들의 업데이트를 직접 호출하기가 쉽지 않아서, 결과적으로는 TextureView를 통해 스케일링 문제를 우회하는 방식이 가장 간단했습니다.
물론 surfaceView에 맞춰 영상을 직접 수동으로 맞추는 방법도 있긴 한데, 개인적으로는 “수동으로 영상 맞추기”가 부담스러워서 최대한 배제하고 생각했습니다.
ViewModel 구성
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()
.apply {
videoUrl?.let { setMediaItem(MediaItem.fromUri(Uri.parse(it))) }
prepare()
playWhenReady = true // 자동 재생
}
_isLoading.emit(false)
}
}
release는 Player를 제거하는 용도, initialize는 초기화하는 코드라고 이해하고 사용하시면 됩니다. videoUrl은 ExoPlayer 재생을 위한 URL이고, 스트리밍이라면 보통 외부(서버)에서 내려받는 데이터가 되겠죠.
player(ExoPlayer)는 여러 설정을 조합하는 핵심 객체입니다. 여기서는 prepare로 등록하고 playWhenReady로 자동 재생 정도만 넣었지만, CacheDataSource.Factory로 캐싱 설정을 하거나, RenderersFactory로 렌더링 옵션을 조정하는 것도 가능합니다.
그리고 동영상은 용량이 큰 편이라, release 처리를 제대로 해주지 않으면 메모리 누수 같은 문제로 이어질 수 있어서 이런 부분은 다른 코드보다 조금 더 신경 써주시는 게 좋습니다.
여러 가지 이유로 이제 웬만하면 Compose UI로 작업을 진행하려고 하는데, 생각보다 에러 사항이 많다는 걸 많이 느낍니다. 버전에 따라 호환되지 않는 부분이 있거나, 아직도 XML에서는 되는데 Compose에서는 바로 지원되지 않아서 구버전 코드를 써야 하는 경우도 있고요. 혹은 라이브러리가 Compose UI와 100% 호환되지 않아서 생기는 문제도 종종 있는 것 같습니다.
UI를 구성하기에 참 좋은 방법이라고 생각하면서도, 중간중간 터져 나오는 스트레스를 감당하는 건 쉽지 않네요. 물론 실력 좋은 분들은 내부 코드를 분석해서 해결해내시기도 하고, 저도 예전에는 내부 코드까지 뜯는 걸 피하려고 했는데 요즘은 어쩔 수 없이 내부 코드를 확인하면서 여러 방법으로 해결하게 되는 것 같습니다. (해결되기 전까지는 자괴감이 크게 들다가, 해결하고 나면 또 스텝업한 것 같기도 하고요.)
언어든 라이브러리든 사람이 만드는 거다 보니 버그가 없을 수 없고, 고려하지 못한 부분이 생길 수도 있다는 건 이해가 되지만 막상 그게 제 프로젝트와 연관될 때 한숨이 나오는 건 어쩔 수 없네요. 그래도 “이 똑똑한 사람들도 실수를 하는데, 저라고 뭐 안 하겠습니까~” 같은 마음으로 편안하게 받아들이려고 노력 중입니다.
하여튼 잡설이 길었는데, XML 없이 PlayerView 클래스만으로도 버그 없이 처리할 수 있는 방법이 있는지 틈틈이 테스트해봐야겠습니다. 만약 테스트가 성공적이라면, 여기에 추가 코드가 올라올 수도 있겠죠!
'안드로이드 > 코틀린' 카테고리의 다른 글
| ComposeUI의 구성 순서와 SideEffect (0) | 2025.05.22 |
|---|---|
| Context parameter from Context Receiver (2) | 2025.05.12 |
| Kotlin - Dispatcher (1) | 2025.04.01 |
| EditText와 RecyclerView(list에서 EditText 사용시 주의점) (0) | 2024.10.24 |
| Android PhotoPicker (4) | 2024.08.19 |
댓글