Hello everyone, I noticed a very weird Compose beh...
# compose
s
Hello everyone, I noticed a very weird Compose behaviour with a Hilt-provided
ViewModel
when the reference of the VM is used in a lambda, like in
confirmValueChange
of
rememberModalBottomSheetState
. For example when I use the VM reference like this
Copy code
val sheetState = rememberModalBottomSheetState(
    confirmValueChange = {
        viewModel.someCallback()
        true
    }
)
the bottom sheet will never show when I call
sheetState.show()
. However when I use
rememberUpdatedState
on the VM the bottom sheet works.
Copy code
val updatedViewModel by rememberUpdatedState(viewModel)
val sheetState = rememberModalBottomSheetState(
    confirmValueChange = {
        updatedViewModel.someCallback()
        true
    }
)
I confirmed that
viewModel
is always the same instance, even after recomposition, so this is not the problem. The VM is provided with
hiltViewModel()
in a
NavHost
. When I change this to a “normal” VM without Hilt injection, the bottom sheet works even without
rememberUpdatedState
! This is all very confusing. What is even more confusing is that I can reproduce this in a small sample project but sometimes the variant without
rememberUpdatedState
works, sometimes when I recompile the app it doesn’t 🤷🏼 The relevant code can be found in MainScreen. Has anyone else encountered this strange and random behaviour?
No one has any idea what the problem could be? 😢 If this is a bug of
androidx.hilt:hilt-navigation-compose
, where would I best report it?
v
I probably know what's going on. Just a second, I will confirm
Actually, not sure. It seems to change randomly between rebuilds as you said 😅
s
Yes, it is a really weird bug. What was your initial thought?
v
My theory was that your ModalBottomSheetState is being recreated on every recomposition because the lambda instance you pass to confirmValueChange changes on each recomposition. So you open the sheet on button click, but on next recomposition the sheetstate is a new object which was not expanded
I'm not sure when new lambda instances are actually created and if this would be the case
Hmm I added some logging and it would seem that my theory is correct. The lambda is getting recreated. Not sure why though 😅 The inconsistency between rebuilds might be due to the compiler for some reason not recompiling the lambda class for some reason. If you change code inside the lambda (like a log line) it should be consistent
Copy code
val lambda = { _: SheetValue ->
        viewModel.someCallback()
//            updatedViewModel.someCallback()
        true
    }
    Log.v("test", "lambda is ${lambda.hashCode()}")
