2

(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

JustSightseeing
  • 1,460
  • 3
  • 17
  • 37
  • Does your fragment have any long-lived references to views, e.g. assigned to a top-level variable (including any View Binding instance)? A Fragment can outlive its views, which is why examples in the docs usually make those references nullable and clear them in `onDestroyView` – cactustictacs Nov 26 '22 at 16:34
  • I will update the question and add some details then – JustSightseeing Nov 26 '22 at 16:47
  • 2
    Oh yeah I see. Honestly (I'm not an expert with Leak Canary) it looks like every potential leak in that trace is part of a standard/Android library component, starting with the Fragment's `mSavedViewState`, so it doesn't seem like you're doing anything wrong in your code? I noticed you don't seem to have any `androidx.fragment` dependencies in your gradle files, so you might be using an old version pulled in from another dependency - they've made changes to saved instance handling (https://developer.android.com/jetpack/androidx/releases/fragment?hl=en#1.5.0) so maybe see if updating helps – cactustictacs Nov 26 '22 at 17:11
  • 2
    Adding the androidx.fragment dependency changed nothing unfortunately, I guess we will just have to wait for the bounty ;-; – JustSightseeing Nov 26 '22 at 21:36

1 Answers1

1

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.

Furkan Yurdakul
  • 2,801
  • 1
  • 15
  • 37
  • 1
    Probably won't be able to test it today, but I will try to check it out tomorrow, still, didn't expect such a detailed and well written answer, thank you :) – JustSightseeing Dec 02 '22 at 22:02
  • It is kinda interfering with the baseFragment class, because the baseFragment class manages the binding variable (making it null), so I cannot use binding in onDestoryView (in fragements), so I'm kinda stuck (I mean, I could create a list of editTexts to clear and pass it as a parameter to the base class but that seems awful) – JustSightseeing Dec 03 '22 at 19:21
  • Tinkered around with it for a bit, and just put the clearTextChangedListeners() whenever the fragment gets replaced (so in my case whenever the user creates an account) and It did stop the memory leak, but I still find it strange that the leak happened in the first place – JustSightseeing Dec 05 '22 at 14:45
  • Thanks for accepting the answer. By the way, you can still use the binding at `onDestroyView` if you override it and access the binding before calling `super.onDestroyView()` because it will still be available, which could be a cleaner solution. – Furkan Yurdakul Dec 06 '22 at 19:55
  • Oh, that's true, I haven't thought about that not gonna lie, thank you – JustSightseeing Dec 06 '22 at 20:18
  • Also, after testing your solution I noticed it would refuse to work on some devices (mainly samsung devices) and based on this answer: https://stackoverflow.com/a/22857849/15749574 I was able to fix it, now it works great, cheers! – JustSightseeing Dec 06 '22 at 20:42
  • 1
    Oh wow I forgot about the theming issue. Updated the answer. – Furkan Yurdakul Dec 07 '22 at 18:28