Hello everyone, I have a "architecture" question f...
# android
m
Hello everyone, I have a "architecture" question for you ^^ I wrote an article recently diving into data loading for MVI and had some issues when doing the development for it that I fixed in a variety of ways. One comment I got on this article however, brought up an interesting point that I just don't have the sufficient knowledge to argue against so I hope some of you can help me understand. The article is here and the comment reads the following (I won't add all of it as it's quite long):
Copy code
This isn't correct.
You are turning your Hot flow of states into a cold flow by using onStart, which creates a new cold flow. Basically, you are losing the distinction and statefullness of your flow by using that operator.
Then you are turning the cold flow back to a hot flow by immediatelly subscribing to it using the `stateIn` operator. You aren't really loading initial data upon the subscription, you're just hiding this problem with the `Lazy` delegate.
At best, this is suboptimal because you're recreating the same flow multiple times and creating an additional coroutine that does basically nothing (as StateFlow does not need conversion in the first place).
I find the comment rather blunt and it seems more accusing than helpful but anyway. Here is the implementation that isn't quite right to them:
Copy code
private val _state: MutableStateFlow<State> = MutableStateFlow(initialState)
val state: StateFlow<State> by lazy {
    _state.onStart {
        viewModelScope.launch {
            initialDataLoad()
        }
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000L),
        initialValue = initialState
    )
}
I'm wondering firstly if the comment is correct and secondly, if so, how it could be fixed?
🧵 4
I'm pretty sure @Ian Lake has already commented on the subject (which I used in the article) but I'm not 100% sure of what the correct approach would be here 🤔
m
m
Yes the comments are correct. Whatever is triggering the initial access to your lazy state variable should rather send an intent to your viewmodel. It's enough to declare state as Stateflow and collect with lifecycle in compose without need for additional operators like onStart and stateIn. So basically 1. Create an intent to represent request for initial data 2. Call your initialDataLoad on this intent 3. Use LaunchEffect at the site of the initial access of your state to send this intent to your viewmodel @Maxime Michel
🧵 3
l
Hi @Maxime Michel, just curious, this is the first time I see we use lazy then stateIn inside.
Copy code
val state: StateFlow<State> by lazy {
    _state.onStart {
       ...
    }.stateIn(
       ...
    )
}
How does it really work? Can you please expand more on this 🙏
m
Thanks @marlonlom and @Mark Marcel! I understand what you've said and unfortunately, the information doesn't really fit my need. The fact I'm using a lazy and overwriting a StateFlow into a Flow and then back into a StateFlow is all sorts of bad news. However, since I want to be able to load some data when collection starts automatically, the information you've given me doesn't solve that issue. I'll keep digging on my side and if I find something, I'll get back to you and see if what I've done seems better to you or not!
I've done some digging and found this "new" way of doing what I want but I'd like an (or multiple) opinion on whether it's a good thing to do or not
Copy code
private val _state: MutableStateFlow<State> = MutableStateFlow(initialState)
val state: StateFlow<State> =_state.onSubscription {
    initialDataLoad()
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5000L),
    initialValue = initialState
)
At first glance, it seems to me that this does what I want: It calls a function when a collector appears and provides a StateFlow without an intermediate Cold Flow conversion.
m
Can you help us understand why the result of your initialDataLoad function is different from initialState?
m
Certainly @Mark Marcel! It seems obvious for me since I'm doing it but without the context, it does make it hard to understand the issue and help ^^
initialState
contains a preconfigured state used when the screen is first displayed. Typically a loading state or other. The
initialDataLoad
function is intended to be used to call some network (or other) related operation to get some data for the screen. A simple example would be a screen that displays a list that is provided via an API. At first, when you open the screen, there is nothing which is expected but there can be a loader or something similar and when the state starts to be collected (ie, the screen is drawn), we call the
initialDataLoad
function to call the API and get the data which will then be loaded into the
_state
variable (MVI does this with events and others things)
m
1. Are you using compose or views for the screen that starts the collection? 2. Is the time of initialState the same as what initialDataLoad outputs or do they share a super class?
m
1. Compose 2. Yes, they are both the same type (in this case a data class)
m
So you want initialDataLoad called on only initial composition of the screen similar to like onCreate in Fragment/Activity?
m
In some sense yes ^^ But the reason I would like it to be linked to the StateFlow collection is for automation purposes. It helps accelerate development times and avoids frustration when trying to figure out why the screen won't update just because a developer forgot to add a LaunchedEffect calling this function 🤔
👌 1
l
Hi @Maxime Michel, I have a small util class called LoomIn, hope this help. Usages:
Copy code
val loom = loomIn(viewModelScope) { fetchData() }
val loomState = loom.state
// Collect state in your ViewModel
loomState.collect { state ->
    when (state) {
        is LoomState.Loading -> showLoadingIndicator()
        is LoomState.Loaded -> updateUI(state.data)
        is LoomState.Error -> showError(state.exception)
    }
}
// or UI
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    // Collect UI state from ViewModel
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
}
👀 1
m
If this is a viewmodel and the viewmodel store owner is the navigation destination for the screen. You can simply call initialDataLoad in an init block of the viewmodel. This is because the viewmodel gets created on navigation to the screen and remains same until screen is popped of NavHost. That should automate the initialLoadData without the chaining flow operators
m
I agree yes, the issue with this comes when testing. If you use the init block, you can't decouple the behaviour when testing the ViewModel which is why I try using another function that can be called when state starts collection and therefore can be tested independently
👌 1
m
So you don't want initialDataLoad called in your test?
m
I do, but I want to control when it is called (which is the point of a test)
m
Does @Leo N library solve the problem for you?
m
I'd prefer not to use a library for this but what he does seems similar to what I implemented with onSubscription 🤔
👍 1
l
Yeh, we had applied in our projects. I’m not publish as library, I think we can try it out, and bring/customize them to your project, just a few classes and solve same problem with @Maxime Michel I believe. > I do, but I want to control when it is called (which is the point of a test) -> this will be triggered whenever a subscriber appear (collect {}, collectAsState, test {}, …)
m
Could a wrapper composable meet your requirements? You could pass a lambda into it. Wrap all your screens in this, launcheffect automatically called in wrapper with lambda? @Maxime Michel
m
I think a wrapper composable would be a great solution. I initially wanted to have some kind of inheritance-based approach but with Compose this wouldn't work wince they are functions 🤔 Do you have an idea as to what a wrapper would look like?
👌 1
m
I usually use something like this.
Copy code
val state = flow { initialDataLoad() }
    .stateIn(...)
you also can use
asFlow()
on the method reference, like this:
Copy code
::initialDataLoad.asFlow()
it is a shorthand for `
Copy code
flow { emit(initialDataLoad()) }
Hope it helps.
m
This would work but I then lose the link with my private backing field which won't help ^^
k
@Maxime Michel you can take a look at

this

if it helps