동아리 mvvm 스터디에서 Android Memory Leak이 발생한다고 설명했지만 정확히 어떻게 메모리 누수가 발생하는지 알아보자.
Leak Canary는 SearchFragment에서 Item 클릭 후 해당하는 아이템의 Detail한 정보를 볼 수 있는 DetailFragment로 이동했을 때 memory leak을 탐지했다.
이 상황은 SearchFragment가 onDestroyView가 되었을 때 memory leak이 발생하는 것을 알 수 있었다.
이 메모리릭에 자세히 알아보기 전에 Fragment의 binding에 대해서 알아볼 필요가 있었다.
binding = null in onDestroyView()
viewbinding은 뷰와 상호작용하는 코드를 쉽게 작성하게 해준다.
뷰바인딩이 활성화되면, XML layoutfile에 대한 바인딩 클래스를 생성한다.
바인딩 클래스의 인스턴스에는 해당 레아웃에 ID가 있는 모든 view에 대한 직접 참조가 포함되어 있다.
class SearchFragment :Fragment() {
private var _binding : SearchFragmentBinding?= null
private val binding get() = _binding!!
override fun onCreateView(
inflater : LayoutInflater,
container : ViewGroup?,
savedInstanceState: Bundle?
) : View? {
_binding = SearchFragmentBinding.inflate(inflater, container, false)
val view = binding.root
return view
}
override fun onDestoryView(){
super.onDestroyView()
_binding = null
}
}
Fragment는 Fragment's view보다 생명주기가 길다.
binding은 view에 대한 직접 참조를 가지고 있는데 onDestroyView()가 호출되어 뷰가 파괴되었음에도 계속 참조를 가지게 된다면, view가 gc에 의해서 정리가 되지 않기 때문에 _binding을 view가 파괴됐을 시점은 onDestroyView()에서 null처리를 통해서 gc가 수집할 수 있도록한다.
하지만 바인딩을 null처리 했음에도 메모리릭은 계속 존재했다.
class SearchFragment :Fragment() {
private var _binding : SearchFragmentBinding?= null
private val binding get() = _binding!!
private val adapter: SearchAdapter by lazy { SearchAdapter(this) } // fragment 가 들고 있음
override fun onCreateView(
inflater : LayoutInflater,
container : ViewGroup?,
savedInstanceState: Bundle?
) : View? {
_binding = SearchFragmentBinding.inflate(inflater, container, false)
val view = binding.root
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.rvSearch.adapter = adapter
}
override fun onDestoryView(){
super.onDestroyView()
_binding = null
}
}
위의 코드만 봤었을 때는
리사이클러뷰가 adapter를 참조하고 있고, binding을 null 처리해줌으로써 리사이클러뷰를 참조하고 있던 binding이 gc에 의해서 정리되기 때문에 문제가 없어보인다.
Leak Canary의 Heap Dump File을 보았다.
Recyclerview -> SearchAdapter의 참조는 binding을 null처리를 해주기 때문에 메모리릭이 없을 것이라 생각했지만,
SearchFragment -> SearchAdapter -> Recyclerview의 숨겨진 참조가 있었다.
그렇다면 메모리릭이 왜 발생했을 까를 생각해보면,
adapter의 Recyclerview에 대한 참조가 Fragment view의 생명주기 바깥에서 유지가 되기 때문인 것 같았다.
Recyclerview는 onDestroyView에서 파괴가 되어야하지만, adapter가 살아있기 때문에 참조를 유지하고 있어서 gc가 회수를 하지 못하는 것이다.
Recyclerview는 binding -> recyclerview의 참조도 있지만, Fragment->adapter ->recyclerview의 참조도 가지고 있다고 생각했다. 따라서 binding을 null처리해도 Fragment -> adapter -> recyclerview를 가지고 있기 때문에 memory leak이 발생한다.
이 메모리릭을 해결하기 위해서는 방법1
override fun onDestroyView() {
super.onDestroyView()
binding.rvSearch.adapter = null // leak canary로 발견한 메모리 릭 해결
}
위의 코드를 작성해주는 것이다.
뭔가 이상한게 위의 코드는 recyclerview -> adapter의 참조를 제거하는 것이 아닌가 싶었다.
위의 게시글을 확인해보면 아래와 같이 써있다.
Actually, I was surprised that this approach works. Even if you null out the reference from RecyclerView to adapter, as long as the adapter has a reference to RecyclerView, you still have circular reference. The only way I can comprehend is that Android actually nulls out the reference from adapter to the RecyclerView as well when you null out the reverse reference, thereby eliminating the circular reference entirely.
해석을 해보자면 RecyclerView에서 adapter의 대한 참조를 무효화하더라도 adapter가 RecyclerView에 대한 참조가 남아있어서 순환참조가 있다. 위 코드를 해결되는 방법에 대해서 이해할 수 있는 유일한 방법은 Android까 recyclerview -> adapter의 참조를 null로 만들때, adapter에서 recyclerview의 참조도 실제로 null로 만들어서 참조를 제거한다는 것이다. 라고 이야기한다 .
순환 참조 ??
위의 블로그 저자에 의하면 위와 같은 참조를 가진다고 말한다.
블로그에서 LeakCanary에서 위와 같은 참조 구조를 보여준다. 2년전 글이라 현재는 LeakCanary가 업데이트가 되서 그런가 나는 MainActivity까지의 참조구조를 볼 수는 없었다.
Fragment는 Context를 가지지 않기 때문에, 상위 activity의 context를 가져와야한다. Recyclerview가 가진 context가 MainActivity이기 때문에 MainActivity에 참조를 가지는 것이라고 이해했다.
따라서 onDestroyView()에서 adapter를 null처리해준다면, 순환참조가 제거되기 때문에 메모리릭을 해결할 수 있다고 한다.
이 방법말고도 다른 방법도 있다.
Adapter를 onViewCreated에 선언하기 방법2
이 방법을 사용했을 때, adapter를 null 처리해줄 필요없다.
class SearchFragment :Fragment() {
private var _binding : SearchFragmentBinding?= null
private val binding get() = _binding!!
override fun onCreateView(
inflater : LayoutInflater,
container : ViewGroup?,
savedInstanceState: Bundle?
) : View? {
_binding = SearchFragmentBinding.inflate(inflater, container, false)
val view = binding.root
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = SearchAdapter(this@SearchFragment) }
binding.rvSearch.adapter = adapter
}
override fun onDestroyView(){
super.onDestroyView()
}
}
이 방법을 사용했을 때, adapter가 onViewCreated가 일어날 때마다( 회전이라 등등 ), 새로 생성되어서 상태를 유지 할 수 없지 않냐라고 생각이 든다.
https://medium.com/androiddevelopers/restore-recyclerview-scroll-position-a8fbdc9a9334
Recyclerview 1.2.0-alpha02 버전부터 새로운 변경되어서 이제 state를 유지할 수 있나보다.
(2020년 4월 1일날 변경되었다는데 몰랐다).
viewAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
이제 어뎁터에 옵션이 3개가 주어진다.
1. ALLOW : 항상 현재 어뎁터의 상태를 저장한다. Default.
2. PREVENT_WHEN_EMPTY : 어뎁터의 아이템이 1개이상일때만 상태를 저장하고 복구
3. PREVENT : 다른 옵션을 주지 않는 한 현재 어뎁터를 저장하지 않는다.
https://realapril.tistory.com/48
위와 같은 코드를 작성 시에 Fragment -> Adapter 참조가 제거된다.
따라서 binding -> Recyclerview <-> Adapter의 참조관계를 가지기 때문에
onDestroyView()에서 recyclerview를 참조하고 있는 binding을 null 처리해줌으로써 gc가 수집할 수 있게 된다.
혼자 이해해볼려고 한 글이기 때문에
틀린 부분에 대해서 알려주시면 너무나 감사할 것 같다
'Android' 카테고리의 다른 글
Activity (0) | 2023.02.22 |
---|---|
Android에서 잠금화면 만들어보자 (0) | 2023.01.19 |
Notification을 수신해보자! (0) | 2022.12.08 |
LiveData를 뜯어보자 (0) | 2022.11.23 |
Coroutine이 무엇일까요? (0) | 2022.11.10 |