Hello, I have a question regarding `unstable` / no...
# compose
s
Hello, I have a question regarding
unstable
/ not
skippable
item views in
LazyList
=> đź§µ
Given is a
LazyList
with some Item Views (
ProductListItemView
), which should load their data as they scroll into view. For that, a suspending lambda
getProduct
is passed in (e.g. from outer ViewModel).
Copy code
@Immutable
data class Product(
    val id: String,
    val name: String
)



@Composable
fun ProductListItemView(
    productId: String,
    modifier: Modifier = Modifier,
    
    // unstable bc of suspend function
    getProduct: suspend (productId: String) -> Result<Product>
) {
    val currentGetProduct by rememberUpdatedState(newValue = getProduct)
    var result by remember { mutableStateOf<Result<Product>>(Result.Loading) }

    LaunchedEffect(key1 = productId) {
        result = currentGetProduct(productId)
    }

    when(result) {
        is Loading -> {
            // Show Loading UI
        }

        Result.Success -> {
            // show success UI
        }
    }
}
I assumed this approach would be nice in terms of encapsulation and testing, but seemingly breaks the “skippability” of the composable (as seen in compose compiler report and metrics):
Copy code
restartable scheme("[androidx.compose.ui.UiComposable]") fun ProductListItemView(
    stable productId: String
    unstable getProduct: SuspendFunction1<@[ParameterName(name = 'productId')] String, Result<Product>>?
    ...
)
As far as I understand, this leads to more recompositions than I actually (wrongfully) assumed. I am wondering myself wether I am on the wrong foot with that approach (passing in a suspending function, and calling it in the LaunchedEffect)? Anyone knowing a better way to trigger a delayed external data load, e.g. when the ListItem comes into view?
Ideally the ProductListItemView should be “dumb” and get only passed an String ID - and a handle to fetch the full product for itself (which can be easily mocked in testing).
c
I'm pretty sure you don't need the
suspend
qualifier for your function in that param. All your composable needs to know is that it is a lambda. The actual declaration/implementation site of the function should be suspend so that inside your composable it does need to be in a coroutine scope. So if you remove the suspend keyword it should be what you're expecting
s
Hey, thanks for the suggestion, but I am afraid that the
suspend
keyword seems to be part of the method signature. If I remove it, at some point up the caller chain I hit the wall:
E.g. in a ViewModel, which has the actual implementation of the
getProduct
Method (which gets passed down to the composable) I can not remove the
suspend
that easily.
Copy code
suspend fun getProduct(productId: String): Result<Product> {
    // do actual stuff
}
c
Right. Compose doesn't need to know it's a suspend function in its params, but you also want the function to retain its signature i.e suspend. I would suggest an interface. Maybe something like this
Copy code
@Immutable
interface UiActions {
    suspend fun getProduct()
}

@Composable
fun Main() {
    content(viewModel)
}

@Composable
fun content(actions: UiActions) {
    LaunchedEffect(actions) {
       actions.getProduct()
    }
    
}
s
Interesting, I quickly tried it - but
fun bla()
and
suspend fun bla()
do not seem to go along. It is either both, interface and implementation to be
suspend
, or none of them. Or am I misunderstanding your suggestion? 🤔
c
You need to add
suspend
to your getProduct definition in your interface.
suspend fun getProduct(productId: String): Product
s
Ah got it. And pass the whole
UiActions
into the composable. That might work.
Thanks for the suggestion! I’ll try it. Curious to see if that would resolve it.
c
They both need to be declared suspend. The interface is the declaration the viewModel is the implementation. The interface is the obfuscation that you can pass to your composable so that it won't recompose a lot. It is important to mark your interface with the
@Immutable
annotation because interfaces aren't stable by default. Meaning it would make it not skippable
s
@Leland Richardson [G] I wonder if
suspend fun
lambdas should also be marked as stable here 🙂
l
to be clear, a suspend lambda is stable. i think the problem here is that the lambda isn’t getting memoized at the call site, so it is a different instance, and thus not skipping
this is an example of what we were talking about internally the other day, where we could potentially start memoizing lambdas like this. There are some other examples which might result in unintentional behavior, though this is an example where it would be doing what you’d expect.
can you share what the code that calls
ProductListItemView
looks like?
s
@Leland Richardson [G] looking at the compiler metrics I feel like it is NOT stable in this case. In compiler we also only check if the type is lambda type, but not suspend lambda if I read this right: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/compiler/compiler-hosted/src/main/java/androidx/compose/compiler/plugins/kotlin/analysis/Stability.kt;l=381?q=stabilityOf
s
@Leland Richardson [G] Regarding suspending functions not skippable: Sorry for not answering earlier, but here is a minimal reproducible example, which in imho shows, that suspending functions do not seem to be treated as skippable (Compose v 1.3.3) : •
MainViewModel
with a normal and a suspending function •
Greeting
Composable function that references those two as lambdas and uses them • ⇒ normal lambda is marked stable and skippable ✅ • ⇒ suspending lambda is marked unstable {thus not skippable?) ⚠️
Copy code
class MainViewModel : ViewModel() {
    fun getData(name: String): String {
        return "result: $name"
    }

    suspend fun getDataSuspending(name: String): String {
        delay(1000)
        return "result: $name"
    }
}


class MainActivity : ComponentActivity() {
    private val viewModel = MainViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Greeting(
                name = "Android",
                getDataSuspending = viewModel::getDataSuspending,
                getData = viewModel::getData,
                modifier = Modifier.fillMaxSize()
            )
        }
    }
}

@Composable
fun Greeting(
    name: String,
    modifier: Modifier = Modifier,
    getDataSuspending: suspend (String) -> String,
    getData: (String) -> String
) {
    val scope = rememberCoroutineScope()

    Column(modifier = modifier) {

        Button(onClick = { scope.launch { getDataSuspending(name) } }) {
            Text("Launch suspend function")
        }

        Button(onClick = { getData(name) }) {
            Text("Launch regular lambda")
        }
    }
}
Generated Metrics file:
Copy code
restartable scheme("[androidx.compose.ui.UiComposable]") fun Greeting(
  stable name: String
  stable modifier: Modifier? = @static Companion
  unstable getDataSuspending: SuspendFunction1<String, String>
  stable getData: Function1<String, String>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun GreetingPreview()
to be clear, a suspend lambda is stable. i think the problem here is that the lambda isn’t getting memoized at the call site, so it is a different instance, and thus not skipping
As far as I can see, this example shows that the (suspending) Lambda is the same instance (a viewmodel reference) and does not change, or am I mistaken in my assumptions?
cc @shikasd
s
I am not sure if calling
viewModel::someMethod
twice will yield the same instance, tbh You can check with ===
s
I suppose it will (but I might be wrong), since the viewmodel is the same instance. Also why is the normal lambda considered stable, but the suspending not? Both are accessed through method references.