Hey :wave: I finished part of the migration to Dec...
# decompose
n
Hey 👋 I finished part of the migration to Decompose of my nav-component App. More details here. I’m experiencing something that I don’t know if it’s expected. When I push a component on top of another and then go back, the Composable of the first screen gets recreated. This causes a moment of white screen that is not desirable. With the nav component, when going back the previous screen is untouched and immediately shows the previous state.
a
I believe with the classic
navigation-compose
, the previous composable leaves the composition, and then enters again. So all
remember
-ed objects are lost.
It would be good to see a video.
n
I’m quite sure this is something I did wrongly, tbh. The first video is of the navigation component and the second one is of Decompose. Thanks for the help, Arkadii.
a
Yeah, the issue seems with the state being updated asynchronously. Not sure how do you store the state in the original case. But with Decompose you can store your state in the Component, which is not destroyed in the back stack.
n
I’m using an MviKotlin Store to store the state.
a
Nice. Then the state should be there. It would be nice to see some code sample of the first screen.
The second screen is also blinking
n
Yes, but that’s expected because the data is fetched from the network every time.
a
In the original case, there is at least some data visible immediately.
n
Regarding the code, here’s how I instantiate my stores in my Blocs:
Copy code
abstract class CompleteBloc<Intent : Any, State : Any, Event : Any, Label : Any>(
    mainContext: CoroutineContext,
    componentContext: ComponentContext,
    storeFactory: () -> Store<Intent, State, Label>,
) : ComponentContext by componentContext,
    Bloc<Intent, State, Event> {

    protected val store = componentContext.instanceKeeper.getStore(storeFactory)
    override val states = store.stateFlow
    private val _events = MutableSharedFlow<Event>()
    override val events = _events
    private val scope = coroutineScope(mainContext + SupervisorJob())
The Store is a normal MviKotlin store and the Compose is:
Copy code
@Composable
fun FeedScreen(
    bloc: FeedBloc,
    showSnackbar: (String) -> Unit,
) {
    val state by bloc.states.collectAsStateWithLifecycle()
    val feedItems = bloc.feedFlow.collectAsLazyPagingItems()
Let me know if this is everything you need.
In the original case, there is at least some data visible immediately.
Oh, you are right. Same thing is happening there.
a
You can check https://github.com/IlyaGulya/TodoAppDecomposeMviKotlin This sample has Decompose + MVIKotlin + Reaktive. You can just ignore Reaktive being used instead of coroutines.
Ok, I suspect this is due to
bloc.states.collectAsStateWithLifecycle
being used.
Can you try just
collectAsState
?
n
Sure, let me try
a
Btw, it doesn't make any significant difference with MVIKotlin, because the Store is hot.
n
Oh, good to know!
Same behaviour with
collectAsState
. This is Compose Multiplatform, btw.
👍 1
a
Also, it's very important to collect Flows on Main.immediate dispatchers, when rendering with Compose
Here are a few things to check as well. 1. You can try adding
lifecycle.doOnDestroy { println("Destroyed: $this" }
and make sure that the component is not destroyed for any reason when moved to the back stack. 2. Try collecting the flow on
Main.immediate
dispatcher.
n
Oh, I thought that only mattered when using TextField
Same thing with
Main.immediate
👍 . Let me debug the destruction of the component.
👍 1
a
Btw, maybe the issue is with
bloc.feedFlow.collectAsLazyPagingItems()
? Not sure what is it.
It's not clear how exactly do you render the screen, what flows are being used.
n
That's for the paging3 library. The feedFlow is just a map over the states flow.
So everything is coming from the states flow.
👍 1
No, the Bloc/Component for the list screen is not being destroyed.
That’s weird… 😞.
a
Then maybe add logs right in the Composable function and see how the state comes?
n
Yep, let me try! 🙇
Something is happening there for sure. Each time I go back to the screen, the Composable is recomposed several times, the first time the state is sending 0 items.
And that causes the blink for sure.
a
Oh, in this case you should check what's going on in your Store. You try enabling logging, as described in the docs.
n
On it, thanks 🙇
Ok, found something that may light a bulb: The
states
flow being exposed from the
Bloc
is just:
store.stateFlow
(I’m not doing any mapping between store and component model) The state is storing some
PagingData<T>
in the state and the thing is that
collectAsLazyPagingItems
is an extension of
Flow<PagingData<T>>
. So I need to expose also the flow of paging data directly to the Composable. That’s why the Bloc has two flows exposed:
Copy code
val states = store.stateFlow //Flow<State>
val feedFlow = store.stateFlow.map { it.feed } // Flow<PagingData<T>>
The Composable is then collecting both (it’s the same flow but the second one is just a map):
Copy code
val state by bloc.states.collectAsState(Dispatchers.Main.immediate)
val feedItems = bloc.feedFlow.collectAsLazyPagingItems(Dispatchers.Main.immediate)
This was working perfectly when I wasn’t using Decompose components. But now this causes something weird with the recomposition because if I comment out the first line and I only observe the feed items it works as expected.
a
So this started to happen after converting to MVIKotlin. Indeed, the paging3 library doesn't work well with MVI. And actually I believe there is usually no need for the paging library. The following approach worked for me just fine multiple times.
Copy code
data class State(
  val items: List<Item>,
  val isLoadingMore: Boolean,
)

sealed class Intent {
  object LoadMore : Intent()
}
Then in your Executor, when you receive
LoadMore
, just load the next page something like this:
Copy code
if (state.isLoadingMore) {
  return
}

dispatch(Msg.LoadingMoreStarted)
val items = repository.loadMore(offset = state.items.size())
dispatch(Msg.LoadedMore(items))
Don't forget to handle Msgs and switch the
isLoadingMore
flag.
n
So this started to happen after converting to MVIKotlin.
Sorry for not being clear! I was using MVIKotlin with the nav component. I’m currently just migrating to Decompose.
a
Oh, then I don't see how Decompose could affect. It's just a holder for your Store, similarly to ViewModel.
There must be something else.
n
Yes sad panda . And the weird thing is that I was already using Essenty’s
InstanceKeeper
to keep my stores between config changes. I’m just adding the Decompose layer.
a
That's surprising, as I don't see how InstanceKeeper fits with navigation-compose. I would just put the Store in a ViewModel.
n
I couldn’t use ViewModel because I went for a multiplatform approach with only native views. Later on, I decided to also use Compose Multiplatform and hence the migration to Decompose. I was using a
Component
class that wasn’t Decompose, just to hold the
Store
instance.
That was a bad decision, now that I can look back. I should’ve used Decompose directly 🤷 .
InstanceKeeper fits with navigation-compose
I used Koin to help me with this. With the
instanceKeeper
extension to retrieve it from the `NavBackStackEntry`:
Copy code
@Composable
inline fun <reified T> getComponent(
    entry: NavBackStackEntry,
    vararg params: Any,
): T {
    return koinInject {
        parametersOf(
            entry.instanceKeeper(),
            *params,
        )
    }
}
And then
Component
is just:
Copy code
interface Component<Intent : Any, State : Any, Event : Any> {
    val states: StateFlow<State>
    val events: Flow<Event>
    fun accept(intent: Intent)
}

class DefaultComponent<Intent : Any, State : Any, Event : Any>(
    instanceKeeper: InstanceKeeper,
    storeFactory: () -> Store<Intent, State, Event>,
) : Component<Intent, State, Event> {
    private val store = instanceKeeper.getStore(storeFactory)
    override val states = store.stateFlow
    override val events = store.labels

    override fun accept(intent: Intent) {
        store.accept(intent)
    }
}
The Component gets recreated each time but the store doesn’t.
But then, this doesn’t explain why the flows are not working the same way in the new Decompose approach.
a
Yes, looks weird.
It's still not clear why there are 0 items in the state on first composition.
n
No, but it’s surely related to the fact that the same flow (store.states) is being collected twice in the same composition.
a
It should be remembered, and the actual collection should be performed only once
Can I see how you navigate your Composables?
You can add DisposableEffect(Unit) { onDispose {} } and log onDispose. It shouldn't be called when you go back. Only called once when you go forward.
n
It should be remembered, and the actual collection should be performed only once
Didn’t follow this.
Can I see how you navigate your Composables?
Surely! I think it’s the usual Decompose approach:
MainBloc
that holds everything looks like this:
Copy code
Children(mainBloc.childStack) {
                when (val child = it.instance) {
                    is MainBloc.Child.Home -> {
                        HomeContainer(child.bloc, snackbarShower::showSnackbar)
                    }

                    is MainBloc.Child.PostDetails ->
                        PostDetailsScreen(
                            child.bloc,
                            child.openCommentBox,
                            snackbarShower::showSnackbar
                        )
                }
            }
The
HomeBloc
is
Copy code
Scaffold(
        modifier = Modifier.fillMaxSize(),
        bottomBar = {
            MainBottomBar(
                currentDestination = state.currentDestination,
                destinations = state.destinations,
                onDestinationClick = {
                    bloc.accept(HomeContract.Intent.OnDestinationClick(it))
                }
            )
        }
    ) {
        Children(bloc.childStack) {
            when (val child = it.instance) {
                is HomeBloc.Child.Feed -> FeedScreen(child.bloc, showSnackBar)
                HomeBloc.Child.MyProfile -> TODO()
                HomeBloc.Child.Notification -> TODO()
                HomeBloc.Child.Progress -> TODO()
            }
        }
    }
The two Composables that are causing this are
FeedScreen
(the list) and
PostDetailsScreen
. Were you asking for this or the internal Component logic?
a
Yep, all looks good here
By remember, I mean it should be done automatically inside the collectAsState whatever functions.
No need to manually remember
n
Oh, OK, yes, I follow you.
a
Then it feels that the state somehow gets reset in the Store, assuming that the store doesn't get recreated for some reason.
n
I’ll try to pull the thread from there. Thank you 🙇
👍 1
It seems it’s a bug on the
paging3
library: https://stackoverflow.com/questions/76120368/in-compose-pagingdata-lazypagingitems-returns-0-items-and-loading-state-initia The reason why it worked with nav component it’s because the navigation was slower ( ) and only the second state with the items already sent was rendered. I checked and it was already getting 0 items and then the real amount, it just wasn’t rendered.
a
Thanks for the update! It seems like it was (partially?) fixed? https://issuetracker.google.com/issues/177245496#comment45
n
Yes, indeed, but only partially: https://issuetracker.google.com/issues/177245496#comment56 and https://issuetracker.google.com/issues/177245496#comment58 So I’ll try to make the “fix” work!
Anyway, Arkadii, thanks very much for the effort! And also for the magnificent libraries! 🙇
K 1
a
Yay!