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

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

原因分析

从泄漏堆栈可以看出,核心泄漏链是:

  1. 系统单例InputMethodManager持有ViewRootImpl的引用;
  2. ViewRootImpl的AttachInfo关联着ViewTreeObserver;
  3. ViewTreeObserver的全局布局监听器列表中,仍保留着你添加的监听器实例;
  4. 该监听器lambda隐式捕获了AndroidComposeView(LocalView.current),而AndroidComposeView又持有ComposeView的引用;
  5. 当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实现的泄漏问题

如果坚持使用原有实现,需确保监听器能被正确移除,并避免不必要的强引用:

  1. 在移除监听器前检查ViewTreeObserver是否存活:
onDispose {
    if (view.viewTreeObserver.isAlive) {
        view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
    }
}
  1. 使用弱引用避免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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.14 19:59:55