LeakCanary를 적용해서 메모리 누수를 해결하자
Search
🥻

LeakCanary를 적용해서 메모리 누수를 해결하자

생성일
2021/09/28 09:10
태그

LeakCanary?

안드로이드 앱에서 발생하는 메모리 누수를 감지해주는 라이브러리다. 메모리 누수가 누적 발생하게 되면 앱의 성능과 사용성이 떨어지게 된다.
메모리 누수는 주로 필요 없는 객체는 해제시켜 메모리 회수를 해야하는데, 이를 계속 참조하면서 발생하게 된다.

LeakCanary 추가

debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
Kotlin
복사
gradle에 leakCanary 2.7을 추가한다.
빌드를 해보면 Leaks라는 귀여운 앱이 같이 깔린다.
앱을 사용하다 보면 Leaks 앱에 차곡차곡 쌓인다.
Leak이 발생하게 되면 Android Studio Logcat에 바로 표시가 되는데
D/LeakCanary: Found 2 objects retained, not dumping heap yet (app is visible & < 5 threshold)
Kotlin
복사
뭔가 2개가 발생했다고 한다. 폰에도 알림이 같이 쌓이는데 "2 ratained objects, tap to dump heal" 이런식으로 표시된다. 이 알림을 터치하면 상단 이미지에 보이는 [Dump Heap Now] 버튼과 같은 역할을 하는데, Android Studio Logcat을 보면
D/LeakCanary: D/LeakCanary: ==================================== D/LeakCanary: HEAP ANALYSIS RESULT D/LeakCanary: ==================================== D/LeakCanary: 2 APPLICATION LEAKS D/LeakCanary: References underlined with "~~~" are likely causes. D/LeakCanary: Learn more at https://squ.re/leaks. D/LeakCanary: 2794 bytes retained by leaking objects D/LeakCanary: Signature: c035c3809c96df4027ca1a434041f9edcfeacd97 D/LeakCanary: ┬─── D/LeakCanary: │ GC Root: System class D/LeakCanary: │ D/LeakCanary: ├─ android.provider.FontsContract class D/LeakCanary: │ Leaking: NO (SingVanaApplication↓ is not leaking and a class is never leaking) D/LeakCanary: │ ↓ static FontsContract.sContext D/LeakCanary: ├─ com.singvana.app.SingVanaApplication instance D/LeakCanary: │ Leaking: NO (SongsFragment↓ is not leaking and Application is a singleton) D/LeakCanary: │ mBase instance of android.app.ContextImpl D/LeakCanary: │ ↓ Hilt_SingVanaApplication.componentManager D/LeakCanary: ├─ dagger.hilt.android.internal.managers.ApplicationComponentManager instance D/LeakCanary: │ Leaking: NO (SongsFragment↓ is not leaking) D/LeakCanary: │ ↓ ApplicationComponentManager.component D/LeakCanary: ├─ com.singvana.app.DaggerSingVanaApplication_HiltComponents_SingletonC instance D/LeakCanary: │ Leaking: NO (SongsFragment↓ is not leaking) D/LeakCanary: │ ↓ DaggerSingVanaApplication_HiltComponents_SingletonC.horizontalContainerRepository D/LeakCanary: ├─ com.singvana.app.ui.common.HorizontalContainerRepository instance D/LeakCanary: │ Leaking: NO (SongsFragment↓ is not leaking) D/LeakCanary: │ ↓ HorizontalContainerRepository.onTouch D/LeakCanary: ├─ com.singvana.app.ui.main.songs.SongsFragment$initTabPager$2 instance D/LeakCanary: │ Leaking: NO (SongsFragment↓ is not leaking) D/LeakCanary: │ Anonymous subclass of kotlin.jvm.internal.Lambda D/LeakCanary: │ ↓ SongsFragment$initTabPager$2.this$0 D/LeakCanary: ├─ com.singvana.app.ui.main.songs.SongsFragment instance D/LeakCanary: │ Leaking: NO (Fragment#mFragmentManager is not null) D/LeakCanary: │ componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, D/LeakCanary: │ wrapping activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ SongsFragment.tabPager$delegate D/LeakCanary: │ ~~~~~~~~~~~~~~~~~ D/LeakCanary: ├─ kotlin.SynchronizedLazyImpl instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 63.4 kB in 969 objects D/LeakCanary: │ ↓ SynchronizedLazyImpl._value D/LeakCanary: │ ~~~~~~ D/LeakCanary: ├─ com.singvana.app.util.TabPager instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 63.4 kB in 968 objects D/LeakCanary: │ ↓ TabPager.tabLayout D/LeakCanary: │ ~~~~~~~~~ D/LeakCanary: ├─ com.google.android.material.tabs.TabLayout instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 19.4 kB in 303 objects D/LeakCanary: │ View not part of a window view hierarchy D/LeakCanary: │ View.mAttachInfo is null (view detached) D/LeakCanary: │ View.mID = R.id.tabLayout D/LeakCanary: │ View.mWindowAttachCount = 1 D/LeakCanary: │ mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping D/LeakCanary: │ activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ View.mParent D/LeakCanary: │ ~~~~~~~ D/LeakCanary: ├─ androidx.constraintlayout.widget.ConstraintLayout instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 20.0 kB in 274 objects D/LeakCanary: │ View not part of a window view hierarchy D/LeakCanary: │ View.mAttachInfo is null (view detached) D/LeakCanary: │ View.mID = R.id.tab_pager D/LeakCanary: │ View.mWindowAttachCount = 1 D/LeakCanary: │ mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping D/LeakCanary: │ activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ View.mParent D/LeakCanary: │ ~~~~~~~ D/LeakCanary: ╰→ androidx.constraintlayout.widget.ConstraintLayout instance D/LeakCanary: Leaking: YES (ObjectWatcher was watching this because com.singvana.app.ui.main.songs.SongsFragment received D/LeakCanary: Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks)) D/LeakCanary: Retaining 2.8 kB in 66 objects D/LeakCanary: key = b7c8dbb9-883a-4a7a-88a0-535c901cebb7 D/LeakCanary: watchDurationMillis = 172013 D/LeakCanary: retainedDurationMillis = 167011 D/LeakCanary: View not part of a window view hierarchy D/LeakCanary: View.mAttachInfo is null (view detached) D/LeakCanary: View.mWindowAttachCount = 1 D/LeakCanary: mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping D/LeakCanary: activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: 3294 bytes retained by leaking objects D/LeakCanary: Signature: 1f677cb987fbf67c1fe3a8b879f5878ece297bd D/LeakCanary: ┬─── D/LeakCanary: │ GC Root: System class D/LeakCanary: │ D/LeakCanary: ├─ android.app.ActivityThread class D/LeakCanary: │ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking) D/LeakCanary: │ ↓ static ActivityThread.sCurrentActivityThread D/LeakCanary: ├─ android.app.ActivityThread instance D/LeakCanary: │ Leaking: NO (MainActivity↓ is not leaking) D/LeakCanary: │ mInitialApplication instance of com.singvana.app.SingVanaApplication D/LeakCanary: │ mSystemContext instance of android.app.ContextImpl D/LeakCanary: │ mSystemUiContext instance of android.app.ContextImpl D/LeakCanary: │ ↓ ActivityThread.mTopActivityClient D/LeakCanary: ├─ android.app.ActivityThread$ActivityClientRecord instance D/LeakCanary: │ Leaking: NO (MainActivity↓ is not leaking) D/LeakCanary: │ activity instance of com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ ActivityThread$ActivityClientRecord.activity D/LeakCanary: ├─ com.singvana.app.ui.main.MainActivity instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking and Activity#mDestroyed is false) D/LeakCanary: │ mApplication instance of com.singvana.app.SingVanaApplication D/LeakCanary: │ mBase instance of androidx.appcompat.view.ContextThemeWrapper D/LeakCanary: │ ↓ ComponentActivity.mActivityResultRegistry D/LeakCanary: ├─ androidx.activity.ComponentActivity$2 instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ Anonymous subclass of androidx.activity.result.ActivityResultRegistry D/LeakCanary:this$0 instance of com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ ActivityResultRegistry.mKeyToCallback D/LeakCanary: ├─ java.util.HashMap instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ ↓ HashMap.table D/LeakCanary: ├─ java.util.HashMap$Node[] array D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ ↓ HashMap$Node[].[8] D/LeakCanary: ├─ java.util.HashMap$Node instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ ↓ HashMap$Node.value D/LeakCanary: ├─ androidx.activity.result.ActivityResultRegistry$CallbackAndContract instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ ↓ ActivityResultRegistry$CallbackAndContract.mCallback D/LeakCanary: ├─ androidx.fragment.app.FragmentManager$10 instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ Anonymous class implementing androidx.activity.result.ActivityResultCallback D/LeakCanary: │ ↓ FragmentManager$10.this$0 D/LeakCanary: ├─ androidx.fragment.app.FragmentManagerImpl instance D/LeakCanary: │ Leaking: NO (SongsDetailFragment↓ is not leaking) D/LeakCanary: │ ↓ FragmentManager.mParent D/LeakCanary: ├─ com.singvana.app.ui.main.songs.SongsDetailFragment instance D/LeakCanary: │ Leaking: NO (Fragment#mFragmentManager is not null) D/LeakCanary: │ componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, D/LeakCanary: │ wrapping activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ Fragment.mTag=f0 D/LeakCanary: │ ↓ SongsDetailFragment.horizontalContainerAdapter$delegate D/LeakCanary: │ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ D/LeakCanary: ├─ kotlin.SynchronizedLazyImpl instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 20 B in 1 objects D/LeakCanary: │ ↓ SynchronizedLazyImpl._value D/LeakCanary: │ ~~~~~~ D/LeakCanary: ├─ com.singvana.app.ui.common.HorizontalContainerAdapter instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 288 B in 15 objects D/LeakCanary: │ ↓ HorizontalContainerAdapter.onScrollToPosition D/LeakCanary: │ ~~~~~~~~~~~~~~~~~~ D/LeakCanary: ├─ com.singvana.app.ui.common.HorizontalContainerAdapter$Holder$1 instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 16 B in 1 objects D/LeakCanary: │ Anonymous subclass of kotlin.jvm.internal.Lambda D/LeakCanary: │ ↓ HorizontalContainerAdapter$Holder$1.this$0 D/LeakCanary: │ ~~~~~~ D/LeakCanary: ├─ com.singvana.app.ui.common.HorizontalContainerAdapter$Holder instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 120 B in 2 objects D/LeakCanary: │ ↓ RecyclerView$ViewHolder.mOwnerRecyclerView D/LeakCanary: │ ~~~~~~~~~~~~~~~~~~ D/LeakCanary: ├─ androidx.recyclerview.widget.RecyclerView instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 32.3 kB in 532 objects D/LeakCanary: │ View not part of a window view hierarchy D/LeakCanary: │ View.mAttachInfo is null (view detached) D/LeakCanary: │ View.mID = R.id.recyclerView D/LeakCanary: │ View.mWindowAttachCount = 1 D/LeakCanary: │ mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping D/LeakCanary: │ activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ View.mParent D/LeakCanary: │ ~~~~~~~ D/LeakCanary: ├─ androidx.swiperefreshlayout.widget.SwipeRefreshLayout instance D/LeakCanary: │ Leaking: UNKNOWN D/LeakCanary: │ Retaining 28.0 kB in 418 objects D/LeakCanary: │ View not part of a window view hierarchy D/LeakCanary: │ View.mAttachInfo is null (view detached) D/LeakCanary: │ View.mID = R.id.refreshLayout D/LeakCanary: │ View.mWindowAttachCount = 1 D/LeakCanary: │ mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping D/LeakCanary: │ activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: │ ↓ View.mParent D/LeakCanary: │ ~~~~~~~ D/LeakCanary: ╰→ androidx.constraintlayout.widget.ConstraintLayout instance D/LeakCanary: Leaking: YES (ObjectWatcher was watching this because com.singvana.app.ui.main.songs.SongsDetailFragment received D/LeakCanary: Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks)) D/LeakCanary: Retaining 3.3 kB in 54 objects D/LeakCanary: key = 9ee99d31-d7cf-4321-bf93-5406bfef78b9 D/LeakCanary: watchDurationMillis = 172014 D/LeakCanary: retainedDurationMillis = 167011 D/LeakCanary: View not part of a window view hierarchy D/LeakCanary: View.mAttachInfo is null (view detached) D/LeakCanary: View.mWindowAttachCount = 1 D/LeakCanary: mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping D/LeakCanary: activity com.singvana.app.ui.main.MainActivity with mDestroyed = false D/LeakCanary: ==================================== D/LeakCanary: 0 LIBRARY LEAKS D/LeakCanary: A Library Leak is a leak caused by a known bug in 3rd party code that you do not have control over. D/LeakCanary: See https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/#4-categorizing-leaks D/LeakCanary: ==================================== D/LeakCanary: 0 UNREACHABLE OBJECTS D/LeakCanary: An unreachable object is still in memory but LeakCanary could not find a strong reference path D/LeakCanary: from GC roots. D/LeakCanary: ==================================== D/LeakCanary: METADATA D/LeakCanary: Please include this in bug reports and Stack Overflow questions. D/LeakCanary: Build.VERSION.SDK_INT: 30 D/LeakCanary: Build.MANUFACTURER: samsung D/LeakCanary: LeakCanary version: 2.7 D/LeakCanary: App process name: com.singvana.app D/LeakCanary: Stats: LruCache[maxSize=3000,hits=7349,misses=144157,hitRate=4%] D/LeakCanary: RandomAccess[bytes=7738904,reads=144157,travel=85703839598,range=44141375,size=54828669] D/LeakCanary: Heap dump reason: user request D/LeakCanary: Analysis duration: 7300 ms D/LeakCanary: Heap dump file path: /storage/emulated/0/Download/leakcanary-com.singvana.app/2021-10-01_14-52-06_637.hprof D/LeakCanary: Heap dump timestamp: 1633067536774 D/LeakCanary: Heap dump duration: 2418 ms D/LeakCanary: ====================================
Kotlin
복사
저어어어엉말 길게 나오는데 위에서 부터 다 볼 필요는 없고 아래쪽에 Leaking: YES가 표시된 부분을 살펴보자 SongsDetailFragment 내의 무엇인가 때문에 메모리 누수가 발생했는데, 이 로그만 보고 정확한 원인을 알 수 없다. 스스로 찾아야하는데, 보통 어떤 경우에서 메모리 누수가 발생하는지 찾아야한다.

