Android Fragments savedInstanceState在返回片段时导致内存泄漏

ecbunoof  于 2022-12-04  发布在  Android
关注(0)|答案(1)|浏览(144)

bounty将于明天到期。回答此问题可获得+100的声望奖励。JustSightseeing正在寻找来自知名来源的答案

(The完整的代码可以在这里找到(没有leakCanary依赖):https://github.com/Dawwit0001/HiltMultiModule)的数据
我创建了两个片段,一个登录片段和一个注册片段,每当用户打开应用程序时,登录屏幕都会显示。如果用户导航到注册屏幕,创建一个帐户,然后导航回登录屏幕,就会发生泄漏。我不知道为什么会发生泄漏,但我发现当我将登录片段中的“savedInstanceState”替换为null(在onViewCreated中)时,不会发生泄漏。
倾城泄露:

┬───
│ 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

我还在学习,所以任何信息/可能的原因/解决方案将不胜感激,谢谢:)
编辑:
基本片段:

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()
    }
}

登录片段:

@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()
            }
        }
    }
}

寄存器片段:

@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()
            }
        }
    }

正如您可能注意到的,BaseFragment类有一个对视图的引用(绑定变量),但它在onDestoryView中释放了它,所以我认为这应该是有效的,而且在泄漏中它并没有“抱怨”绑定本身

ih99xse1

ih99xse11#

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.

相关问题