I have a Flow that's collected in my ViewModel. Th...
# compose
c
I have a Flow that's collected in my ViewModel. The Flow is actually from firestore which notifies me of updates. In my VM I update my snapshot state with the result of that Flow. My composable updates correctly. Life is good. But when I leave my app... the Flowable keeps on listening to updates. How do I do stop listening to my Flow once the app is backgrounded. It seems like it'd be easier if my flow was collected in the Composable, but there has to be a way to do this while collecting in my VM... right? Short example in thread
👀 1
Sample:
Copy code
val myFlow = flow { while (true) {
    delay(1000) 
    emit(1) } }
VM
Copy code
var myState by mutableStateOf(0)

init {
  viewModelScope.launch {
    myFlow.collect {
      myState = myState.plus(it)
    }
  }
}
Composable
Copy code
Text(text = vm.myState.toString())
tl;dr instead of simply pausing (which is what `LifecycleCoroutineScope.launchWhenStarted`/etc. do, leading to hot flows continuing to flow),
Lifecycle.repeatOnLifecycle
cancels and restarts the block, so it upstream is also cancelled while your app is not in the expected lifecycle state
c
Doesn't that force me to collect in my Composable vs collecting in my VM?
i
If you want collection to only be happening when your UI is present, then collecting in the ViewModel was always the wrong thing to be doing
That's true in Compose and in the non-Compose world. ViewModels work much better when they are cold flows that rely on something else (i.e., your UI) to actually make them active
f
you can have your viewmodel's flow stop collecting from firestore when the UI unsubscribes. That's also mentioned in the article linked above, so you can continue subscribing to the repository on your viewmodel, while stopping when the app is backgrounded
o
So exposing a
State
from the VM is a "bad" thing? Because in such way Composable cannot "unsubsribe" from it?
s
One idea is that you could expose that subscription to the UI so that it decides when this collection is happening. Remove the
init
block and expose it as something that the UI can subscribe to
Copy code
class MyViewModel(
  val myFlow: Flow<Int> = flow {
    while (currentCoroutineContext().isActive) {
      delay(1000)
      emit(1)
    }
  },
) : ViewModel() {
  var myState by mutableStateOf(0)
  fun subscribeToFirestoreChanges(): Flow<Unit> {
    return flow {
      myFlow.collect {
        myState = myState.plus(it)
      }
    }
  }
}
Then on the UI simply turn the subscription on with the safe
repeatOnLifecycle
and decide on which state you want it to be on from (in this case
Lifecycle.State.STARTED
)
Copy code
fun MyComposable(viewModel: MyViewModel) {
  val lifecycleOwner = LocalLifecycleOwner.current
  LaunchedEffect(viewModel, lifecycleOwner) {
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
      viewModel.subscribeToFirestoreChanges().collect()
    }
  }
  val text = viewModel.myState
}
Not sure if there are any problems with such an approach. I’ve done it once here and here since my
subscribeToAuthResultUseCase
was fetching for a status update every 1 second and only wanted that to happen while on screen. And this worked just fine for this use case👌
c
If you want collection to only be happening when your UI is present, then collecting in the ViewModel was always the wrong thing to be doing
Hm. Then I probably never want to be calling collect{} in a VM as I can't think of any reason when I want to be collecting something even when I'm not on the screen. I wonder if I can write a lint rule to prevent me from making this mistake.
f
there is nothing wrong with
collect
in the viewmodel, the business logic belongs there and the UI layer should not be talking to your data layer. What needs to happen is for your viewmodel to be aware when the UI is no longer collecting the state so that it can unsubscribe its own collectors
c
Hm. Yeah I'm trying to think of a convenient way to do that.
I think going off of @Stylianos Gakis that everything would work properly... but I'm actually having a hard time seeing if that'd work in my scenario. Only one way to find out I suppose!
f
you can use
stateIn
in your viewmodel to expose the UI state, and have it active only while subscribed, with an optional timeout (for configuration changes, for instance), so that it has a grace period before it disconnects
note that Stylianos' code is duplicating what the new
collectWithLifecycle
(or whatever it's called) does, so you don't need to do the repeat on lifecycle yourself
s
Using
stateIn
would work with a
Flow
not with
State<T>
which I guess is not what Colton is going for. The new
collectStateWithLifecycle
API works when collecting a Flow not when you got compose State in your ViewModel afaik. I was trying to help for this specific case. Personally I also try to expose everything as a Flow anyway with
stateIn
in order to get this functionality for free. But it’s true that I haven’t managed to get that working for all the cases I’ve encountered.
f
another option is to use a disposable effect to start/stop the collection,
Copy code
DisposableEffect(key1 = viewModel) {
    viewModel.onStart()
    onDispose { viewModel.onStop() }
}
this is a more manual approach but it would allow you to start/stop the collection when the composable enters/exits the composition
so you would move your
init
block to
onStart
and have an
onStop
where you stop your flows (you would get the returned
job
and
cancel()
that)
c
Definitely interesting to think about it that way since android lifecycles wont be there on desktop for example.
c
I will try a couple of these approaches out. I think overall I have to change my thinking a bit BUT in the meantime I would be fine with a quick and dirty implementation so that im not using a ton of data when the user isn't even looking at the screen.
@Francesc thanks for the article. I do like the simplicity and multiplatformness of just using an onStart and onStop methods.
Now I just gotta figure out if my firestore flows will actually cancel. I wonder how rotation effects it too. Maybe that's one reason to use the stateIn approach instead.
s
The flows will in fact cancel if you don't keep it alive for that time in-between the rotation unless you either use stateIn as you said. If I understand it correctly, doing this https://kotlinlang.slack.com/archives/CJLTWPH7S/p1618760581089700?thread_ts=1618691113.058800&amp;cid=CJLTWPH7S may also make it so that the UI will not stop listening if you only do a rotation.
c
Gotcha. Okay so at least that part works as I imagined. and yeah, opting out of config changes is on my plate to do at some point. I do like to treat it as a quickway totest config changes (like wallpaper changes) which you can't opt out of.
Alright. group code review time... I kinda like this approach for its simplicity and the fact that it'll be an easy refactor from my current approach. VM:
Copy code
var myState by mutableStateOf(0)
  
  val myFlow = flow {
    while (true) {
      delay(1000)
      emit(1)
    }
  }
  
  fun subscribeToFlow1(): Flow<Unit> = flow {
    myFlow.collectLatest {
      myState = myState.plus(it)
    }
  }
Composable screen: real easy!
Copy code
vm.subscribeToFlow1().collectAsStateWithLifecycle(initialValue = Unit)
Essentially all I'm doing is wrapping my current flows in another flow and then moving them out of init {} and then calling collect in the composable screen.
f
why not apply
stateIn
to your
myFlow
? that would achieve the same result without the wrapper flow if you used "while subscribed"
c
Uhm. Not sure how that would look/work. Let me try?!
f
you can add to your flow
onEach { myState = myState.plus(it) }
c
Not sure I'm following anymore. How would I "apply
stateIn
to your
myFlow
?"
Fransec do you mean this?
Copy code
fun subscribeToFlow1(): Flow<Unit> = flow<Unit> {
  myFlow.collectLatest {
    myState = myState.plus(it)
  }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Unit)
I'm not sure how I'd get rid of the wrapper flow {}
e
collecting one flow for the side effect on another flow - I don't like that at all
c
I agree that it looks a bit smelly, but I'm failing to see how else I'd do this.
i
If you're going to collect a Flow in the UI anyways, just collect the actual flow and skip the roundabout through State
f
I had this in mind
Copy code
val myFlow = flow {
    while (true) {
      delay(1000)
      emit(1)
    }
  }
  .onEach { /* TODO */ }
  .stateIn(...)
c
@Ian Lake I agree and it looks that way in this example, but I guess throughout my application I have a bunch of other flows that are doing way more complex things... which results in some state being updated which my composables use. That's why I don't feel like just dumping all of that logic into the composable makes sense.
@Francesc I've never used onEach as that seems side-effecty (maybe my Rx java experience is leaking in here. which i was never great at Rx anyway, but I thought we should "avoid" side-effects.
f
onEach
is another way to collect your flow, when you use
launchIn
e
scope.launch { flow.collect { ... } }
==
flow.onEach { ... }.launchIn(scope)
to be clear
c
I've always avoided onEach (like I said, for rx java reasons) so thanks for highlighting that!
e
or
.collect { ... }
==
.onEach { ... }.collect()
and
scope.launch { .collect() }
==
.launchIn(scope)
, but you almost never see the parameterless
.collect()
c
Alright. Thanks everyone. I have ended up with this! VM:
Copy code
var myState by mutableStateOf(0)

val myFlow = flow {
  while (true) {
    delay(1000)
    emit(1)
  }
}.onEach { myState = myState.plus(it) }
 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Unit)