주로 발생하는 메모리 누수

뭘 잘못했는지 모르겠지만 메모리 누수가 발생했다고 귀여운 새가 자꾸만(?) 알려준다. 주로 발생한 메모리 누수와 해결 방법에 대해 설명하려 한다.

1. DataBinding(ViewBinding)

공식문서를 살펴보면
onDestroyView()에서 binding을 null처리 해줘야한다.

2. RecyclerView or ViewPager2

override fun onDestroyView() { recyclerView.adapter = null super.onDestroyView() }
Kotlin
복사

3. 그 외에도 메모리 누수가 발생한다면?

정확히 어디서 발생하는지 원인을 알 수가 없었다.
tabLayoutMediator.detach() viewPager.adapter = null tabLayout.removeOnTabSelectedListener(tabOnTabSelectedListener) tabLayout.clearOnTabSelectedListeners() viewPager.unregisterOnPageChangeCallback(onPageChangeCallback) viewPager.clearDisappearingChildren() tabPager.rootLayout.removeAllViews() rootLayout.removeAllViews()
Kotlin
복사
viewPager 같은 경우에는 아래 내용처럼 싹 정리해줬다.
var onClick: ((CollaboDetail) -> Unit)? = null var onClickSing: ((CollaboDetail) -> Unit)? = null var onClickCollaboCount: ((CollaboDetail) -> Unit)? = null var onClickTrash: ((CollaboDetail, Int) -> Unit)? = null var onClickProfile: ((CollaboDetail) -> Unit)? = null
Kotlin
복사
코틀린 고차함수를 사용하면서도 메모리 누수가 발생했다.
recyclerView.adapter = null rootLayout = removeAllViews() adapter.clearFunctions() /*adapter.clearFunctions() onClick = null onClickSing = null onClickCollaboCount = null onClickTrash = null onClickProfile = null */ player.clearMediaItems() player.release() /* player = SimpleExoPlayer.Builder(requireContext()).build() */
Kotlin
복사
그래서.. 이것도 싹 정리했고, ExoPlayer도 사용하고 있었는데 이것도 역시 싹 정리했다. (원인 중 하나)