Copy code
11:00:08.163 test                    com.svenjacobs.sample     V  lambda is 161472184
11:00:09.041 test                    com.svenjacobs.sample     V  lambda is 128193555
11:00:13.019 test                    com.svenjacobs.sample     V  lambda is 92163513
11:00:13.504 test                    com.svenjacobs.sample     V  lambda is 220277346
adding
@Stable
to the viewmodel class, `remember`ing the lambda (or using `rememberUpdatedState`:
Copy code
11:01:14.804 test                    com.svenjacobs.sample     V  lambda is 161472184
11:01:15.208 test                    com.svenjacobs.sample     V  lambda is 161472184
11:01:16.189 test                    com.svenjacobs.sample     V  lambda is 161472184
11:01:16.572 test                    com.svenjacobs.sample     V  lambda is 161472184
The reason you saw it working with
rememberUpdatedState
is that the lambda object will actually capture the state object (which is stable), not the viewmodel object itself.
I'm not sure how the compose compiler actually interacts with creating lambdas that reference unstable values, I thought it would only affect skipping and not actual lambda recreation. Someone with knowledge of the compiler itself can probably elaborate here 😅
s
Hm, thanks for your investigation. But why is it still a problem if the
viewModel
instance itself never changes? And why does it behave differently once Hilt is involved? If you replace
hiltViewModel()
with
viewModel()
and use the other non-Hilt ViewModel class of the sample project everything works 🤷🏼
v
I'm not sure how non-hilt viewmodels work but that doesn't seem to work for me either. Lambda is still recreated
s
But can you reproduce the bottom sheet issue with the non-Hilt VM?
v
yes
🤯 1
and that's actually what I would expect
s
Just to be clear: You replaced any reference of
MainHiltViewModel
with
MainViewModel
and used
viewModel()
in
MainActivity
?
Nevermind, I can now reproduce this with the normal VM, too.
Copy code
val lambda = { _: SheetValue ->
        viewModel.someCallback()
//            updatedViewModel.someCallback()
        true
    }
    Log.v("test", "lambda is ${lambda.hashCode()}")
Where did you put this code? Because
val lambda = …
will of course be evaluated on recomposition but is it actually the lambda instance that is used in
rememberModalBottomSheetState()
?
v
Copy code
val lambda = { _: SheetValue ->
        viewModel.someCallback()
//            updatedViewModel.someCallback()
        true
    }
    Log.v("test", "lambda is ${lambda.hashCode()}")
    val sheetState = rememberModalBottomSheetState(
        skipPartiallyExpanded = true,
        confirmValueChange = lambda
    )
s
Yeah, I don’t this this is right. I think this is not how you can test whether the lambda is recreated because for this code it 100% certainly is.
If you follow the implementation of
rememberModalBottomSheetState
you will see that no keys are used for the
rememberSaveable
call so the same state should be returned regardless of the
confirmValueChange
argument.
But
sheetState
changes on every recomposition once
viewModel
is used inside the lambda. This is unexpected!
Copy code
val sheetState = rememberModalBottomSheetState(
    skipPartiallyExpanded = true,
    confirmValueChange =  {
        viewModel.someCallback()
        true
    }
)

SideEffect {
    Log.d("XXX", "${sheetState.hashCode()}")
}
v
Copy code
return rememberSaveable(
        skipPartiallyExpanded, confirmValueChange,
the keys are definitely here? 🤔
SheetDefault.kt, rememberSheetState
s
Oh, you’re right. I mixed up
inputs
and
key
argument
Anyway, the state is recreated once
viewModel
is referenced inside the lambda although the instance is always the same. This is unexpected, right?
Copy code
val sheetState = rememberModalBottomSheetState(
    skipPartiallyExpanded = true,
    confirmValueChange = {
        viewModel.someCallback()
        true
    }
)

SideEffect {
    Log.d("XXX", "VM ${viewModel.hashCode()}")
    Log.d("XXX", "STATE ${sheetState.hashCode()}")
}
Copy code
D  VM 67021652
D  STATE 43694845
D  VM 67021652
D  STATE 240805241
D  VM 67021652
D  STATE 12137239
D  VM 67021652
D  STATE 207944826
v
yes, that is the cause of the end result you are seeing (sheet staying closed)
However, I am not very familiar with how kotlin lambdas are implemented and how the compose compiler deals with them 😅
s
Either adding
@Stable
to the ViewModel class or using a reference of the callback function fixes this behaviour. But I have not seen the recommendation of adding
@Stable
to a ViewModel before?!
Copy code
val callback = viewModel::someCallback
val sheetState = rememberModalBottomSheetState(
    skipPartiallyExpanded = true,
    confirmValueChange = {
        callback()
        true
    }
)
v
want to hear something even more confusing?
When running
./gradlew :app:buildDebug --rerun-tasks
and checking compose metrics, the viewmodel is reported as runtime stable by compose metrics:
Copy code
stable class MainHiltViewModel {
  <runtime stability> = Stable
}
... and it works (returns the same SheetState instance)
But if I rebuild the same code in Android Studio, it doesn't work (doesn't return the same SheetState instance) 😅 (And for some reason also no compose metrics output by the compiler)
s
what the…?! 🤯
v
Well actually not the exact same code, I modify a log statement inside the lambda.
See if you can reproduce this? You can also use Build -> Rebuild project in Android Studio instead of using gradle on the command line (modified my earlier message to include the
--rerun-tasks
I forgot to write.)
this is very strange indeed
with
--rerun-tasks
(or rebuild):
Copy code
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MainScreen(
  stable viewModel: MainViewModel
  stable modifier: Modifier? = @static Companion
)
after modifying a log line in the lambda and rebuilding:
Copy code
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MainScreen(
  viewModel: MainViewModel
  stable modifier: Modifier? = @static Companion
)
s
Yeah, rebuild from AS and it works… 🤯
v
notice how the stable annotation disappears from
MainViewModel
s
Weird… so this could be a bug with the Compose compiler or Android Studio because it only happens in AS? I hope someone from Google sees this 👀
v
I don't think it's a bug in android studio, it has something to do with the fact that it's doing an incremental build vs from scratch. It fails to infer stability when doing an incremental build for some reason
👍🏼 1
Does this seem like a bug or expected behaviour in the compiler? There's a case of stability changing depending on doing a clean build vs incremental build: with `--rerun-tasks`:
Copy code
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MainScreen(
  stable viewModel: MainViewModel
  stable modifier: Modifier? = @static Companion
)
by rebuilding:
Copy code
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MainScreen(
  viewModel: MainViewModel
  stable modifier: Modifier? = @static Companion
)
plus1 1
s
Thanks for your help so far @vide. It’s good to have someone else look over this and confirm that I’m not going crazy 😉
v
No problem 😄 I'm trying to understand the internals of compose myself and think trying to solve these mysterious issues is the best way to learn while doing
s
Me too 😅 and I always wanted to read Compose Internals by Jorge Castillo but unfortunately haven’t found the time yet.
v
Here's some decompiled bytecode. This is after a clean build (
--rerun-tasks
). •
var0: MainViewModel
var2: Composer
Copy code
var2.startReplaceableGroup(1157296644);
         ComposerKt.sourceInformation(var2, "CC(remember)P(1):Composables.kt#9igjgp");
         boolean var8 = var2.changed(var0);
         var9 = var2.rememberedValue();
         if (var8 || var9 == Composer.Companion.getEmpty()) {
            var9 = (Function1)(new Function1(var0) {
               final MainViewModel $viewModel;

               {
                  this.$viewModel = var1;
               }

               public final Boolean invoke(SheetValue var1) {
                  Intrinsics.checkNotNullParameter(var1, "<anonymous parameter 0>");
                  this.$viewModel.someCallback();
                  Log.v(LiveLiterals$MainScreenKt.INSTANCE.String$arg-0$call-v$fun-$anonymous$$val-lambda$fun-MainScreen(), LiveLiterals$MainScreenKt.INSTANCE.String$arg-1$call-v$fun-$anonymous$$val-lambda$fun-MainScreen());
                  Log.v(LiveLiterals$MainScreenKt.INSTANCE.String$arg-0$call-v-1$fun-$anonymous$$val-lambda$fun-MainScreen(), LiveLiterals$MainScreenKt.INSTANCE.String$arg-1$call-v-1$fun-$anonymous$$val-lambda$fun-MainScreen());
                  Log.v(LiveLiterals$MainScreenKt.INSTANCE.String$arg-0$call-v-2$fun-$anonymous$$val-lambda$fun-MainScreen(), LiveLiterals$MainScreenKt.INSTANCE.String$arg-1$call-v-2$fun-$anonymous$$val-lambda$fun-MainScreen());
                  return LiveLiterals$MainScreenKt.INSTANCE.Boolean$fun-$anonymous$$val-lambda$fun-MainScreen();
               }
            });
            var2.updateRememberedValue(var9);
         }

         var2.endReplaceableGroup();
