I'm using LeakCanary to detect leaks in the app I'm working on. And I have no idea where this one comes from. What might ExternalSyntheticLambda1 refer to? I used binding first, and I decided to remove that, since SwipeRefreshLayout leaked. It looks like removing the binding and going old school worked, however now I get a new error that I don't understand. Help? Thanks
┬───
│ GC Root: Input or output parameters in native code
│
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never
│ leaking)
│ ↓ ClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (InternalLeakCanary↓ is not leaking)
│ ↓ Object[36]
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
com.app.MainActivity instance
│ Leaking: NO (Activity#mDestroyed is false)
│ mApplication instance of com.app.Application
│ mBase instance of androidx.appcompat.view.ContextThemeWrapper
│ ↓ MainActivity.userViewModel$delegate
│ ~~~~~~~~~~~~~~~~~~~~~~
├─ kotlin.SynchronizedLazyImpl instance
│ Leaking: UNKNOWN
│ Retaining 20 B in 1 objects
│ ↓ SynchronizedLazyImpl._value
│ ~~~~~~
├─ com.app.user.UserViewModel instance
│ Leaking: UNKNOWN
│ Retaining 4.4 kB in 103 objects
│ ↓ UserViewModel._userId
│ ~~~~~~~
├─ androidx.lifecycle.MutableLiveData instance
│ Leaking: UNKNOWN
│ Retaining 427 B in 19 objects
│ ↓ LiveData.mObservers
│ ~~~~~~~~~~
├─ androidx.arch.core.internal.SafeIterableMap instance
│ Leaking: UNKNOWN
│ Retaining 368 B in 16 objects
│ ↓ SafeIterableMap[key()]
│ ~~~~~~~
├─ com.app.today.habitday.
│ HabitDayFragment$$ExternalSyntheticLambda1 instance
│ Leaking: UNKNOWN
│ Retaining 12 B in 1 objects
│ ↓ HabitDayFragment$$ExternalSyntheticLambda1.f$0
│ ~~~
╰→ com.app.today.habitday.HabitDayFragment instance
Leaking: YES (ObjectWatcher was watching this because com.app.today.habitday.HabitDayFragment received Fragment#onDestroy()
callback and Fragment#mFragmentManager is null)
Retaining 48.7 kB in 805 objects
key = 31c4bca9-d5f5-478d-b9cd-e4f826d3764c
watchDurationMillis = 5699
retainedDurationMillis = 699
METADATA
Build.VERSION.SDK_INT: 31
Build.MANUFACTURER: samsung
LeakCanary version: 2.8.1
App process name: com.app
Stats: LruCache[maxSize=3000,hits=145238,misses=327470,hitRate=30%]
RandomAccess[bytes=24498778,reads=327470,travel=129136265851,range=54226345,size
=65429917]
Analysis duration: 22458 ms
I think I'm already following all the best practices with view models and observing.
class UserViewModel(private val repository: repository,
private val chatRepository: ChatRepository,
private val crashlyticsHelper: CrashlyticsHelper,
private val systemMemoryHelper: SystemMemoryHelper) : ViewModel(), EventListener {
...
private var _userId = MutableLiveData<String>()
val userId: LiveData<String> = _userId
...
override fun onCleared() {
super.onCleared()
// Clearing listeners
chatRepository.unregisterListener(this)
}
class HabitDayFragment: Fragment(R.layout.fragment_habit_day) {
private val repository: Repository by inject()
private val analyticsHelper: AnalyticsHelper by inject()
private val userViewModel: UserViewModel by inject()
...
companion object {
fun instance(localDate: LocalDate): HabitDayFragment {
val data = Bundle()
data.putString(DATE_BUNDLE_STRING, DateUtils.localDateToString(localDate))
return HabitDayFragment().apply {
arguments = data
}
}
}
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
habitDayViewModel = ViewModelProvider(this, HabitDayViewModelFactory(repository)).get(HabitDayViewModel::class.java)
habitDayViewModel?.updateDate(localDate)
// Using lifecycleOwner here
userViewModel.userId.observe(viewLifecycleOwner, Observer {
habitDayViewModel?.setUserId(it)
})
...
// Also using lifecycle owner here, and repeat on lifecycle.
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
habitDayViewModel?.uiState?.distinctUntilChanged
....
Turns out, it was ViewPager2 that was causing the issues by not calling onDestroyView. Fixed it by going to the good ol' ViewPager v1.
More here: Fragment lifecylce behaviour using ViewPager2 and FragmentStateAdapter
Related
Do you guys have any suggestion what can cause this memory leak that I got from the Leak Canary?
Below is the description I am getting from the report.
Steps to reproduce are, In the Settings menu, click on the switch which will perform the theme change, after this action, leaking happening. Below this I will provide the code piece from the SettingsFragmentClass. Any help would be most welcomed guys.
Basically issue is happening when this line is being called.
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
┬───
│ GC Root: Global variable in native code
│
├─ android.graphics.animation.RenderNodeAnimator instance
│ Leaking: UNKNOWN
│ Retaining 1.1 MB in 16344 objects
│ ↓ RenderNodeAnimator.mTarget
│ ~~~~~~~
├─ android.graphics.RenderNode instance
│ Leaking: UNKNOWN
│ Retaining 1.1 MB in 16274 objects
│ ↓ RenderNode.mHostView
│ ~~~~~~~~~
├─ com.google.android.material.switchmaterial.SwitchMaterial instance
│ Leaking: YES (View.mContext references a destroyed activity)
│ Retaining 1.1 MB in 16272 objects
│ View not part of a window view hierarchy
│ View.mAttachInfo is null (view detached)
│ View.mID = R.id.null
│ View.mWindowAttachCount = 1
│ mContext instance of com.flixeron.my_passkeeper.main.MainActivity with
│ mDestroyed = true
│ ↓ View.mContext
╰→ com.flixeron.my_passkeeper.main.MainActivity instance
Leaking: YES (ObjectWatcher was watching this because com.flixeron.
my_passkeeper.main.MainActivity received Activity#onDestroy() callback
and Activity#mDestroyed is true)
Retaining 251.2 kB in 5956 objects
key = 3c074833-b927-4971-b2b5-0a77b9e68bde
watchDurationMillis = 5216
retainedDurationMillis = 215
mApplication instance of com.flixeron.my_passkeeper.main.MainApplication
mBase instance of androidx.appcompat.view.ContextThemeWrapper
METADATA
Build.VERSION.SDK_INT: 31
Build.MANUFACTURER: Xiaomi
LeakCanary version: 2.7
App process name: com.flixeron.my_passkeeper
Stats: LruCache[maxSize=3000,hits=6345,misses=185335,hitRate=3%]
RandomAccess[bytes=9694284,reads=185335,travel=115234430872,range=38720254,size=
46574102]
Heap dump reason: 7 retained objects, app is visible
Analysis duration: 15049 ms
Fragment Class
class SettingsFragment : Fragment() {
private val cardViewModel: CardViewModel by viewModel()
private var bindingProp: FragmentSettingsBinding? = null
private val binding get() = bindingProp!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
bindingProp = FragmentSettingsBinding.inflate(inflater, container, false)
setTextLockTime()
configureScreenShoots()
themeSettings()
// changing dark/light mode
binding.switchTheme.setOnClickListener {
if (!PreferenceProvider.darkMode) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
PreferenceProvider.darkMode = true
binding.imageViewTheme.setImageResource(R.drawable.ic_dark_mode)
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
PreferenceProvider.darkMode = false
binding.imageViewTheme.setImageResource(R.drawable.ic_light_mode)
}
}
override fun onDestroyView() {
super.onDestroyView()
bindingProp = null
}
}
Xml file
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/switchTheme"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="30dp"
app:layout_constraintBottom_toBottomOf="#+id/textViewChangeTheme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="#+id/textViewChangeTheme">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="#+id/switchTheme1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false"
app:layout_constraintBottom_toBottomOf="#+id/switchTheme"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="#+id/switchTheme"
android:scaleX="1.3"
android:scaleY="1.3"/>
</androidx.constraintlayout.widget.ConstraintLayout>
Updated leak canary
┬───
│ GC Root: System class
│
├─ android.view.inputmethod.InputMethodManager class
│ Leaking: NO (InputMethodManager↓ is not leaking and a class is never
│ leaking)
│ ↓ static InputMethodManager.sInstance
├─ android.view.inputmethod.InputMethodManager instance
│ Leaking: NO (DecorView↓ is not leaking and InputMethodManager is a
│ singleton)
│ ↓ InputMethodManager.mCurRootView
├─ android.view.ViewRootImpl instance
│ Leaking: NO (DecorView↓ is not leaking)
│ mContext instance of com.android.internal.policy.DecorContext, wrapping
│ activity com.flixeron.my_passkeeper.main.MainActivity with mDestroyed =
│ false
│ ViewRootImpl#mView is not null
│ mWindowAttributes.mTitle = "com.flixeron.my_passkeeper/com.flixeron.
│ my_passkeeper.main.MainActivity"
│ mWindowAttributes.type = 1
│ ↓ ViewRootImpl.mView
├─ com.android.internal.policy.DecorView instance
│ Leaking: NO (View attached)
│ View is part of a window view hierarchy
│ View.mAttachInfo is not null (view attached)
│ View.mWindowAttachCount = 1
│ mContext instance of com.android.internal.policy.DecorContext, wrapping
│ activity com.flixeron.my_passkeeper.main.MainActivity with mDestroyed =
│ false
│ ↓ View.mResources
│ ~~~~~~~~~~
├─ dev.b3nedikt.app_locale.AppLocaleResources instance
│ Leaking: UNKNOWN
│ Retaining 1.9 kB in 55 objects
│ context instance of androidx.appcompat.view.ContextThemeWrapper
│ ↓ AppLocaleResources.context
│ ~~~~~~~
├─ androidx.appcompat.view.ContextThemeWrapper instance
│ Leaking: UNKNOWN
│ Retaining 274.9 kB in 6390 objects
│ mBase instance of io.github.inflationx.viewpump.ViewPumpContextWrapper
│ ContextThemeWrapper does not wrap a known Android context
│ ↓ ContextWrapper.mBase
│ ~~~~~
├─ io.github.inflationx.viewpump.ViewPumpContextWrapper instance
│ Leaking: UNKNOWN
│ Retaining 270.3 kB in 6242 objects
│ mBase instance of dev.b3nedikt.app_locale.AppLocaleContextWrapper
│ ViewPumpContextWrapper does not wrap a known Android context
│ ↓ ContextWrapper.mBase
│ ~~~~~
├─ dev.b3nedikt.app_locale.AppLocaleContextWrapper instance
│ Leaking: UNKNOWN
│ Retaining 270.2 kB in 6236 objects
│ mBase instance of android.app.ContextImpl
│ AppLocaleContextWrapper does not wrap a known Android context
│ ↓ ContextWrapper.mBase
│ ~~~~~
├─ android.app.ContextImpl instance
│ Leaking: YES (ContextImpl.mOuterContext is an instance of com.flixeron.
│ my_passkeeper.main.MainActivity with Activity.mDestroyed true)
│ Retaining 268.1 kB in 6175 objects
│ mAutofillClient instance of com.flixeron.my_passkeeper.main.MainActivity
│ with mDestroyed = true
│ mOuterContext instance of com.flixeron.my_passkeeper.main.MainActivity
│ with mDestroyed = true
│ ↓ ContextImpl.mAutofillClient
╰→ com.flixeron.my_passkeeper.main.MainActivity instance
Leaking: YES (ObjectWatcher was watching this because com.flixeron.
my_passkeeper.main.MainActivity received Activity#onDestroy() callback
and Activity#mDestroyed is true)
Retaining 264.2 kB in 6091 objects
key = 44a30066-31c8-4abb-8660-907035d18457
watchDurationMillis = 5229
retainedDurationMillis = 224
mApplication instance of com.flixeron.my_passkeeper.main.MainApplication
mBase instance of androidx.appcompat.view.ContextThemeWrapper
METADATA
Build.VERSION.SDK_INT: 31
Build.MANUFACTURER: Xiaomi
LeakCanary version: 2.7
App process name: com.flixeron.my_passkeeper
Count of retained yet cleared: 1 KeyedWeakReference instances
Stats: LruCache[maxSize=3000,hits=3959,misses=160418,hitRate=2%]
RandomAccess[bytes=8403631,reads=160418,travel=86543130456,range=36387679,size=4
4147138]
Heap dump reason: 6 retained objects, app is visible
Analysis duration: 11869 ms```
your leak is strictly related to GUI, which gets restared when user change theme with your option
conider releasing GUI-related objects by releasing binded Views when Fragment gets destroyed (and further/soon will be recreated with new theme)
override fun onDestroyView() {
bindingProp = null
super.onDestroyView()
}
(The whole code can be found here (without the leakCanary dependencies): https://github.com/Dawwit0001/HiltMultiModule)
I created 2 fragments, a login fragment and a register fragment, whenever the user opens the app, the login screen is displayed
if the user navigates to the register screen, creates an account and then navigates back to the login screen a leak happens. I am not sure why is that, but I discovered that when I replace the "savedInstanceState" in the login fragment with null (inside onViewCreated), it does not happen.
The whole leak:
┬───
│ GC Root: Input or output parameters in native code
│
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never
│ leaking)
│ ↓ ClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (InternalLeakCanary↓ is not leaking)
│ ↓ Object[728]
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
├─ winged.example.hiltmultimodule.MainActivity instance
│ Leaking: NO (RegisterFragment↓ is not leaking and Activity#mDestroyed is
│ false)
│ mApplication instance of winged.example.hiltmultimodule.di.
│ HiltMultiModuleApplication
│ mBase instance of androidx.appcompat.view.ContextThemeWrapper
│ ↓ ComponentActivity.mOnConfigurationChangedListeners
├─ java.util.concurrent.CopyOnWriteArrayList instance
│ Leaking: NO (RegisterFragment↓ is not leaking)
│ ↓ CopyOnWriteArrayList[4]
├─ androidx.fragment.app.FragmentManager$$ExternalSyntheticLambda0 instance
│ Leaking: NO (RegisterFragment↓ is not leaking)
│ ↓ FragmentManager$$ExternalSyntheticLambda0.f$0
├─ androidx.fragment.app.FragmentManagerImpl instance
│ Leaking: NO (RegisterFragment↓ is not leaking)
│ ↓ FragmentManager.mParent
├─ winged.example.feature_login.register.RegisterFragment instance
│ Leaking: NO (Fragment#mFragmentManager is not null)
│ componentContext instance of dagger.hilt.android.internal.managers.
│ ViewComponentManager$FragmentContextWrapper, wrapping activity winged.
│ example.hiltmultimodule.MainActivity with mDestroyed = false
│ ↓ Fragment.mSavedViewState
│ ~~~~~~~~~~~~~~~
├─ android.util.SparseArray instance
│ Leaking: UNKNOWN
│ Retaining 417.7 kB in 4154 objects
│ ↓ SparseArray.mValues
│ ~~~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 417.6 kB in 4152 objects
│ ↓ Object[9]
│ ~~~
├─ android.widget.TextView$SavedState instance
│ Leaking: UNKNOWN
│ Retaining 416.1 kB in 4113 objects
│ ↓ TextView$SavedState.text
│ ~~~~
├─ android.text.SpannableStringBuilder instance
│ Leaking: UNKNOWN
│ Retaining 416.0 kB in 4109 objects
│ ↓ SpannableStringBuilder.mSpans
│ ~~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 36 B in 1 objects
│ ↓ Object[0]
│ ~~~
├─ android.text.method.PasswordTransformationMethod$Visible instance
│ Leaking: UNKNOWN
│ Retaining 415.4 kB in 4099 objects
│ ↓ PasswordTransformationMethod$Visible.mText
│ ~~~~~
├─ androidx.emoji2.text.SpannableBuilder instance
│ Leaking: UNKNOWN
│ Retaining 415.4 kB in 4098 objects
│ ↓ SpannableStringBuilder.mSpans
│ ~~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 76 B in 1 objects
│ ↓ Object[0]
│ ~~~
├─ android.widget.TextView$ChangeWatcher instance
│ Leaking: UNKNOWN
│ Retaining 16 B in 1 objects
│ ↓ TextView$ChangeWatcher.this$0
│ ~~~~~~
├─ com.google.android.material.textfield.TextInputEditText instance
│ Leaking: UNKNOWN
│ Retaining 410.0 kB in 3980 objects
│ View not part of a window view hierarchy
│ View.mAttachInfo is null (view detached)
│ View.mID = R.id.repeatPasswordTIET
│ View.mWindowAttachCount = 1
│ mContext instance of androidx.appcompat.view.ContextThemeWrapper, wrapping
│ activity winged.example.hiltmultimodule.MainActivity with mDestroyed =
│ false
│ ↓ View.mParent
│ ~~~~~~~
├─ android.widget.FrameLayout instance
│ Leaking: UNKNOWN
│ Retaining 1.0 kB in 15 objects
│ View not part of a window view hierarchy
│ View.mAttachInfo is null (view detached)
│ View.mWindowAttachCount = 1
│ mContext instance of androidx.appcompat.view.ContextThemeWrapper, wrapping
│ activity winged.example.hiltmultimodule.MainActivity with mDestroyed =
│ false
│ ↓ View.mParent
│ ~~~~~~~
├─ com.google.android.material.textfield.TextInputLayout instance
│ Leaking: UNKNOWN
│ Retaining 381.0 kB in 3284 objects
│ View not part of a window view hierarchy
│ View.mAttachInfo is null (view detached)
│ View.mID = R.id.repeatPasswordTIL
│ View.mWindowAttachCount = 1
│ mContext instance of androidx.appcompat.view.ContextThemeWrapper, wrapping
│ activity winged.example.hiltmultimodule.MainActivity with mDestroyed =
│ false
│ ↓ View.mParent
│ ~~~~~~~
╰→ androidx.constraintlayout.widget.ConstraintLayout instance
Leaking: YES (ObjectWatcher was watching this because winged.example.
feature_login.register.RegisterFragment received Fragment#onDestroyView()
callback (references to its views should be cleared to prevent leaks))
Retaining 2.5 kB in 59 objects
key = 16bf9a7e-c3de-4737-a5c2-8933c6fed9d3
watchDurationMillis = 132084
retainedDurationMillis = 127081
View not part of a window view hierarchy
View.mAttachInfo is null (view detached)
View.mID = R.id.mainCL
View.mWindowAttachCount = 1
mContext instance of dagger.hilt.android.internal.managers.
ViewComponentManager$FragmentContextWrapper, wrapping activity winged.
example.hiltmultimodule.MainActivity with mDestroyed = false
METADATA
Build.VERSION.SDK_INT: 30
Build.MANUFACTURER: unknown
LeakCanary version: 2.10
App process name: winged.example.hiltmultimodule
Class count: 18527
Instance count: 115319
Primitive array count: 86210
Object array count: 17808
Thread count: 21
Heap total bytes: 16303680
Bitmap count: 4
Bitmap total bytes: 228214
Large bitmap count: 0
Large bitmap total bytes: 0
Db 1: open /data/user/0/winged.example.
hiltmultimodule/databases/HiltMultiModuleDB
Stats: LruCache[maxSize=3000,hits=40347,misses=84973,hitRate=32%]
RandomAccess[bytes=4231371,reads=84973,travel=25038680029,range=19100784,size=25
202710]
Analysis duration: 6049 ms
I'm still learning so any info / possible reasons / solutions will be appreciated, thanks :)
Edit:
BaseFragment:
abstract class BaseFragment<T : ViewDataBinding>(#LayoutRes private val fragmentRes: Int) : Fragment() {
private var _binding: T? = null
val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = DataBindingUtil.inflate(inflater, fragmentRes, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
fun navigateTo(targetDestination: Int) {
findNavController().navigate(targetDestination)
}
fun navigateUp() {
findNavController().navigateUp()
}
}
loginFragment:
#AndroidEntryPoint
class LoginFragment : BaseFragment<FragmentLoginBinding>(R.layout.fragment_login) {
private val viewModel: LoginViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpLogInButton()
setUpTextRedirection()
observeForLoginEvents()
}
private fun setUpTextRedirection() {
binding.signUpTV.setOnClickListener {
navigateTo(R.id.registerFragment)
}
}
private fun setUpLogInButton() {
binding.logInBTN.setOnClickListener {
val email = binding.emailTIET.extractText()
val password = binding.passwordTIET.extractText()
if(email.isAValidEmail() && password.isNotBlank()) {
viewModel.logIn(LoginCredentials(mail = email, password = password))
}
}
}
private fun observeForLoginEvents() {
viewModel.loginEvent.observe(viewLifecycleOwner) { result ->
if(result.isSuccess) {
/* Adding some kind of "Main Screen" module would be an idea
but as I've stated previously, this is just a small "test" project
showing off architecture, so I hope you will forgive me <3
(PS: if you are reading this and there still isn't that module, you can make a PR
and add it)*/
Toast.makeText(requireContext(), "Success!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), "No matching account", Toast.LENGTH_SHORT).show()
}
}
}
}
registerFragment:
#AndroidEntryPoint
class RegisterFragment: BaseFragment<FragmentRegisterBinding>(R.layout.fragment_register) {
private val viewModel: RegisterViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpCreateAccountButton()
setUpTextRedirection()
observeRegisterEvents()
}
private fun setUpTextRedirection() {
binding.logInTV.setOnClickListener {
navigateTo(R.id.loginFragment)
}
}
private fun setUpCreateAccountButton() {
binding.createAnAccountBTN.setOnClickListener {
val email = binding.emailTIET.extractText()
val password = binding.passwordTIET.extractText()
val repeatedPassword = binding.repeatPasswordTIET.extractText()
if(email.isAValidEmail() && (password == repeatedPassword) && password.isNotEmpty()) {
viewModel.saveUser(
LoginCredentials(mail = email, password = password)
)
}
}
}
private fun observeRegisterEvents() {
viewModel.registerEvent.observe(viewLifecycleOwner) { result ->
if(result.isSuccess) {
navigateTo(R.id.loginFragment)
} else {
Toast.makeText(requireContext(), "Something went wrong", Toast.LENGTH_SHORT).show()
}
}
}
As you may notice, the BaseFragment class has a reference to a view (binding variable), but it releases it in onDestoryView, so I think that should be working, also in the leak it isn't "complaining" about the binding itself
Found the culprit. The problem comes from an EditText that has the input type android:inputType="textPassword" or any other variant that has a password. In this case, it is one of the TextInputLayout instance, that has a TextInputEditText. But: It may need to be combined with the usage of Emoji Library, because the class has an element with the type androidx.emoji2.text.SpannableBuilder that belongs to the Emoji library.
The TextInputEditText's text is spannable, which means it's not a simple string, it's an object. An object, that can be Parcelable, which means its state can be saved. And, it looks like its actually saved here. No idea how though, since Parcelable limits which types can be saved.
The memory leak appears to be on the TextInputEditText with the ID R.id.repeatPasswordTIET. In your layout file, you can also search for #+id/repeatPasswordTIET or #id/repeatPasswordTIET to find the specific one.
Why the leak?
TextView's (or more likely EditText's) have a tendency to not remove their listeners once they are not needed. It's just not configured that way, maybe due to expecting the callers to remove the listeners themselves once they are not needed. A lot of other listeners get cleared once they are not needed, but the TextWatcher is an exception unfortunately.
Examining the leak canary trace, android.text.method.PasswordTransformationMethod$Visible instance has a androidx.emoji2.text.SpannableBuilder which contains an array, and one of the entries points to android.widget.TextView$ChangeWatcher instance which then shows the TextInputEditText that is leaked. It is leaked because in the same trace, you can see that the listener is saved to android.widget.TextView$SavedState instance, which I assume gets restored in a future fragment.
I actually tried to fetch the value myself, but wasn't able to do it. The saved state did not hold the listener.
Although, I have a potential solution: Delete every listener when the view is not necessary anymore.
Potential solution:
import android.content.Context
import android.text.TextWatcher
import android.util.AttributeSet
import com.google.android.material.textfield.TextInputEditText
class ListenerAwareEditText #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.editTextStyle
): TextInputEditText(context, attrs, defStyleAttr) {
private companion object {
val textChangedListenersStatic: MutableList<TextWatcher> = ArrayList()
}
private val textChangedListeners: MutableList<TextWatcher> = ArrayList()
/**
* Swap the listeners added in the companion object list with the actual.
*/
init {
textChangedListeners.addAll(textChangedListenersStatic)
textChangedListenersStatic.clear()
}
/**
* Overridden to hold a reference of the listener
*/
override fun addTextChangedListener(watcher: TextWatcher?) {
super.addTextChangedListener(watcher)
watcher?.let {
// NullPointerException may happen because this method
// can be called before the object itself is constructed,
// from the super classes.
// So, to hold the values, a static list in a
// companion object was used, and then the elements
// get transferred to the actual list, clearing the
// static one.
try {
textChangedListeners.add(it)
} catch (ignore: NullPointerException) {
textChangedListenersStatic.add(it)
}
}
}
/**
* Overridden to release the listener in our list
*/
override fun removeTextChangedListener(watcher: TextWatcher?) {
super.removeTextChangedListener(watcher)
watcher?.let {
// NullPointerException may happen because this method
// can be called before the object itself is constructed,
// from the super classes.
// So, to hold the values, a static list in a
// companion object was used, and then the elements
// get transferred to the actual list, clearing the
// static one.
try {
textChangedListeners.remove(it)
} catch (ignore: NullPointerException) {
textChangedListenersStatic.remove(it)
}
}
}
/**
* Clears the text changed listeners. Call this from the
* fragment's [onDestroyView] or Activity's [onDestroy].
*/
fun clearTextChangedListeners() {
textChangedListeners.forEach {
super.removeTextChangedListener(it)
}
textChangedListeners.clear()
}
}
What the class does: It caches all the listeners added in a list, and allows you to call clearTextChangedListeners() once it is not needed. (I tried to do this automatically but the lifecycle got confusing once fragments, nested recyclerviews etc... got involved so I left it here)
Usage:
Swap with your layouts' TextInputEditText with this class, and at your fragment's onDestroyView, call editText.clearTextChangedListeners().
It should solve your problem, however it's the Android world. It might not.
Edit: Apparently you should use com.google.android.material.R.attr.editTextStyle as your default theme (taken from TextInputEditText's constructor) which is updated in the code. It should not affect the design as it should be set from the XML anyway.
I receive a notice from Leakcanary when debugging my app on a Samsung Galaxy S10 running on Android 12.
Indeed, Leakcanary notifies a leaked activity when toggle from light to night mode or vice versa.
Below is shown the Leakcanary report:
┬───
│ GC Root: System class
│
├─ android.view.inputmethod.InputMethodManager class
│ Leaking: NO (InputMethodManager↓ is not leaking and a class is never
│ leaking)
│ ↓ static InputMethodManager.sInstance
├─ android.view.inputmethod.InputMethodManager instance
│ Leaking: NO (DecorView↓ is not leaking and InputMethodManager is a
│ singleton)
│ ↓ InputMethodManager.mCurRootView
├─ android.view.ViewRootImpl instance
│ Leaking: NO (DecorView↓ is not leaking)
│ mContext instance of com.android.internal.policy.DecorContext, wrapping
│ activity com.my_package.activity.MainActivity
│ with mDestroyed = false
│ ViewRootImpl#mView is not null
│ mWindowAttributes.mTitle = "com.my_package.activity.MainActivity"
│ mWindowAttributes.type = 1
│ ↓ ViewRootImpl.mParentDecorView
├─ com.android.internal.policy.DecorView instance
│ Leaking: NO (View attached)
│ View is part of a window view hierarchy
│ View.mAttachInfo is not null (view attached)
│ View.mWindowAttachCount = 1
│ mContext instance of com.android.internal.policy.DecorContext, wrapping
│ activity com.my_package.activity.MainActivity
│ with mDestroyed = false
│ ↓ DecorView.mMSActions
│ ~~~~~~~~~~
├─ com.samsung.android.multiwindow.MultiSplitActions instance
│ Leaking: UNKNOWN
│ Retaining 43 B in 1 objects
│ ↓ MultiSplitActions.mWindow
│ ~~~~~~~
├─ com.android.internal.policy.PhoneWindow instance
│ Leaking: YES (Window#mDestroyed is true)
│ Retaining 15,0 kB in 286 objects
│ mContext instance of com.my_package.activity.
│ MainActivity with mDestroyed = true
│ mOnWindowDismissedCallback instance of com.my_package.activity.MainActivity with mDestroyed = true
│ ↓ Window.mContext
╰→ com.my_package.activity.MainActivity instance
Leaking: YES (ObjectWatcher was watching this because com.my_package.activity.MainActivity received
Activity#onDestroy() callback and Activity#mDestroyed is true)
Retaining 534,5 kB in 11484 objects
key = 442fa647-742b-4e2f-b4c7-452ea418ffdb
watchDurationMillis = 5868
retainedDurationMillis = 867
dataBackupAgent instance of com.my_package.activity.
MyBackupAgent
mApplication instance of com.my_package.activity.MyApplicationClass
mBase instance of androidx.appcompat.view.ContextThemeWrapper
METADATA
Build.VERSION.SDK_INT: 31
Build.MANUFACTURER: samsung
LeakCanary version: 2.8.1
Stats: LruCache[maxSize=3000,hits=129558,misses=253658,hitRate=33%]
RandomAccess[bytes=21184127,reads=253658,travel=102525154060,range=50140315,size
=69790586]
Analysis duration: 12217 ms
Please do you have any idea concerning the origin of this ?
First of all thank you for your reply.
Indeed, I have a class named MyBackupAgent:
#Singleton
class MyBackupAgent #Inject constructor(
private val backupPrefsKey: String,
private val backupHelper: MyBackupHelper
): BackupAgentHelper() {
override fun onCreate() {
super.onCreate()
addHelper(backupPrefsKey, backupHelper)
}
}
The MyBackupHelper class has the following implementation:
#Singleton
class MyBackupHelper #Inject constructor(
private val context: Context,
private val backupPreferencesKey: String
) : SharedPreferencesBackupHelper(context, backupPreferencesKey) {
.....
}
The MyBackupAgent is injected into Activity with:
#Inject
lateinit var backupAgent: MyBackupAgent
The MyBackupAgent and MyBackupHelper are provided by the MyBaseModule class:
#InstallIn(SingletonComponent::class)
#Module
class MyBaseModule {
#Singleton
#Provides
fun provideMyBackupHelper(#ApplicationContext context: Context, #BackUpPreferencesKey backupPrefsKey: String): MyBackupHelper {
val backupHelper by lazy {
MyBackupHelper(context, backupPrefsKey)
}
return backupHelper
}
#Singleton
#Provides
fun provideMyBackupAgent(#BackUpPreferencesKey backupPreferencesKey: String, backupHelper: MyBackupHelper): MyBackupAgent {
val backupAgent by lazy {
MyBackupAgent(backupPreferencesKey, backupHelper)
}
return backupAgent
}
}
I'm using the paging 3 android library with the RxJava source. I have two fragments, the first displays a list of images in a grid, when an image is clicked the second fragment is shown and it displays the image in fullscreen and has a ViewPager to swipe between images. Because those use the same data I figured I can use a shared view model, in both fragments I have
#Inject lateinit var viewModelFactory: ViewModelProvider.Factory
private val viewModel by activityViewModels<FilesViewModel> { viewModelFactory }
And the viewmodel creates the rx flowable that both fragments observe when their view is visible
class FilesViewModel #Inject constructor(
settings: SettingsRepository,
private val filesRepository: FilesRepository
): ViewModel() {
...
var cachedFileList = filesRepository.getPagedFiles("path").cachedIn(viewModelScope)
...
}
After navigating back to the list the fragment is retained, after doing that five times LeakCanary shows the leak
┬───
│ GC Root: System class
│
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
├─ com.guillermonegrete.gallery.folders.MainActivity instance
│ Leaking: NO (Activity#mDestroyed is false)
│ mApplication instance of com.guillermonegrete.gallery.MyApplication
│ mBase instance of android.app.ContextImpl
│ ↓ ComponentActivity.mViewModelStore
│ ~~~~~~~~~~~~~~~
├─ androidx.lifecycle.ViewModelStore instance
│ Leaking: UNKNOWN
│ Retaining 816 B in 11 objects
│ ↓ ViewModelStore.mMap
│ ~~~~
├─ java.util.HashMap instance
│ Leaking: UNKNOWN
│ Retaining 804 B in 10 objects
│ ↓ HashMap.table
│ ~~~~~
├─ java.util.HashMap$HashMapEntry[] array
│ Leaking: UNKNOWN
│ Retaining 764 B in 9 objects
│ ↓ HashMap$HashMapEntry[].[0]
│ ~~~
├─ java.util.HashMap$HashMapEntry instance
│ Leaking: UNKNOWN
│ Retaining 520 B in 6 objects
│ ↓ HashMap$HashMapEntry.value
│ ~~~~~
├─ com.guillermonegrete.gallery.files.FilesViewModel instance
│ Leaking: UNKNOWN
│ Retaining 4,0 kB in 145 objects
│ ↓ FilesViewModel.cachedFileList
│ ~~~~~~~~~~~~~~
├─ io.reactivex.internal.operators.flowable.FlowableFromPublisher instance
│ Leaking: UNKNOWN
│ Retaining 28 B in 2 objects
│ ↓ FlowableFromPublisher.publisher
│ ~~~~~~~~~
├─ kotlinx.coroutines.reactive.FlowAsPublisher instance
│ Leaking: UNKNOWN
│ Retaining 16 B in 1 objects
│ ↓ FlowAsPublisher.flow
│ ~~~~
├─ kotlinx.coroutines.flow.SafeFlow instance
│ Leaking: UNKNOWN
│ Retaining 48 B in 2 objects
│ ↓ SafeFlow.block
│ ~~~~~
├─ androidx.paging.multicast.Multicaster$flow$1 instance
│ Leaking: UNKNOWN
│ Retaining 36 B in 1 objects
│ Anonymous subclass of kotlin.coroutines.jvm.internal.SuspendLambda
│ ↓ Multicaster$flow$1.this$0
│ ~~~~~~
├─ androidx.paging.multicast.Multicaster instance
│ Leaking: UNKNOWN
│ Retaining 108 B in 4 objects
│ ↓ Multicaster.channelManager$delegate
│ ~~~~~~~~~~~~~~~~~~~~~~~
├─ kotlin.SynchronizedLazyImpl instance
│ Leaking: UNKNOWN
│ Retaining 50 B in 2 objects
│ ↓ SynchronizedLazyImpl._value
│ ~~~~~~
├─ androidx.paging.multicast.ChannelManager instance
│ Leaking: UNKNOWN
│ Retaining 30 B in 1 objects
│ ↓ ChannelManager.actor
│ ~~~~~
├─ androidx.paging.multicast.ChannelManager$Actor instance
│ Leaking: UNKNOWN
│ Retaining 19,5 kB in 573 objects
│ ↓ ChannelManager$Actor.channels
│ ~~~~~~~~
├─ java.util.ArrayList instance
│ Leaking: UNKNOWN
│ Retaining 19,4 kB in 567 objects
│ ↓ ArrayList.elementData
│ ~~~~~~~~~~~
├─ java.lang.Object[] array
│ Leaking: UNKNOWN
│ Retaining 19,4 kB in 566 objects
│ ↓ Object[].[1]
│ ~~~
├─ androidx.paging.multicast.ChannelManager$ChannelEntry instance
│ Leaking: UNKNOWN
│ Retaining 3,6 kB in 110 objects
│ ↓ ChannelManager$ChannelEntry.channel
│ ~~~~~~~
├─ kotlinx.coroutines.channels.LinkedListChannel instance
│ Leaking: UNKNOWN
│ Retaining 3,6 kB in 109 objects
│ ↓ AbstractSendChannel.queue
│ ~~~~~
├─ kotlinx.coroutines.internal.LockFreeLinkedListHead instance
│ Leaking: UNKNOWN
│ Retaining 3,6 kB in 108 objects
│ ↓ LockFreeLinkedListNode._next
│ ~~~~~
├─ kotlinx.coroutines.channels.Closed instance
│ Leaking: UNKNOWN
│ Retaining 3,6 kB in 106 objects
│ ↓ Closed.closeCause
│ ~~~~~~~~~~
├─ kotlinx.coroutines.JobCancellationException instance
│ Leaking: UNKNOWN
│ Retaining 3,5 kB in 105 objects
│ ↓ JobCancellationException.job
│ ~~~
├─ kotlinx.coroutines.reactive.FlowSubscription instance
│ Leaking: UNKNOWN
│ Retaining 3,4 kB in 102 objects
│ ↓ FlowSubscription.subscriber
│ ~~~~~~~~~~
├─ io.reactivex.internal.operators.flowable.
│ FlowableSubscribeOn$SubscribeOnSubscriber instance
│ Leaking: UNKNOWN
│ Retaining 3,3 kB in 99 objects
│ ↓ FlowableSubscribeOn$SubscribeOnSubscriber.downstream
│ ~~~~~~~~~~
├─ io.reactivex.internal.operators.flowable.
│ FlowableObserveOn$ObserveOnSubscriber instance
│ Leaking: UNKNOWN
│ Retaining 3,2 kB in 93 objects
│ ↓ FlowableObserveOn$ObserveOnSubscriber.downstream
│ ~~~~~~~~~~
├─ io.reactivex.internal.subscribers.LambdaSubscriber instance
│ Leaking: UNKNOWN
│ Retaining 2,6 kB in 86 objects
│ ↓ LambdaSubscriber.onNext
│ ~~~~~~
├─ com.guillermonegrete.gallery.files.details.
│ FileDetailsFragment$setUpViewModel$1 instance
│ Leaking: UNKNOWN
│ Retaining 2,5 kB in 85 objects
│ Anonymous class implementing io.reactivex.functions.Consumer
│ ↓ FileDetailsFragment$setUpViewModel$1.this$0
│ ~~~~~~
╰→ com.guillermonegrete.gallery.files.details.FileDetailsFragment instance
Leaking: YES (ObjectWatcher was watching this because com.
guillermonegrete.gallery.files.details.FileDetailsFragment received
Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
Retaining 2,5 kB in 84 objects
key = 0b0dad5d-1c55-4938-94d8-0f923fc29508
watchDurationMillis = 23025
retainedDurationMillis = 18023
You can see that the 2nd fragment is leaking and it is related to cachedFileList
Now if I remove the cachedIn(viewModelScope) then the leaks are gone however the app now makes API calls every time I navigate between fragments, which the whole point of sharing the view model is to save api calls.
Is there any way to avoid the multiple api calls and the leaks? I know I can use a database but I want to avoid that overhead if possible.
EDIT:
How the flow is consumed, basically the same for both fragments
class FilesListFragment: Fragment(R.layout.fragment_files_list) {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
bindViewModel(folder)
}
private fun bindViewModel(folder: String){
disposable.add(viewModel.loadPagedFiles(folder)
.subscribeOn(Schedulers.io()) // Omitted some mapping
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ adapter.submitData(lifecycle, it) },
{ error -> println("Error loading files: ${error.message}") }
)
)
}
override fun onDestroyView() {
binding.filesList.adapter = null
_binding = null
disposable.clear()
adapter.removeLoadStateListener(loadListener)
super.onDestroyView()
}
...
}
Here is how it is created
class DefaultFilesRepository #Inject constructor(private var fileAPI: FilesServerAPI): FilesRepository {
override fun getPagedFiles(folder: String): Flowable<PagingData<File>> {
return Pager(PagingConfig(pageSize = 20)) {
FilesPageSource(fileAPI, baseUrl, folder)
}.flowable
}
}
As recommended by #dlam, I used a switchMap.
class FilesViewModel #Inject constructor(
settings: SettingsRepository,
private val filesRepository: FilesRepository
): ViewModel() {
...
private val folderName: Subject<String> = PublishSubject.create()
var cachedFileList: Flowable<PagingData<File>> = folderName.distinctUntilChanged().switchMap {
filesRepository.getPagedFiles(it).toObservable()
}.toFlowable(BackpressureStrategy.LATEST).cachedIn(viewModelScope)
fun setFolderName(name: String){
folderName.onNext(name)
}
...
}
And observing the data from the fragments
disposable.add(viewModel.loadPagedFiles(folder)
// .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) // Omitted some mapping
.subscribe(
{ adapter.submitData(lifecycle, it) },
{ error -> println("Error loading files: ${error.message}") }
)
)
viewModel.setFolderName(folder)
I removed .subscribeOn(Schedulers.io()) from the 1st fragment, for some reason it caused the switchMap to never be called at the start. A related question.
Also removed the one in the 2nd fragment. When navigating back to the 1st fragment this exception was thrown:
kotlinx.coroutines.channels.ClosedSendChannelException: Channel was closed
After removing the subscribeOn the exception went away.
My problem is, that my shopViewModel which holds an instance of a paging-flow is somehow leaking. I've tried to solve this problem by converting the flow into a livedata, but that changed nothing.
ViewModel
class ShopViewModel #ViewModelInject constructor(
private val shopPagingSource: ShopPagingSource,
) : ViewModel() {
val SHOP_PAGE_CONFIG: PagingConfig = PagingConfig(pageSize = 20, enablePlaceholders = false)
// As LiveData
val shopFlow = Pager(SHOP_PAGE_CONFIG) { shopPagingSource }.flow.cachedIn(viewModelScope).asLiveData()
// Before
val shopFlow = Pager(SHOP_PAGE_CONFIG) { shopPagingSource }.flow.cachedIn(viewModelScope)
}
Fragment
#AndroidEntryPoint
class ShopFragment(private val shopListAdapter: ShopAdapter) : Fragment(R.layout.fragment_shop), ShopAdapter.OnItemClickListener {
private val shopViewModel: ShopViewModel by viewModels()
private val shopBinding: FragmentShopBinding by viewBinding()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
shopBinding.adapter = shopListAdapter.withLoadStateFooter(ShopLoadAdapter(shopListAdapter::retry))
shopListAdapter.clickHandler(this)
collectShopList()
}
override fun forwardClick(product: #NotNull Product) {
val action = ShopFragmentDirections.actionShopFragmentToShopItemFragment(product)
findNavController().navigate(action)
}
private fun collectShopListWithLiveData() = lifecycleScope.launch {
shopViewModel.shopFlow.observe(viewLifecycleOwner) {
lifecycleScope.launch {
shopListAdapter.submitData(it)
}
}
}
// Before converting to livedata
private fun collectShopListWithFlow() = lifecycleScope.launch {
shopViewModel.shopFlow.collectLatest {
shopListAdapter.submitData(it)
}
}
// To avoid memory leak from injected adapter
override fun onDestroyView() {
requireView().findViewById<RecyclerView>(R.id.rv_shop).adapter = null
super.onDestroyView()
}
}
Adapter
class ShopAdapter #Inject constructor() : PagingDataAdapter<Product, ShopAdapter.ShopViewHolder>(Companion) {
private lateinit var clickListener: OnItemClickListener
companion object: DiffUtil.ItemCallback<Product>() {
override fun areItemsTheSame(oldItem: Product, newItem: Product): Boolean = oldItem.articelNumber == newItem.articelNumber
override fun areContentsTheSame(oldItem: Product, newItem: Product): Boolean = oldItem == newItem
}
inner class ShopViewHolder(val binding: ShopListItemBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ShopAdapter.ShopViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = ShopListItemBinding.inflate(layoutInflater, parent, false)
return ShopViewHolder(binding).also {
binding.mcvProductItem.setOnClickListener { clickListener.forwardClick(binding.product!!) }
}
}
override fun onBindViewHolder(holder: ShopAdapter.ShopViewHolder, position: Int) {
holder.binding.product = getItem(position) ?: return
holder.binding.executePendingBindings()
}
fun clickHandler(clickEventHandler: OnItemClickListener) {
clickListener = clickEventHandler
}
interface OnItemClickListener {
fun forwardClick(product: #NotNull Product)
}
}
MainFragmentFactory
class MainFragmentFactory #Inject constructor(
// .. other dependencies
private val shopAdapter: ShopAdapter,
) : FragmentFactory() {
override fun instantiate(classLoader: ClassLoader, className: String): Fragment = when(className) {
// ... other fragments
ShopFragment::class.java.name -> ShopFragment(shopAdapter)
else -> super.instantiate(classLoader, className)
}
PagingSource
class ShopPagingSource #Inject constructor(
private val shopRepository: ShopFirebaseRepository,
) : PagingSource<QuerySnapshot, Product>() {
override suspend fun load(params: LoadParams<QuerySnapshot>): LoadResult<QuerySnapshot, Product> = try {
withTimeout(SHOP_MAX_LOADING_TIME) {
val currentPage = params.key ?: shopRepository.getCurrentPage()
val lastDocumentSnapShot = currentPage.documents[currentPage.size() - 1]
val nextPage = shopRepository.getNextPage(lastDocumentSnapShot)
LoadResult.Page(
data = currentPage.toObjects(),
prevKey = null,
nextKey = nextPage
)
}
} catch (e: TimeoutCancellationException) {
Timber.d("Mediator failed, No Internet Connection")
LoadResult.Error(e)
} catch (e: ArrayIndexOutOfBoundsException) {
Timber.d("Mediator failed, ArrayIndexOutOfBounds")
LoadResult.Error(e)
} catch (e: Exception) {
Timber.d("Mediator failed, Unknown Error: ${e.message.toString()}")
LoadResult.Error(e)
}
}
LeakCanary
D/LeakCanary: ====================================
HEAP ANALYSIS RESULT
====================================
1 APPLICATION LEAKS
References underlined with "~~~" are likely causes.
Learn more at https://squ.re/leaks.
2618 bytes retained by leaking objects
Signature: 944313b4ecbdb77c99682dc8c1646e12e4f37d8
┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never leaking)
│ ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (InternalLeakCanary↓ is not leaking)
│ ↓ Object[].[2142]
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
├─ com.example.app.framework.ui.view.MainActivity instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking and Activity#mDestroyed is false)
│ ↓ MainActivity.navController$delegate
├─ kotlin.SynchronizedLazyImpl instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ ↓ SynchronizedLazyImpl._value
├─ androidx.navigation.NavHostController instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ ↓ NavHostController.mLifecycleOwner
├─ com.example.app.framework.ui.view.utils.MainNavHostFragment instance
│ Leaking: NO (Fragment#mFragmentManager is not null)
│ ↓ MainNavHostFragment.mainFragmentFactory
│ ~~~~~~~~~~~~~~~~~~~
├─ com.example.app.framework.ui.view.utils.MainFragmentFactory instance
│ Leaking: UNKNOWN
│ ↓ MainFragmentFactory.shopAdapter
│ ~~~~~~~~~~~
├─ com.example.app.framework.ui.adapter.recyclerview.ShopAdapter instance
│ Leaking: UNKNOWN
│ ↓ ShopAdapter.differ
│ ~~~~~~
├─ androidx.paging.AsyncPagingDataDiffer instance
│ Leaking: UNKNOWN
│ ↓ AsyncPagingDataDiffer.differBase
│ ~~~~~~~~~~
├─ androidx.paging.AsyncPagingDataDiffer$differBase$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of androidx.paging.PagingDataDiffer
│ ↓ AsyncPagingDataDiffer$differBase$1.receiver
│ ~~~~~~~~
├─ androidx.paging.PageFetcher$PagerUiReceiver instance
│ Leaking: UNKNOWN
│ ↓ PageFetcher$PagerUiReceiver.this$0
│ ~~~~~~
├─ androidx.paging.PageFetcher instance
│ Leaking: UNKNOWN
│ ↓ PageFetcher.pagingSourceFactory
│ ~~~~~~~~~~~~~~~~~~~
├─ com.example.app.framework.ui.viewmodel.ShopViewModel$shopFlow$1 instance
│ Leaking: UNKNOWN
│ Anonymous subclass of kotlin.jvm.internal.Lambda
│ ↓ ShopViewModel$shopFlow$1.this$0
│ ~~~~~~
╰→ com.example.app.framework.ui.viewmodel.ShopViewModel instance
Leaking: YES (ObjectWatcher was watching this because com.example.app.framework.ui.viewmodel.ShopViewModel received ViewModel#onCleared() callback)
key = 0e65fcab-e6dd-475a-83d4-87b2050d797b
watchDurationMillis = 7771
retainedDurationMillis = 2769
====================================
EDIT
When scoping the ShopAdapter with #FragmentScoped, I get the following leak:
┬───
│ GC Root: Local variable in native code
│
├─ dalvik.system.PathClassLoader instance
│ Leaking: NO (InternalLeakCanary↓ is not leaking and A ClassLoader is never leaking)
│ ↓ PathClassLoader.runtimeInternalObjects
├─ java.lang.Object[] array
│ Leaking: NO (InternalLeakCanary↓ is not leaking)
│ ↓ Object[].[409]
├─ leakcanary.internal.InternalLeakCanary class
│ Leaking: NO (MainActivity↓ is not leaking and a class is never leaking)
│ ↓ static InternalLeakCanary.resumedActivity
├─ com.example.app.framework.ui.view.MainActivity instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking and Activity#mDestroyed is false)
│ mApplication instance of com.example.app.App
│ mBase instance of androidx.appcompat.view.ContextThemeWrapper, not wrapping known Android context
│ ↓ MainActivity.navController$delegate
├─ kotlin.SynchronizedLazyImpl instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ ↓ SynchronizedLazyImpl._value
├─ androidx.navigation.NavHostController instance
│ Leaking: NO (MainNavHostFragment↓ is not leaking)
│ mActivity instance of com.example.app.framework.ui.view.MainActivity with mDestroyed = false
│ mContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper, wrapping
│ activity com.example.app.framework.ui.view.MainActivity with mDestroyed = false
│ ↓ NavHostController.mLifecycleOwner
├─ com.example.app.framework.ui.view.utils.MainNavHostFragment instance
│ Leaking: NO (Fragment#mFragmentManager is not null)
│ componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper,
│ wrapping activity com.example.app.framework.ui.view.MainActivity with mDestroyed = false
│ ↓ MainNavHostFragment.mainFragmentFactory
│ ~~~~~~~~~~~~~~~~~~~
D/LeakCanary: ├─ com.example.app.framework.ui.view.utils.MainFragmentFactory instance
│ Leaking: UNKNOWN
│ Retaining 212 bytes in 7 objects
│ ↓ MainFragmentFactory.shopAdapter
│ ~~~~~~~~~~~
├─ com.example.app.framework.ui.adapter.recyclerview.ShopAdapter instance
│ Leaking: UNKNOWN
│ Retaining 14461 bytes in 546 objects
│ ↓ ShopAdapter.clickListener
│ ~~~~~~~~~~~~~
╰→ com.example.app.framework.ui.view.fragments.shop.ShopFragment instance
Leaking: YES (ObjectWatcher was watching this because com.example.app.framework.ui.view.fragments.shop.
ShopFragment received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
Retaining 2121 bytes in 79 objects
key = 71ec5094-8509-47a5-9e0a-070fe642ca8a
watchDurationMillis = 18366
retainedDurationMillis = 13365
componentContext instance of dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper,
wrapping activity com.example.app.framework.ui.view.MainActivity with mDestroyed = false
I know that I'm late for the party but I can see that you are using lifecycleScope.launch a lot and inside you are calling the adapter to submitData. This means this adapter will not be able to be properly garbage collected. This is probably the root of memory leak.
Try to use viewLifecycleOwner.lifecycleScope.launch instead.
This is a known mistake:
https://proandroiddev.com/5-common-mistakes-when-using-architecture-components-403e9899f4cb
Okay I've managed to solve this leak. The leak was caused, because I've injected my ShopAdapter via Constructor Injection into my Fragment. When injecting something into the fragment via constructor injection, you have to pass the dependency to the MainFragmentFactory. But because of this, the MainFragmentFactory will always hold a reference to the adapter, even when the fragment is destroyed and the fragment is not needed any more (therefore, requireView().findViewById<RecyclerView>(R.id.rv_shop).adapter = null wont't even make a change here).
To solve this problem, DON'T inject the Adapter via constructor injection and rather inject it via field injection.