정리

Google의 샘플 프로젝트(architecture-components-samples)에 보면 AutoClearedValue라는 class가 있다.
import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.singvana.app.remote.CollaboDetail import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty class AutoClearedValue<T : Any>(val fragment: Fragment) : ReadWriteProperty<Fragment, T> { private var _value: T? = null init { fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> viewLifecycleOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { _value = null } }) } } }) } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { return _value ?: throw IllegalStateException("should never call auto-cleared-value get when it might not be available") } override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { _value = value } } /** * Creates an [AutoClearedValue] associated with this fragment. */ fun <T : Any> Fragment.autoCleared() = AutoClearedValue<T>(this)
Kotlin
복사
이런 형태의 클래스이다. binding이 알아서 null 처리되는 것은 좋지만 위에서 설명해온 것 처럼 다른 뷰나 객체에 대한 처리도 같이 해주고 싶어 약간 변경을 했다.
AutoClearedValue.kt
class AutoClearedValue<T : Any>(val fragment: Fragment, var onDestroy: (T) -> Unit) : ReadWriteProperty<Fragment, T> { private var _value: T? = null init { fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onCreate(owner: LifecycleOwner) { fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> viewLifecycleOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { _value?.let { onDestroy.invoke(it) } _value = null } }) } } }) } override fun getValue(thisRef: Fragment, property: KProperty<*>): T { return _value ?: throw IllegalStateException("should never call auto-cleared-value get when it might not be available") } override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) { _value = value } } /** * Creates an [AutoClearedValue] associated with this fragment. */ fun <T : Any> Fragment.autoCleared(onDestroy : (T)->Unit) = AutoClearedValue<T>(this, onDestroy)
Kotlin
복사
BaseFragment.kt
abstract class BaseFragment<B : ViewDataBinding>(@LayoutRes val layoutId: Int) : Fragment() { protected var binding by autoCleared<B>(onDestroy = { onDestroyBindingView(it) }) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = DataBindingUtil.inflate(inflater,layoutId, container, false) return binding.root } final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.lifecycleOwner = this onViewCreated(savedInstanceState) } . . .
Kotlin
복사
UsingFragment.kt (가명)
class UsingFragment : BaseFragment<FragmenUsingBinding>(R.layout.fragment_using) { . . . override fun onDestroyBindingView(b: FragmenUsingBinding) { player.clearMediaItems() player.release() b.recyclerView.adapter = null b.rootLayout.removeAllViews() singWithMeAdapter.clearFunctions() collaboAdapter.clearFunctions() } }
Kotlin
복사

참고