Reputation: 2019
So I have this Composable
that I use to detect if a keyboard is visible:
@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
}
...
// usage:
val isKeyboardVisible by keyboardVisibilityAsState()
It's being used inside another Composable, which in turn is used in a Fragment via ComposeView
. Whenever I exit from the Fragment that uses this Composable, LeakCanary flags my Composable as the source of a leak, specifically:
┬───
│ 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
I was just wondering why I'm still getting this leak even if I specifically remove the listener during onDispose. Anyone experienced something similar?
Upvotes: 1
Views: 593
Reputation: 6703
I found an answer to your question from this answer on SO where initially it had exact code as you have, but later with the comment from m.myalkin it was corrected to address the memory leak issues.
Basically @m.mmyalkin says that:
ViewTreeObserver observer is not guaranteed to remain valid for the lifetime of this View
Take a look at his gist
You should end up with the code like this one below:
@Composable
fun keyboardVisibilityAsState(): State<Boolean> {
val keyboardState = remember { mutableStateOf(false) }
val view = LocalView.current
val viewTreeObserver = view.viewTreeObserver
DisposableEffect(viewTreeObserver) { // <--- NOTICE the key is now viewTreeObserver
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
keyboardState.value = ViewCompat
.getRootWindowInsets(view)
?.isVisible(WindowInsetsCompat.Type.ime()) ?: true
}
viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
}
}
return keyboardState
}
Upvotes: 0