Fragment의 Lifecycle 잘 알고 있나요
서론
최근에 기본기를 다지다 보면서 Fragment의 Lifecycle에 대해서 다시 공부했었습니다. 그런데, 확실히 알고 나니 제 프로젝트에서 오동작하는 상황에서 어떠한 부분이 문제고, 어떻게 고쳐야 할지 빠르게 파악이 되고, 수정할 수 있는 "힘"이 생기는 것 같습니다.
저의 프로젝트에서 문제 상황을 먼저 보고, Fragment Lifecycle을 공부한 뒤, 해결해보는 시간을 가져보겠습니다.
문제
블로그 주인 seokzoo는 RecyclerView를 이용해 유저의 List를 LinearLayout 형식으로 보여주고 있다.
대세에 따라 Single Activity, Mutiple Fragment를 구현해주었고, RecyclerView를 보여주는 UserListFragment와 아이템을 클릭시에 상세 페이지를 보여주는 UserDetailFragment가 있다.
(UserListFragment -> UserDetailFragment로 replace 해준다.)
seokzoo는 다음과 같은 코드를 작성했는데, UserDetailFragment만 들어갔다 나오면 스크롤도 맨위로 올라가고, 화면이 새로고침된다... 어떤부분이 문제일까?
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = DataBindingUtil.bind(view)
?: throw IllegalStateException("fail to bind")
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = userListViewModel
doRefresh() // swipe 시에 행동 구현
setScrollListener() // 무한 스크롤 구현
setRecyclerView() // 리사이클러뷰 어댑터와 무한 스크롤 리스너를 리사이클러뷰에 연결
firstDataSubmit() // 초기 데이터 submitList
}
정답은 같이 공부 후, 마지막에 같이 맞춰보겠습니다.
Fragment의 Lifecycle
먼저 Fragment의 Lifecycle에 대해서 알아봅시다.
위의 사진은 Android developer에 올라온 Fragment의 lifecycle입니다.
우리가 알고있듯이, Fragment Callbacks는 위와 같은 순서대로 호출이 됩니다! 저기 없는 것이 있다면, onCreate() 이전에 onAttatch()와 onDestroy() 이후에 onDetach()가 호출됩니다.
또한 추가로 알아두면 좋을 것이 있다면, onCreateView에서 findViewById등을 사용하면 뷰가 완전히 초기화가 되지 않아 충돌이 일어날 수 있으니, inflate외의 초기화 함수는 onViewCreated() 에서 사용하면 더 좋다 정도가 있을 것 같습니다.
그렇다면 다음의 문제를 풀어봅시다.
실제 실행결과를 보기 전에 어떻게 나올지 먼저 생각해보세요!
문제 1. Activity에서 Fragment 위에 Fragment를 add하면 각각의 Fragment의 Lifecycle Callback은 어떻게 호출될까?
실제 실행결과를 보겠습니다!
- MainActivity가 onCreate되고, 가장 먼저 add된 MainFragment가 onStart까지 호출됩니다.
- 후에 MainActivity가 onResume 까지 가고
- MainFragment도 onResume까지 따라가게됩니다.
- 그리고 나서 add로 MainFragment 위에 SecondFragment를 덮어서 올리게 된다면
- 다른 Activity, Fragment의 Lifecycle은 멈춰있고 SecondFragment만 onResume까지 진행하게 됩니다.
즉, Fragment의 특성상 다른 Fragment위에 Fragment를 올린다면 두 Fragment 모두 onResume에서 멈춰있게 되며, 현재 보이는 Fragment를 찾아 어떠한 작업을 한다면, 오류를 일으킬 수 있습니다.
왜냐하면 두 Fragment 모두 현재 보이는 Fragment에서 true를 반환할 수 있기 때문입니다!
문제 2. Fragment위에 add Fragment가 아닌, replace Fragment를 했을 때(addToBackStack을 해주지 않을 때)
실제 실행결과를 보겠습니다!
- MainFragment의 onResume까지는 add와 동일하기 때문에 넘어가겠습니다.
- replace가 되었을 때, MainFragment의 onPause와 onStop이 불립니다.
- 후에 SecondFragment가 onAttach부터 onStart까지 불리게 됩니다.
- 그 이후에 MainFragment는 onDestroyView, onDestroy, onDetach까지 한꺼번에 불리고난 뒤
- 마지막으로 SecondFragment가 onResume으로 변하게 됩니다.
현재 Fragment 처럼 replace시에 addToBackStack을 하지 않는다면, replace 후에 back key를 누를 시 이전 Fragment가 아닌 Activity와 Fragment가 onStop까지 가며 화면 밖으로 나가버리게 됩니다. 아래와 같이요!
위와 같은 상태는 백그라운드에서 오래 있을 경우, 리소스가 모자란다면 시스템에서 강제 종료하여 onDestroy까지 불릴 수도 있는 상태입니다.
예상하신 것과 동일한가요 ? 다음 문제를 보겠습니다!
문제 3. Fragment위에 add Fragment가 아닌, replace Fragment와 addToBackStack을 사용해줍니다.
실제 실행결과를 보겠습니다!
다른 점을 찾으셨나요? addToBackStack을 사용한다면 onDestry와 onDetach가 불리지 않게 됩니다.
이를 통해 알 수 있는 점은, Fragment의 리소스를 해제해줄 때 onDestroy나 onDetach에서 해줄 경우 addToBackStack을 이용했을 경우 위의 함수가 불리지 않아 *리소스가 해제되지 않을 수도 있습니다. * 이전에 작성했던 post인 Fragment에서의 ViewBinding null 처리를 보면 onDestroyView에서 binding = null 처리를 해주는데, 위와 같은 이유에서 입니다.
또한, onDestroy와 onDetach가 불리지 않는 이유는, backstack에 저장되는 Fragment의 정보를 가지고 있다가 다시 불러오기 때문에 Fragment의 정보를 모두 삭제하지 않고 복구할 수 있는 정보를 들고 있다가 back key를 눌렀을 때 다시 복구하게 됩니다.
문제 4. replace 후에 다시 이전 Fragment로 돌아오게 된다면?
실제 실행 결과를 보겠습니다! (이전의 실행 결과는 위와 동일하기 때문에 SecondFragment의 onResume부터 캡쳐했습니다.)
replace 이후에 back key를 눌러 다시 이전 Fragment로 돌아온다면
- onStop까지 불리고나서 MainFragment가 onStart까지 가게 됩니다.
- 이번에는 replace때와는 다르게 onDestroy, onDetach까지 모두 불리게 됩니다.
- 후에 MainFragment가 onResume으로 상태변경 callback을 받게됩니다.
여기서 주의깊게 볼점은 다시 나타난 MainFragment는 onAttach와 onCreate가 불리지 않았다는 점입니다.
즉, MainFragment에 addToBackStack을 적용한 replace는 Fragment를 destroy나 detach가 불리지 않았기 때문에 화면에 보이지 않을 뿐 여전히 MainActivity에 attach 되어있다는 뜻입니다.
onCreate는 생명주기에서 단 한번만 불린다는 특징이 있다는 것, 기억하시나요?
이점을 꼭 유의해서 개발하시면 Fragment의 lifecycle에 대해서는 어려운점 없이 개발할 수 있을 것 같습니다.
문제 해결!
그렇다면 위의 문제로 다시 돌아가, 어떤 것이 잘못 됐을까요?
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = DataBindingUtil.bind(view) ?: throw IllegalStateException("fail to bind")
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = userListViewModel
doRefresh() // swipe 시에 행동 구현
setScrollListener() // 무한 스크롤 구현
setRecyclerView() // 리사이클러뷰 어댑터와 무한 스크롤 리스너를 리사이클러뷰에 연결
firstDataSubmit() // 초기 데이터 submitList
}
이 코드는 상세페이지를 보기 위해 클릭하여 UserDetailFragment로 들어갔다가, back key를 눌러 다시 나오게됐을 때, 다시 ViewModel에 데이터를 요청하여 list를 다시 submit하고, 리스트의 맨 위에서부터 다시 보여주게 됩니다. 즉, UserDetailFragment에 들어갔다 나올 때마다 리스트를 submitList() 해주게된다는 것입니다.
이 문제를 해결하기 위해서는 View를 초기화해주는 onCreateView보다 위의 단계인, lifecycle에서 한번만 호출해도 되는 onCreate()에서 초기 데이터 setting 함수를 호출해주면 해결할 수 있게됩니다.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
firstDataSubmit() // 초기 데이터 submitList
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = DataBindingUtil.bind(view) ?: throw IllegalStateException("fail to bind")
binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = userListViewModel
doRefresh() // swipe 시에 행동 구현
setScrollListener() // 무한 스크롤 구현
setRecyclerView() // 리사이클러뷰 어댑터와 무한 스크롤 리스너를 리사이클러뷰에 연결
}
이렇게 된다면 목적에 맞게 초기 데이터 setting 함수는 onCreate에서 단 한번만 호출되게 됩니다.
주의할 점
다만 주의할 점이 있습니다. 위와 같은 코드는, 화면 회전에 대해서는 대응하지 않은 코드입니다!
화면 회전시, configuration change로 onCreate부터 다시 돌게됨을 인지하고, 그에 따른 대응도 해주어야 합니다.
저는 아래와 같이 해결해주었습니다.
MainActivity에서 화면 회전이 아닌 경우(첫 실행)에만 fragment를 add 해줍니다.
// MainActivity
if (savedInstanceState == null) {
supportFragmentManager.commit {
add<UserListFragment>(R.id.fc, tag = "UserListFragment")
setReorderingAllowed(true)
}
}
Fragment에서도 configuration change시에 onCreate가 다시 돌기 때문에,
savedInstanceState가 null인 경우에만 첫 데이터 submit을 해주도록 합니다.
// UserListFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
firstDataSubmit()
}
}
후기
위와 같이 Fragment lifecycle에 대해서 정확하게 알고 있어야지만, 왜 onDestroy가 아니라 onDestroyView에서 리소스를 해제해주어야 하는지, 왜 onCreateView가 아닌 onCreate()에서 초기 데이터 setting 함수를 호출 해야 하는지, 화면 회전 등 다양한 문제들에 대해서 타당한 이유를 가지고 코딩할 수있게 되는 것 같습니다.
많은 도움이 돼셨으면 좋겠습니다! 감사합니다.