Jetpack compose when to create a new lambda instance in Composable? - android

Please see my code:
#Composable
fun RecomposeLambdaTest() {
var state by remember {
mutableStateOf("1")
}
val stateHolder = remember {
StateHolder()
}
Column {
Button(onClick = {
state += "1"
}) {
Text(text = "change the state")
}
OuterComposable(state = state) {
stateHolder// just a reference to the instance outer the scope
}
}
}
#Composable
fun OuterComposable(state: String, onClick: () -> Unit) {
LogUtil.d("lambda hashcode: ${onClick.hashCode()}")
Column {
Text(text = state)
Button(onClick = onClick) {
Log.d("Jeck", "compose 2")
Text(text = "Text")
}
}
}
//#Stable
class StateHolder{
private var b = 2
}
Every time I click button, OuterComposable recompose, and log the lambda hashcode——always different! It means that a new lambda instance is created when recompose, everytime
and I uncomment the code in StateHolder and make it look like:
#Stable
class StateHolder{
private var b = 2
}
Every time I click button, OuterComposable recompose, and log the lambda hashcode——always the same! It means that when recompose, Composer reuse the lambda
So what' s under the hood?
Edit:
Ok, make it easier, Let's change the code like this:
val stateHolder = remember {
2
}
the result is lambda is reused.
make val to var, the lambda is created when every recompose.
So I think I know that: If the lambda refenence a valuable outer scope and the valuable is not stable, recreate lambda every time.
So the question is:
Why Compose compiler do this?
Why Compiler think the StateHolder before is not stable, it only contains a private var!?

An author met the same question, here is his article——6 Jetpack Compose Guidelines to Optimize Your App Performance
He said, private property still affact stability, it seems it is a Google team's choice.

Related

Lambda function used as input argument causes recomposition

Consider snippet below
fun doSomething(){
}
#Composable
fun A() {
Column() {
val counter = remember { mutableStateOf(0) }
B {
doSomething()
}
Button(onClick = { counter.value += 1 }) {
Text("Click me 2")
}
Text(text = "count: ${counter.value}")
}
}
#Composable
fun B(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("click me")
}
}
Now when pressing "click me 2" button the B compose function will get recomposed although nothing inside it is got changed.
Clarification: doSomething is for demonstration purposes. If you insist on having a practical example you can consider below usage of B:
B{
coroutinScope.launch{
bottomSheetState.collapse()
doSomething()
}
}
My questions:
Why this lamda function causes recomposition
Best ways to fix it
My understanding of this problem
From compose compiler report I can see B is an skippable function and the input onClick is stable. At first I though its because lambda function is recreated on every recomposition of A and it is different to previous one. And this difference cause recomposition of B. But it's not true because if I use something else inside the lambda function, like changing a state, it won't cause recomposition.
My solutions
use delegates if possible. Like viewmode::doSomething or ::doSomething. Unfortunately its not always possible.
Use lambda function inside remember:
val action = remember{
{
doSomething()
}
}
B(action)
It seems ugly =)
3. Combinations of above.
When you click the Button "Click me 2" the A composable is recomposed because of Text(text = "count: ${counter.value}"). It happens because it recompose the scope that are reading the values that can change.
If you are using something like:
B {
Log.i("TAG","xxxx")
}
the B composable is NOT recomposed clicking the Button "Click me 2".
If you are using
B{
coroutinScope.launch{
Log.i("TAG","xxxx")
}
}
the B composable is recomposed.
When a State is read it triggers recomposition in nearest scope. And a scope is a function that is not marked with inline and returns Unit.
To use a coroutinScope you have to use rememberCoroutineScope that is a composable inline function. The the body of inline composable functions are simply copied into their call sites, such functions do not get their own recompose scopes.
To avoid it you can use:
B {
Log.i("TAG","xxxx")
}
and
#Composable
fun B(onClick: () -> Unit) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
onClick()
}
}
) {
Text(
"click me ",
)
}
}
Sources and credits:
Thracian's answer: Jetpack Compose Smart Recomposition
What is “donut-hole skipping” in Jetpack Compose? post: https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose
scoped recomposition: https://dev.to/zachklipp/scoped-recomposition-jetpack-compose-what-happens-when-state-changes-l78
You can use the LogCompositions composable described in the 2nd post to check the recomposition in your code.
Generally speaking, if you are using a property inside a lambda function that is unstable, it causes the child compose function unskippable and thus gets recomposed every time its parent gets recomposed. This is not something easily visible and you need to be careful with it. For example, the bellow code will cause B to get recomposed because coroutinScope is an unstable property and we are using it as an indirect input to our lambda function.
fun A(){
...
val coroutinScope = rememberCoroutineScope()
B{
coroutineScope.launch {
doSomething()
}
}
}
To bypass this you need to use remember around your lambda or delegation (:: operator). There is a note inside this video about it. around 40:05
There are many other parameters that are unstable like context. To figure them out you need to use compose compiler report.
Here is a good explanation about the why: https://multithreaded.stitchfix.com/blog/2022/08/05/jetpack-compose-recomposition/

