ViewModel is cleared too early
bettercalltolya opened this issue · 7 comments
I encountered an issue with navigation in my app. Let's say I have Feature1
and Feature2
screens and both use getViewModel()
inside Content
function.
I navigate Feature2
-> [pop] back to Feature1
-> [push] open again Feature2
pretty fast.
What's happening in my logs: when I pop Feature2
screen, VM gets cleared in about 30ms, but screen is disposed in 600-700ms, so if then I push Feature2
again within these 600-700ms, I'm stuck on Feature2
screen with cleared VM.
I‘ve encounter a related issue without having to click fast: when using a transition animation when popping screen 2 the viewmodel gets cleaned up to early. The transition will call it.Content()
which will try to get the viewmodel but it was already cleared. This will result in a crash.
I used the latest version rc3
We have encountered a similar issue, also without clicking fast. ViewModels will be cleared before the animations are done, which will result in a ViewModel leak, as follows:
- Push ScreenA, ScreenA will create a ViewModelA
- Pop ScreenA, first all the lifecycle will be cleared with
ScreenLifecycleStore.clear(ScreenA)
, this will clear ViewModelA, then the exit animation will be played. - The exit animation will call ScreenA.Content() which will create a new ViewModelA (since a new ViewModelStore is created, since the previous one was already disposed in step 2).
- Once the exit animation is done, the ViewModelStore containing ViewModelA will never be cleared (since StepDisposableEffect will not clear the ScreenLifeCycleStore again for ScreenA), leaking the ViewModel.
This leads to some weird bugs in our application and should probably be fixed quickly. I might be able to create a PR once I have found a solution.
Our current workaround is to disable calling the content function on animations if the lifecycle is already destroyed:
content = {
if (LocalSavedStateRegistryOwner.current.lifecycle.currentState != Lifecycle.State.DESTROYED) {
it.Content()
}
},
The downside of that is having no content when navigating from one screen to another. But at least, the app behaves as expected.
Thanks. Having no contend on animations is not a solution for us unfortunately. I will try to find another workaround, maybe by disposing the screen manually after the animation completes and disabling Voyagers disposal.
This is a dealbreaker for us.
So after some research I have found a working workaround for the issue that also renders the screen during the animations. There is a couple of issues at play here:
- The
ViewModelStore
provided in the animations is always the one of theNavigator.lastItem
, for the content of both screens that are being animated, when they each should be rendered with their own respectiveViewModelStore
.
Say you start with a stack of[ScreenA, ScreenB]
, then you popScreenB
. During the animation, theViewModelStore
ofNavigator.lastItem
will be provided to bothScreenA
andScreenB
, which means theViewModelB
will be pushed into theViewModelStore
ofScreenA
.
This can be fixed by adding the following in the transition code:
ScreenTransition(
(...),
content = { screen ->
val lifecycleOwner = rememberScreenLifecycleOwner(screen)
val hooks = lifecycleOwner.getHooks()
CompositionLocalProvider(
*hooks.providers.toTypedArray(),
) {
screen.Content()
}
}
)
This fixes the first issue.
- When popping a Screen its
ViewModelStore
will be disposed immediately, before the transition animation completes, which is only okay if there are no transitions. If there are transitions, we have to dispose theViewModelStore
after the transition animation completes, so that the exiting ScreensViewModel
will not be recreated for the transition animation.
My current workaround for this is to disable the default dispose behavior of Voyager entirely and disposing by hand after the transition is completed. Unfortunately for this I have to copy the private Navigator.dispose(screen: Screen)
method that is internal to Voyager
. Also, listening to the end of the transition animation is currently a bit akward, I have not found a clean solution for this yet since Jetpack Compose doesn't provide a listener hook. So I am still looking for improvements in that regard.
I will probably post a complete workaround once I have fleshed it out.
In any case, this should probably be fixed in Voyager itself at some point, since the broken default behavior leads to very surprising bugs.
So after some research I have found a working workaround for the issue that also renders the screen during the animations. There is a couple of issues at play here:
- The
ViewModelStore
provided in the animations is always the one of theNavigator.lastItem
, for the content of both screens that are being animated, when they each should be rendered with their own respectiveViewModelStore
.
Say you start with a stack of[ScreenA, ScreenB]
, then you popScreenB
. During the animation, theViewModelStore
ofNavigator.lastItem
will be provided to bothScreenA
andScreenB
, which means theViewModelB
will be pushed into theViewModelStore
ofScreenA
.
This can be fixed by adding the following in the transition code:ScreenTransition( (...), content = { screen -> val lifecycleOwner = rememberScreenLifecycleOwner(screen) val hooks = lifecycleOwner.getHooks() CompositionLocalProvider( *hooks.providers.toTypedArray(), ) { screen.Content() } } )
This fixes the first issue.
- When popping a Screen its
ViewModelStore
will be disposed immediately, before the transition animation completes, which is only okay if there are no transitions. If there are transitions, we have to dispose theViewModelStore
after the transition animation completes, so that the exiting ScreensViewModel
will not be recreated for the transition animation.My current workaround for this is to disable the default dispose behavior of Voyager entirely and disposing by hand after the transition is completed. Unfortunately for this I have to copy the private
Navigator.dispose(screen: Screen)
method that is internal toVoyager
. Also, listening to the end of the transition animation is currently a bit akward, I have not found a clean solution for this yet since Jetpack Compose doesn't provide a listener hook. So I am still looking for improvements in that regard.I will probably post a complete workaround once I have fleshed it out. In any case, this should probably be fixed in Voyager itself at some point, since the broken default behavior leads to very surprising bugs.
Do you already have some code to share for the "waiting for animation to complete" problem? Thx.
Yes, you can do the following:
- Disposing after the screen animations are done:
ScreenTransition(
navigator = navigator,
transition = { ... },
content = { screen ->
val lifecycleOwner = rememberScreenLifecycleOwner(screen)
val hooks = lifecycleOwner.getHooks()
// Fixes a voyager issue: https://github.com/adrielcafe/voyager/issues/106#issuecomment-1441314987
CompositionLocalProvider(
*hooks.providers.toTypedArray(),
) {
screen.Content()
// Dispose ViewModels after the screen transition animation is done
transition.ListenerEffect(
onExitEnd = {
if (!navigator.items.contains(screen)) {
// Dispose inner Navigators if there are any
screen.getChildNavigators().forEach { navigator ->
navigator.items.forEach { childScreen ->
navigator.dispose(childScreen)
}
}
// Dispose the screen
navigator.dispose(screen)
}
},
)
}
},
)
Here you have to call the Navigator.dispose()
method via reflection because unfortunately it is currently internal.
- Calling
Navigator.dispose()
via reflection:
private fun Navigator.dispose(
screen: Screen,
) {
try {
callDispose(screen)
} catch (error: Throwable) {
... log this or not ...
}
}
private fun Navigator.callDispose(screen: Screen): Unit = javaClass.methods
.first { method ->
method.name.startsWith("dispose") && // Will also cover methods that have a generated name (with $ sign)
method.parameterCount == 1 &&
method.parameterTypes
.map { clazz: Class<*> -> clazz.isAssignableFrom(screen::class.java) }
.all { it }
}
.let { method ->
method.isAccessible = true
method.invoke(this, screen)
}
- To listen to the animation ending you will have to add some code too:
@Composable
public inline fun Transition<EnterExitState>.ListenerEffect(
noinline transitionSpec: @Composable Transition.Segment<EnterExitState>.() -> FiniteAnimationSpec<Int> = {
spring(visibilityThreshold = 1)
},
label: String = "ListenerEffect",
onEnterStart: () -> Unit = {},
onEnterEnd: () -> Unit = {},
onExitStart: () -> Unit = {},
onExitEnd: () -> Unit = {},
) {
// This slightly akward code might be improved in the future if there is a better solution:
// https://kotlinlang.slack.com/archives/CJLTWPH7S/p1677138472614839
val animationProgress by animateValue(Int.VectorConverter, transitionSpec, label) { enterExitState ->
when (enterExitState) {
EnterExitState.PreEnter -> 200
EnterExitState.Visible -> 100
EnterExitState.PostExit -> 0
}
}
when (animationProgress) {
200 -> onEnterStart()
100 -> when (currentState) {
EnterExitState.PreEnter -> when (targetState) {
EnterExitState.Visible -> onEnterEnd()
else -> Unit // ignore
}
EnterExitState.Visible -> when (targetState) {
EnterExitState.PostExit -> onExitStart()
else -> Unit // ignore
}
else -> Unit // ignore
}
0 -> onExitEnd()
}
}
The animation listener code might be improved, its a bit rudimentary but it works (see the slack thread in the comments).
Hope this helps you out!
Please note that the .childNavigators()
extension is also made by us but is too deeply integrated to just share. Basically we store the currently active Navigators in a place where non-compose can access it. That way we can find the child navigators of a particular screen.