티스토리 뷰
개요
오디라는 프로젝트에서 점진적으로 Compose 마이그레이션을 진행중이다.
처음부터 Compose를 사용한게 아니기 때문에, 화면 전환에는 Compose Navigation이 아닌 Intent나 FragmentManager 방식을 사용하고 있다.
화면 전환은 기존에 사용하던 방식을 사용하고, 각각의 Activity/Fragment에서 컴포저블 함수를 호출하는 방식이다.
예를 들어 LoginActivity라면 LoginScreen을 호출하고, MeetingsActivity라면 MeetingsScreen을 호출한다.
이 때, Fragment에서는 ViewCompositionStrategy와 함께 컴포저블 함수를 호출한다.
"State 유지와 메모리 누수를 방지하기 위해" ViewCompositionStrategy를 사용하는 것은 알고 있었지만,
막상 어떤 문제가 발생하는지, 그리고 ViewCompositionStrategy은 어떤 역할을 하는지 제대로 알지 못하고 사용했다.
구글링을 해봐도 명확한 답이 나오지 않아서 이를 계기로 블로그를 작성하게 되었다.
Activity에서 Compose 사용하기
class XXXActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
enableEdgeToEdge()
OdyTheme {
XXXScreen()
}
}
}
}
가장 먼저 Activity에서는 위처럼 간단히 setContent 블록 내에서 컴포저블 함수를 호출할 수 있다.
Fragment에서 Compose 사용하기 - 좋지 못한 예시
class XXXFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeView(requireContext()).apply {
setContent {
OdyTheme {
XXXScreen()
}
}
}
}
}
그런데 Fragment에서 Activity와 유사하게 컴포저블 함수를 호출하면 메모리 누수가 발생한다.
그 이유가 뭘까? 그리고 안전하게 컴포저블 함수를 호출하려면 어떻게 해야할까?
Fragment에서 Compose 사용하기 - 좋은 예시
class XXXFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
)
setContent {
OdyTheme {
XXXScreen()
}
}
}
}
}
결론부터 말하면 위처럼 사용하면 안전하게 컴포저블 함수를 사용할 수 있다.
setContent 뿐만 아니라 글 초기에 말했던 ViewCompositionStrategy도 설정해야 한다.
ViewCompositionStrategy를 설정하지 않으면 발생하는 문제
class XXXFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeView(requireContext()).apply {
setContent {
OdyTheme {
XXXScreen()
}
}
}
}
}
일단 아까 좋지 못한 예시로 보여준 코드의 문제점을 자세히 살펴보자.
기본적으로 컴포지션은 Window에 detach될 때 dispose된다.
Window에서 detach된다는 것은 여러 경우가 있다. 곧 뷰가 소멸되거나, 컨테이너가 뷰에서 벗어나는 경우 등이다.
즉, Fragment View가 소멸되는 타이밍과 Fragment의 컴포지션이 제거되는 타이밍이 다르다.
여기서 한가지 의문이 생겼다.
그렇다면 " Window에 detach되는 것"은 Fragment의 어떤 생명주기에서 발생될까?
구글링을 열심히 해본 결과 명확한 답을 찾을 수 없었다..
(Window에 detach되는 것과 Activity에 detach되는 것은 다르기 때문에, onDetach() 이전에 컴포지션이 제거될 것이라 생각한다)
어쨌든 Fragment에서 뷰가 소멸되는 onDestroyView()과는 완전히 대응되지 않아서 이러한 문제가 발생한다는 것은 알겠다!
만약 Fragment View가 소멸되지 않았는데 Fragment의 컴포지션이 제거된다면?
View는 남아있는데 컴포지션이 제거되어 처음부터 다시 컴포지션이 될 것이다.
이 경우 기존 컴포지션에서 사용되고 있던 State들은 사라진다.
즉, 기존 State들이 유실되어 부자연스러운 UI가 나타날 수 있다.
예를 들어 LazyColumn의 스크롤 상태가 보존되지 못하고 갑자기 화면 가장 상단으로 스크롤이 이동할 수도 있을 것이다.
이는 좋지 못한 사용자 경험으로도 이어질 수 있으니 최대한 지양해야 한다.
또는 Fragment View는 소멸되었는데 컴포지션은 살아있다면, 메모리 누수로 이어질 수 있다.
ViewCompositionStrategy의 역할
이제 Fragment에서 ViewCompositionStrategy 없이 컴포즈를 사용하면 어떤 문제가 발생하는지 이해했다.
그렇다면 ViewCompositionStrategy는 어떤 역할을 하길래 이러한 문제를 해결해 주는지 알아보자.
ViewCompositionStrategy
ViewCompositionStrategy는 컴포지션을 언제 제거해야 하는지 정의하는 것이다.
Fragment 컴포지션이 제거되는 시점이 Fragmen View의 소멸 시점과 달라서 생기는 문제라면,
컴포지션이 제거되는 시점을 코드 상으로 커스텀하면 되는 것이다!
ViewCompositionStrategy 종류
현재는 ViewCompositionStrategy에 4가지 종류가 존재한다.
ViewCompositionStrategy에 대한 글이 아니므로 간단히만 살펴보려고 한다.
- DisposeOnDetachedFromWindow
- 뷰가 Window에서 detach되면 컴포지션이 제거된다.
- DisposeOnDetachedFromWindowOrReleasedFromPool
- 뷰가 Window에서 detach되면 컴포지션이 제거된다.
- Pooling Container의 뷰가 Window에서 detach되거나, 풀이 가득찬 경우 컴포지션이 제거된다.
- (뷰가 Pooling Container에 포함되지 않는다면 DisposeOnDetachedFromWindow와 동일)
- 더 자세히 알고 싶다면 링크 참고
- DisposeOnLifecycleDestroyed
- 인자로 들어온 Lifecycle이 소멸되면 컴포지션이 제거된다.
- DisposeOnViewTreeLifecycleDestroyed
- 연결된 뷰에서 가져온 ViewTreeLifecycleOwner가 소멸되면 컴포지션이 제거된다.
ViewCompositionStrategy의 기본값
현재 모든 컴포지션의 ViewCompositionStrategy 기본값은 DisposeOnDetachedFromWindowOrReleasedFromPool이다.
ViewCompositionStrategy.Default 내부를 확인해보면 단순히 DisposeOnDetachedFromWindowOrReleasedFromPool만 반환하고 있다.
(공식문서에도 나와있듯 충분히 기본값이 변경될 여지가 있어보인다.)
즉, 별도로 ViewCompositionStrategy를 설정하지 않으면 Window에서 detach될 때 컴포지션이 제거된다는 것을 알 수 있다.
Fragment의 ViewCompositionStrategy
class XXXFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
)
setContent {
OdyTheme {
XXXScreen()
}
}
}
}
}
다시 Fragment의 ViewCompositionStrategy로 돌아와보자.
위 코드는 글 초반에 본 좋은 예시의 코드다.
네 종류의 ViewCompositionStrategy 중 DisposeOnViewTreeLifecycleDestroyed를 사용했다.
즉, 명시적으로 "Fragment View가 소멸될 때 컴포지션도 제거해줘"라고 설정했기 때문에,
Fragment View의 생명주기와 동일한 시점에 컴포지션이 제거된다는 것이 보장되는 것이고, State도 유실되지 않을 수 있는 것이다!
class XXXFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnLifecycleDestroyed(this@XXXFragment.viewLifecycleOwner)
)
setContent {
OdyTheme {
XXXScreen()
}
}
}
}
}
DisposeOnViewTreeLifecycleDestroyed이 아닌 DisposeOnLifecycleDestroyed을 사용할 수도 있다.
위처럼 명시적으로 인자로 Fragment View의 LifecycleOwner를 전달해주면,
DisposeOnViewTreeLifecycleDestroyed를 사용한 것과 동일하게 Fragment View가 소멸될 때 컴포지션도 제거된다.
Activity에서는 ViewCompositionStrategy를 사용하지 않는 이유
그런데 왜 Acticity에서는 ViewCompositionStrategy를 따로 설정하지 않았는지 의문이 들 수도 있다.
이유는 Fragment와 Activity의 생명주기 관리 방식의 차이 때문이다.
먼저 Fragment의 생명주기를 보자.
Fragment는 Fragment 자체의 생명주기와 Fragment View의 생명주기가 다르다.
Fragment 생명주기가 Fragment View의 생명주기 보다 길기 때문에,
Fragment는 살아있는데 Fragment View가 여러번 생성/소멸될 수 있다.
즉, 뷰가 소멸된 후에도 참조가 남아있다면 메모리에서 해제되지 못하고 메모리 누수로 이어질 수 있기 때문에 주의해야 한다.이러한 관점에서 컴포지션의 생명주기와 Fragment View 생명주기를 완전히 동일시켜야 안정적이다.
반면에 Activity의 생명주기는 Activity의 생명주기와 Activity View의 생명주기가 동일하다.
Activity가 소멸되면 Activity View도 소멸되고 컴포지션 또한 제거된다.
즉, 특별한 설정 없어도 Activity View와 컴포지션은 함께 정리된다.
이러한 차이 때문에 Fragment에서는 항상 Activity 보다 추가적인 설정이 필요한 것 같다.
(데이터 바인딩에서 binding 객체의 참조를 명시적으로 제거한 것처럼..)
마무리
이렇게 마음 한구석 찝찝했던 Fragment의 ViewCompositionStrategy에 대해 알아봤다!
코드의 생산성을 위해 프로젝트를 Compose로 마이그레이션하고 있지만, 이것저것 새롭게 알게 되는 것이 많아 즐거운 마음으로 마이그레이션하고 있다.
100% Compose를 목표로 틈틈히 마이그레이션 중이다~!
현재 마이그레이션 진행 중인 오디 프로젝트의 깃허브 링크를 끝으로 글을 마무리한다.
https://github.com/woowacourse-teams/2024-ody
GitHub - woowacourse-teams/2024-ody: 더 이상 "너 어디야?"라고 물어보지 마세요. 약속을 더욱 즐겁고 편안
더 이상 "너 어디야?"라고 물어보지 마세요. 약속을 더욱 즐겁고 편안하게, 여러분의 우정을 더욱 돈독하게 만들어 드릴게요. - woowacourse-teams/2024-ody
github.com
잘못된 내용이 있다면 피드백 부탁드립니다! (--) (__)
참고 자료
https://developer.android.com/reference/kotlin/androidx/compose/ui/platform/ViewCompositionStrategy
https://developer.android.com/guide/components/activities/activity-lifecycle?hl=ko
https://developer.android.com/guide/fragments/lifecycle?hl=ko
'kotlin > compose' 카테고리의 다른 글
Paging3+RecyclerView를 Compose로 마이그레이션하기 (0) | 2025.04.04 |
---|---|
Compose Side Effect 이해하기! (2) (0) | 2025.03.24 |
Compose Side Effect 이해하기! (1) (2) | 2025.02.27 |