Android Jetpack Compose: VM not updating data structure when modified

I’ve got a problem with a LazyColumn of elements that have a favourite button: basically when I tap the favourite button, the item that is being favourited (a document in my case) is changed in the underlying data structure in the VM, but the view isn’t updated, so I never see any change in the button state.
class MainViewModel(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) : ViewModel() {
var documentList = emptyList<PDFDocument>().toMutableStateList()
....
fun toggleFavoriteDocument(pdfDocument: PDFDocument) {
documentList.find {
it == pdfDocument
}?.let {
it.favorite = !it.favorite
}
}
}
The composables are:
#Composable
fun DocumentRow(
document: PDFDocument,
onDocumentClicked: (String, Boolean) -> Unit,
onFavoriteValueChange: (Uri) -> Unit
) {
HeartIcon(
isFavorite = document.favorite,
onValueChanged = { onFavoriteValueChange(document.uri) }
)
}
#Composable
fun HeartIcon(
isFavorite: Boolean,
color: Color = Color(0xffE91E63),
onValueChanged: (Boolean) -> Unit
) {
IconToggleButton(
checked = isFavorite,
onCheckedChange = {
onValueChanged()
}
) {
Icon(
tint = color,
imageVector = if (isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Default.FavoriteBorder
},
contentDescription = null
)
}
}
Am I doing something wrong? because when I call the toggleFavouriteDocument in the ViewModel, I see it’s marked or unmarked as favorite but there is no recomposition at all anywhere.
I might be missing it because you didn't post the rest of your code, but your documentList in the VM isn't observable, so how would the Composable know that it got changed? It needs to be something like Flow or LiveData, and it needs to be observed in the Composable. Something like this:
in ViewModel:
val documentList = MutableLiveData<List<PDFDocument>>()
in Composable:
val documentList by viewModel.documentList.observeAsState(List<PDFDocument>())
And you'll probably have to change the way you modify items in documentList. LiveData is weird about mutable collections inside MutableLiveData, and modifying individual items doesn't trigger a state change. You have to create a copy of the list with the modified items, and then re-port the whole list to the LiveData variable:
fun toggleFavoriteDocument(pdfDocument: PDFDocument) {
documentList.value?.let { oldList ->
// create a copy of existing list
val newList = mutableListOf<PDFDocument>()
newList.addAll(oldList)
// modify the item in the new list
newList.find {
it == pdfDocument
}?.let {
it.favorite = !it.favorite
}
// update the observable
documentList.postValue(newList)
}
}
Edit: There's also a potential problem with the way that you're trying to update the favorite value in the existing list. Without knowing how PDFDocument is implemented, I don't know if you can use the = operator. You should test that to make sure that newList.find { it == pdfDocument } actually finds the document

When I change ViewModel var, Composable Doesn't Update in Kotlin + Compose

