You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

返回Fragment时savedInstanceState引发内存泄漏问题求助

内存泄漏问题分析与解决(Login/RegisterFragment场景)

问题场景

开发的Android应用包含LoginFragment和RegisterFragment,启动默认显示登录页。用户跳转至注册页完成账号创建后返回登录页时,出现内存泄漏。测试发现将LoginFragment的onViewCreated方法中savedInstanceState参数替换为null后,泄漏不再发生。

LeakCanary检测日志

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

相关代码

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

原因分析

  1. 导航逻辑错误:RegisterFragment中返回登录页使用navigateTo(R.id.loginFragment),会在导航栈中添加新的LoginFragment实例,旧的RegisterFragment仍保留在栈内无法销毁。
  2. SavedViewState持有View引用:未销毁的RegisterFragment的mSavedViewState保存了View层级状态,其中PasswordTransformationMethod的内部类持有TextView引用,导致整个View树(ConstraintLayout、TextInputLayout等)无法被GC回收,引发泄漏。
  3. 临时方案生效原因:将LoginFragment的onViewCreated参数savedInstanceState设为null,阻止了状态恢复流程,间接让GC有机会回收RegisterFragment的View层级,但未解决根本问题。

解决方案

1. 修复导航逻辑(根本解决)

将RegisterFragment中返回登录页的逻辑改为navigateUp(),弹出当前RegisterFragment回到原LoginFragment,避免栈中积累无用实例:

// 修改RegisterFragment的setUpTextRedirection方法
private fun setUpTextRedirection() {
    binding.logInTV.setOnClickListener {
        navigateUp()
    }
}

// 修改RegisterFragment的observeRegisterEvents方法
private fun observeRegisterEvents() {
    viewModel.registerEvent.observe(viewLifecycleOwner) { result ->
        if(result.isSuccess) {
            navigateUp()
        } else {
            Toast.makeText(requireContext(), "Something went wrong", Toast.LENGTH_SHORT).show()
        }
    }
}

2. 手动清理SavedViewState(辅助优化)

在RegisterFragment的onDestroyView中手动清除SavedViewState,确保View引用被释放:

override fun onDestroyView() {
    super.onDestroyView()
    setSavedViewState(null)
}

3. 临时规避方案(可选)

若无需LoginFragment恢复状态,可在onViewCreated中传入null给super调用,但优先推荐修复导航逻辑:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, null)
    setUpLogInButton()
    setUpTextRedirection()
    observeForLoginEvents()
}

内容的提问来源于stack exchange,提问作者JustSightseeing

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.10 22:45:37