Composable screen:
Copy code
vm.myFlow.collectAsStateWithLifecycle(initialValue = Unit)
The only thing that I'm not in absolute love with is the fact that I have two initial values declared, once as the 3rd arg stateIn and another as the arg in collectAsStateWithLifecycle. No wrapper flows or anything!
I did find a case that I'm unsure how to convert and that's because collect {} == onEach{}.collect() but in some of my cases I'm using collectLatest. As an example. My user is exposed as a Flowable. and in a lot of my VMs I base the network call on the user.
Copy code
init{
  viewModelScope.launch {
    userRepo.getUser().collectLatest { user ->
      apiService.getBooksForUser(user.id).collect {
...
I'll try working on the above though. maybe theres some collectLatest equivalent with onEach
f
your VM flow is a
StateFlow
, you don't need to define another init value in your composable if you expose it as a
StateFlow
Copy code
userRepo.getUser().collectLatest { user ->
      apiService.getBooksForUser(user.id).collect {
use
flatmapLatest
instead
Copy code
userRepo.getUser().flatmapLatest { user -> apiService.getBooksForuser(user.id) }
c
your VM flow is a
StateFlow
, you don't need to define another init value in your composable if you expose it as a
StateFlow
oh. neat
use
flatmapLatest
instead
awesome. theres a method for everything!
Just for the sake of completion.
Copy code
userRepo.getUser().flatmapLatest { user -> apiService.getBooksForuser(user.id) }
didn't work but
Copy code
userRepo.getUser().mapLatest { user -> apiService.getBooksForuser(user.id) }
did
f
flatmapLatest expects a flow, I guess your method is a suspending function instead, in which case map is correct
c
So much to learn. Thanks again for teaching!
145 Views