So this is what happens: Kotlin generates a new instance of the lambda per every function execution (as expected), but the compose compiler generates code around it to automatically remember it, and it only regenerates the function if the captured value (MainViewModel) has changed between executions. That explains why the instance is same.
lets compare this to decompiled bytecode after doing a rebuild without a clean start
Here's the same lambda from an incremental build (changed 1 character in the
Log.v()
call). No generated
rememberedValue
&
updateRememberedValue
.
Copy code
Function1 var13 = (Function1)(new Function1(var0) {
            final MainViewModel $viewModel;

            {
               this.$viewModel = var1;
            }

            public final Boolean invoke(SheetValue var1) {
               Intrinsics.checkNotNullParameter(var1, "<anonymous parameter 0>");
               this.$viewModel.someCallback();
               Log.v(LiveLiterals$MainScreenKt.INSTANCE.String$arg-0$call-v$fun-$anonymous$$val-lambda$fun-MainScreen(), LiveLiterals$MainScreenKt.INSTANCE.String$arg-1$call-v$fun-$anonymous$$val-lambda$fun-MainScreen());
               Log.v(LiveLiterals$MainScreenKt.INSTANCE.String$arg-0$call-v-1$fun-$anonymous$$val-lambda$fun-MainScreen(), LiveLiterals$MainScreenKt.INSTANCE.String$arg-1$call-v-1$fun-$anonymous$$val-lambda$fun-MainScreen());
               Log.v(LiveLiterals$MainScreenKt.INSTANCE.String$arg-0$call-v-2$fun-$anonymous$$val-lambda$fun-MainScreen(), LiveLiterals$MainScreenKt.INSTANCE.String$arg-1$call-v-2$fun-$anonymous$$val-lambda$fun-MainScreen());
               return LiveLiterals$MainScreenKt.INSTANCE.Boolean$fun-$anonymous$$val-lambda$fun-MainScreen();
            }
         });
If the compiler infers the captured value is unstable then I guess it doesn't make sense to generate the optimization either 😅
👍🏼 1
s
I reported the issue here.
v
I somehow missed that this was reported and fixed. Thanks for the effort and happy that it is fixed now!
✌🏼 1