When I change ViewModel variable, Composable Doesn't Update the View and I'm not sure what to do.
This is my MainActivity:
class MainActivity : ComponentActivity() {
companion object {
val TAG: String = MainActivity::class.java.simpleName
}
private val auth by lazy {
Firebase.auth
}
var isAuthorised: MutableState<Boolean> = mutableStateOf(FirebaseAuth.getInstance().currentUser != null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val user = FirebaseAuth.getInstance().currentUser
setContent {
HeroTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
if (user != null) {
Menu(user)
} else {
AuthTools(auth, isAuthorised)
}
}
}
}
}
}
I have a a View Model:
class ProfileViewModel: ViewModel() {
val firestore = FirebaseFirestore.getInstance()
var profile: Profile? = null
val user = Firebase.auth.currentUser
init {
fetchProfile()
}
fun fetchProfile() {
GlobalScope.async {
getProfile()
}
}
suspend fun getProfile() {
user?.let {
val docRef = firestore.collection("Profiles")
.document(user.uid)
return suspendCoroutine { continuation ->
docRef.get()
.addOnSuccessListener { document ->
if (document != null) {
this.profile = getProfileFromDoc(document)
}
}
.addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
}
}
}
}
And a Composable View upon user autentication:
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel = ProfileViewModel()
Column(
modifier = Modifier
.background(color = Color.White)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Signed in!");
ProfileVModel.profile?.let {
Text(it.username);
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = {
FirebaseAuth.getInstance().signOut()
context.startActivity(Intent(context, MainActivity::class.java))
}) {
Text(
color = Color.Black,
text = "Sign out?",
modifier = Modifier.padding(all = 8.dp)
)
}
}
}
}
When my Firestore method returns, I update the profile var, and "expect" it to be updated in the composable, here:
ProfileVModel.profile?.let {
Text(it.username);
}
However, nothing is changing?
When I was adding firebase functions from inside composable, I could just do:
context.startActivity(Intent(context, MainActivity::class.java))
And it would update the view. However, I'm not quite sure how to do this from inside a ViewModel, since "context" is a Composable-specific feature?
I've tried to look up Live Data, but every tutorial is either too confusing or differs from my code. I'm coming from SwiftUI MVVM so when I update something in a ViewModel, any view that's using the value updates. It doesn't seem to be the case here, any help is appreciated.
Thank you.
Part 1: Obtaining a ViewModel correctly
On the marked line below you are setting your view model to a new ProfileViewModel instance on every recomposition of your Menu composable, which means your view model (and any state tracked by it) will reset on every recomposition. That prevents your view model to act as a view state holder.
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel = ProfileViewModel() // <-- view model resets on every recomposition
// ...
}
You can fix this by always obtaining your ViewModels from the ViewModelStore. In that way the ViewModel will have the correct owner (correct lifecycle owner) and thus the correct lifecycle.
Compose has a helper for obtaining ViewModels with the viewModel() call.
This is how you would use the call in your code
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel: ProfileViewModel = viewModel()
// or this way, if you prefer
// val ProfileVModel = viewModel<ProfileViewModel>()
// ...
}
See also ViewModels in Compose that outlines the fundamentals related to ViewModels in Compose.
Note: if you are using a DI (dependency injection) library (such as Hilt, Koin...) then you would use the helpers provided by the DI library to obtain ViewModels.
Part 2: Avoid GlobalScope (unless you know exactly why you need it) and watch out for exceptions
As described in Avoid Global Scope you should avoid using GlobalScope whenever possible. Android ViewModels come with their own coroutine scope accessible through viewModelScope. You should also watch out for exceptions.
Example for your code
class ProfileViewModel: ViewModel() {
// ...
fun fetchProfile() {
// Use .launch instead of .async if you are not using
// the returned Deferred result anyway
viewModelScope.launch {
// handle exceptions
try {
getProfile()
} catch (error: Throwable) {
// TODO: Log the failed attempt and/or notify the user
}
}
}
// make it private, in most cases you want to expose
// non-suspending functions from VMs that then call other
// suspend factions inside the viewModelScope like fetchProfile does
private suspend fun getProfile() {
// ...
}
// ...
}
More coroutine best practices are covered in Best practices for coroutines in Android.
Part 3: Managing state in Compose
Compose tracks state through State<T>. If you want to manage state you can create MutableState<T> instances with mutableStateOf<T>(value: T), where the value parameter is the value you want to initialize the state with.
You could keep the state in your view model like this
// This VM now depends on androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
class ProfileViewModel: ViewModel() {
var profile: Profile? by mutableStateOf(null)
private set
// ...
}
then every time you would change the profile variable, composables that use it in some way (i.e. read it) would recompose.
However, if you don't want your view model ProfileViewModel to depend on the Compose runtime then there are other options to track state changes while not depending on the Compose runtime. From the documentation section Compose and other libraries
Compose comes with extensions for Android's most popular stream-based
solutions. Each of these extensions is provided by a different
artifact:
Flow.collectAsState() doesn't require extra dependencies. (because it is part of kotlinx-coroutines-core)
LiveData.observeAsState() included in the androidx.compose.runtime:runtime-livedata:$composeVersion artifact.
Observable.subscribeAsState() included in the androidx.compose.runtime:runtime-rxjava2:$composeVersion or
> androidx.compose.runtime:runtime-rxjava3:$composeVersion artifact.
These artifacts register as a listener and represent the values as a
State. Whenever a new value is emitted, Compose recomposes those parts
of the UI where that state.value is used.
This means that you could also use a MutableStateFlow<T> to track changes inside the ViewModel and expose it outside your view model as a StateFlow<T>.
// This VM does not depend on androidx.compose.runtime.* anymore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ProfileViewModel : ViewModel() {
private val _profileFlow = MutableStateFlow<Profile?>(null)
val profileFlow = _profileFlow.asStateFlow()
private suspend fun getProfile() {
_profileFlow.value = getProfileFromDoc(document)
}
}
And then use StateFlow<T>.collectAsState() inside your composable to get the State<T> that is needed by Compose.
A general Flow<T> can also be collected as State<T> with Flow<T : R>.collectAsState(initial: R), where the initial value has to be provided.
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel: ProfileViewModel = viewModel()
val profile by ProfileVModel.profileFlow.collectAsState()
Column(
// ...
) {
// ...
profile?.let {
Text(it.username);
}
// ...
}
}
To learn more about working with state in Compose see the documentation section on Managing State. This is fundamental information to be able to work with state in Compose and trigger recompositions efficiently. It also covers the fundamentals of state hoisting. If you prefer a coding tutorial here is the code lab for State in Jetpack Compose.
An introduction to handling the state as the complexity increases is in the video from Google about Using Jetpack Compose's automatic state observation.
Profile in view model should be State<*>
private val _viewState: MutableState<Profile?> = mutableStateOf(null)
val viewState: State<Profile?> = _viewState
In composable
ProfileVModel.profile.value?.let {
Text(it.username);
}
I recommend using MutableStateFlow.
a simple sample is described in this Medium article :
https://farhan-tanvir.medium.com/stateflow-with-jetpack-compose-7d9c9711c286

