返回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() } } } }
原因分析
- 导航逻辑错误:RegisterFragment中返回登录页使用
navigateTo(R.id.loginFragment),会在导航栈中添加新的LoginFragment实例,旧的RegisterFragment仍保留在栈内无法销毁。 - SavedViewState持有View引用:未销毁的RegisterFragment的
mSavedViewState保存了View层级状态,其中PasswordTransformationMethod的内部类持有TextView引用,导致整个View树(ConstraintLayout、TextInputLayout等)无法被GC回收,引发泄漏。 - 临时方案生效原因:将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
相关产品推荐
相关产品推荐

