ComposeView内存泄漏:移除OnGlobalLayoutListener后仍泄漏求解答
键盘可见性监听Composable导致ComposeView内存泄漏问题
问题描述
实现了一个监听键盘可见性的Composable函数keyboardVisibilityAsState,通过DisposableEffect添加ViewTreeObserver.OnGlobalLayoutListener,并在onDispose中移除监听器。该Composable嵌套在Fragment的ComposeView中使用,退出Fragment时LeakCanary检测到ComposeView内存泄漏。
实现代码
@Composable fun keyboardVisibilityAsState(): State<Boolean> { val keyboardState = remember { mutableStateOf(false) } val view = LocalView.current DisposableEffect(view) { val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { val rect = Rect() view.getWindowVisibleDisplayFrame(rect) val screenHeight = view.rootView.height val keypadHeight = screenHeight - rect.bottom keyboardState.value = keypadHeight > screenHeight * 0.15 } view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } } return keyboardState } // 使用方式: val isKeyboardVisible by keyboardVisibilityAsState()
泄漏堆栈信息
┬─── │ GC Root: System class │ ├─ android.view.inputmethod.InputMethodManager class │ Leaking: NO (InputMethodManager↓ is not leaking and a class is never │ leaking) │ ↓ static InputMethodManager.sInstance ├─ android.view.inputmethod.InputMethodManager instance │ Leaking: NO (InputMethodManager is a singleton) │ ↓ InputMethodManager.mCurRootView │ ~~~~~~~~~~~~ ├─ android.view.ViewRootImpl instance │ Leaking: UNKNOWN │ Retaining 16.6 kB in 405 objects │ mContext instance of com.android.internal.policy.DecorContext, wrapping │ activity com.someapp.ui.home.HomeActivity with mDestroyed │ = false │ ViewRootImpl#mView is not null │ mWindowAttributes.mTitle = "com.someapp.uat/com.someapp. │ someapp.ui.home.HomeActivity" │ mWindowAttributes.type = 1 │ ↓ ViewRootImpl.mAttachInfo │ ~~~~~~~~~~~ ├─ android.view.View$AttachInfo instance │ Leaking: UNKNOWN │ Retaining 678.8 kB in 11728 objects │ ↓ View$AttachInfo.mTreeObserver │ ~~~~~~~~~~~~~ ├─ android.view.ViewTreeObserver instance │ Leaking: UNKNOWN │ Retaining 677.5 kB in 11691 objects │ ↓ ViewTreeObserver.mOnGlobalLayoutListeners │ ~~~~~~~~~~~~~~~~~~~~~~~~ ├─ android.view.ViewTreeObserver$CopyOnWriteArray instance │ Leaking: UNKNOWN │ Retaining 677.2 kB in 11677 objects │ ↓ ViewTreeObserver$CopyOnWriteArray.mData │ ~~~~~ ├─ java.util.ArrayList instance │ Leaking: UNKNOWN │ Retaining 677.2 kB in 11675 objects │ ↓ ArrayList[0] │ ~~~ ├─ com.someapp.common.compose.utils. │ KeyboardUtilsKt$keyboardVisibilityAsState$1$$ExternalSyntheticLambda0 │ instance │ Leaking: UNKNOWN │ Retaining 677.1 kB in 11673 objects │ ↓ KeyboardUtilsKt$keyboardVisibilityAsState$1$$ExternalSyntheticLambda0.f$0 │ ~~~ ├─ androidx.compose.ui.platform.AndroidComposeView instance │ Leaking: UNKNOWN │ Retaining 677.1 kB in 11669 objects │ View not part of a window view hierarchy │ View.mAttachInfo is null (view detached) │ View.mWindowAttachCount = 1 │ mContext instance of dagger.hilt.android.internal.managers. │ ViewComponentManager$FragmentContextWrapper, wrapping activity com. │ someapp.ui.home.HomeActivity with mDestroyed = false │ ↓ View.mParent │ ~~~~~~~ ╰→ androidx.compose.ui.platform.ComposeView instance Leaking: YES (ObjectWatcher was watching this because com. someapp.feature.featurea.ui.createpost. CreatePostFragment received Fragment#onDestroyView() callback (references to its views should be cleared to prevent leaks)) Retaining 1.6 kB in 29 objects key = ccfc3149-9a0c-464a-928a-8329be9aa408 watchDurationMillis = 12212 retainedDurationMillis = 7209 View not part of a window view hierarchy View.mAttachInfo is null (view detached) View.mWindowAttachCount = 1 mContext instance of dagger.hilt.android.internal.managers. ViewComponentManager$FragmentContextWrapper, wrapping activity com. someapp.ui.home.HomeActivity with mDestroyed = false METADATA Build.VERSION.SDK_INT: 33 Build.MANUFACTURER: samsung LeakCanary version: 2.12 App process name: com.someapp.uat Class count: 35246 Instance count: 261168 Primitive array count: 171598 Object array count: 39671 Thread count: 85 Heap total bytes: 40863658 Bitmap count: 30 Bitmap total bytes: 21878570 Large bitmap count: 0 Large bitmap total bytes: 0 Db 1: open /data/user/0/com.someapp.uat/databases/com.google.android. datatransport.events Db 2: closed /data/user/0/com.someapp. uat/databases/google_app_measurement_local.db Db 3: open /data/user/0/com.someapp.uat/databases/someapp_db Stats: LruCache[maxSize=3000,hits=133609,misses=253576,hitRate=34%] RandomAccess[bytes=12798605,reads=253576,travel=112908047953,range=43409570,size =57885308] Analysis duration: 411616 ms
原因分析
从泄漏堆栈可以看出,核心泄漏链是:
- 系统单例
InputMethodManager持有ViewRootImpl的引用; ViewRootImpl的AttachInfo关联着ViewTreeObserver;ViewTreeObserver的全局布局监听器列表中,仍保留着你添加的监听器实例;- 该监听器lambda隐式捕获了
AndroidComposeView(LocalView.current),而AndroidComposeView又持有ComposeView的引用; - 当Fragment执行
onDestroyView后,ComposeView本应被回收,但因上述引用链存在,无法被GC回收。
你在DisposableEffect的onDispose中调用了移除监听器的方法,但可能存在以下问题:
- ViewTreeObserver生命周期失效:当
View从窗口分离(mAttachInfo为null)后,对应的ViewTreeObserver可能已处于不可用状态,此时调用移除方法无法正确清理列表中的引用; - 监听器强引用捕获:lambda监听器强持有
view和keyboardState,即使你调用了移除方法,若ViewTreeObserver因InputMethodManager的持有未被回收,监听器实例会一直持有AndroidComposeView,进而导致ComposeView泄漏。
解决方案
方案1:改用Compose官方推荐的WindowInsets监听方式
放弃ViewTreeObserver实现,直接使用Compose提供的WindowInsets API,这是更稳定且无泄漏风险的方案:
@Composable fun keyboardVisibilityAsState(): State<Boolean> { val windowInsetsController = rememberWindowInsetsController() return remember { derivedStateOf { windowInsetsController.isVisible(WindowInsets.Type.ime()) } } }
方案2:修复现有ViewTreeObserver实现的泄漏问题
如果坚持使用原有实现,需确保监听器能被正确移除,并避免不必要的强引用:
- 在移除监听器前检查
ViewTreeObserver是否存活:
onDispose { if (view.viewTreeObserver.isAlive) { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } }
- 使用弱引用避免lambda强持有目标对象:
@Composable fun keyboardVisibilityAsState(): State<Boolean> { val keyboardState = remember { mutableStateOf(false) } val view = LocalView.current val weakView = remember { WeakReference(view) } val weakState = remember { WeakReference(keyboardState) } DisposableEffect(view) { val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { val currentView = weakView.get() ?: return@OnGlobalLayoutListener val currentState = weakState.get() ?: return@OnGlobalLayoutListener val rect = Rect() currentView.getWindowVisibleDisplayFrame(rect) val screenHeight = currentView.rootView.height val keypadHeight = screenHeight - rect.bottom currentState.value = keypadHeight > screenHeight * 0.15 } view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) onDispose { if (view.viewTreeObserver.isAlive) { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } } } return keyboardState }
方案3:在Fragment层面补充清理逻辑
在Fragment的onDestroyView方法中,手动清理ComposeView引用并隐藏软键盘,减少InputMethodManager对旧ViewRootImpl的持有:
override fun onDestroyView() { super.onDestroyView() binding.composeView?.let { ViewCompat.getWindowInsetsController(it)?.hide(WindowInsets.Type.ime()) it.disposeComposition() binding.composeView = null } }
内容的提问来源于stack exchange,提问作者Alvin Dizon
相关产品推荐
相关产品推荐