Android Compose how to update other Composable function better way?

Screenshot
I just wanna click button can log ComposeableB().or liek this , For example, if you click ComposableA, ComposableB will start an animation instead of updating the data.
Although with Compose it is generally recommended to pass events to the app logic (like the ViewModel) instead of to the app UI (Thinking in Compose), here's how your code could look like if you really need to do that:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
#Composable
fun ComposableA() {
var addLogEntry by remember { mutableStateOf(false) }
Column {
Button(onClick = {
addLogEntry = true
}) {
Text(text = "Log")
}
ComposableB(addLogEntry = addLogEntry) {
addLogEntry = false
}
}
}
#Composable
fun ComposableB(
addLogEntry: Boolean,
onLogEntryAdded: () -> Unit
) {
if (addLogEntry) {
Log.d("Shadowmeld", "onAddLogEntry")
onLogEntryAdded()
}
}
Here you are passing a function as a second parameter (onLogEntryAdded, in lambda expression format) to ComposableB. This passed lambda expression will be called from ComposableB to modify state in ComposableA.
I believe there are better ways of doing this, like ComposableB being declared inside ComposableA (hoisting state to ComposableA) or, if that is not an option, passing the Button onClick event to a ViewModel that both ComposableA and ComposableB can observe.

Composable reparenting in Jetpack Compose

Is there a way to reparent a Composable without it losing the state? The androidx.compose.runtime.key seems to not support this use case.
For example, after transitioning from:
// This function is in the external library, you can not
// modify it!
#Composable
fun FooBar() {
val uid = remember { UUID.randomUUID().toString() }
Text(uid)
}
Box {
Box {
FooBar()
}
}
to
Box {
Row {
FooBar()
}
}
the Text will show a different message.
I'm not asking for ways to actually remember the randomly generated ID, as I could obviously just move it up the hierarchy. What I want to archive is the composable keeping its internal state.
Is this possible to do without modifying the FooBar function?
The Flutter has GlobalKey specifically for this purpose. Speaking Compose that might look something like this:
val key = GlobalKey.create()
Box {
Box {
globalKey(key) {
FooBar()
}
}
}
Box {
Row {
globalKey(key) {
FooBar()
}
}
}
This is now possible with
movableContentOf
See this example:
val boxes = remember {
movableContentOf {
LetterBox(letter = 'A')
LetterBox(letter = 'B')
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { isRow = !isRow }) {
Text(text = "Switch")
}
if (isRow) {
Row(
Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
boxes()
}
} else {
Column(
Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
boxes()
}
}
}
remember will store only one value in the same view. The key in Compose has a very different purpose: if the key passed to remember has a different value from the last recomposition, it means that the old value is no longer relevant and must be recomputed.
There is no direct equivalent of Flutter keys in Compose.
You can simply declare a global variable. In case you need to change it, wrap it with a mutable state, so changes will update your view.
var state by mutableStateOf(UUID.randomUUID().toString())
I'm not sure if that the same what GlobalKey does, in any case it's not the best practice, just like any other global variable.
If you need to share some data between views, it is much cleaner to use view models.
#Composable
fun TestScreen() {
val viewModel = viewModel<SomeViewModel>()
Column {
Text("TestScreen text: ${viewModel.state}")
OtherView()
}
}
#Composable
fun OtherView() {
val viewModel = viewModel<SomeViewModel>()
Text("OtherScreen text: ${viewModel.state}")
}
class SomeViewModel: ViewModel() {
var state by mutableStateOf(UUID.randomUUID().toString())
}
The hierarchy topmost viewModel call creates a view model - in my case inside TestScreen. All children that call viewModel of the same class will get the same object. The exception to this is different destinations of Compose Navigation, see how to handle this case in this answer.
You can update the mutable state value, and it will be reflected on all views using that model. Check out more about state in Compose.
When the view that created the view model is removed from the view hierarchy, the view model is also freed, so a new one will be created next time.

Categories

Resources