Home > Net >  savedInstanceState causes a memory leak when returning to fragment
savedInstanceState causes a memory leak when returning to fragment

Time:12-06

(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

CodePudding user response:

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 = 0
): 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.

  • Related