Cancelling the collect of the Kotlin flow - android

I have a parent class that has different states, this parent class has a list of child classes that have different states each. I want to collect on each one of them and cancel the one that reaches the Terminated state. Something like that:
coroutineScope.launch(Dispatcher.IO) {
parent.parentState.collect {
if(it is ParentState.Normal){
it.children.forEach{ child ->
coroutineScope.launch(Dispatcher.IO){
child.childState.collect{
if(it is ChildState.Terminated){
//when this line executed all the collectors stop until I change the states for each one of them..
this.coroutineContext.job.cancel()
} else{
// Do something else for any other state...
}
}
}
}
}
}
}
But when I do that all the children that I am collecting from stop collecting, but it starts collecting again If I changed the state for each one of them, which is wasn't the case before cancelling one of them.
So my question is why it behaves like that when cancelling the job for one of the collectors?
Also is there a better way "reactive way" to write this?

I totally agree that you can use SupervisorJob to deal with your problems.
But in my opinion, not so many sub-jobs are needed. A sub-job can solve the problems you encounter. In your code, a child coroutine of the size of children Collection will be created. Although the coroutine is very lightweight, I think it is unnecessary overhead.
You can completely convert List<Flow<T>> into Flow<List<T>> before each Flow collect. After that, only the converted single Flow can be collect.
Here is how I handled it:
inline fun <reified T> List<Flow<T>>.flattenFlow(): Flow<List<T>> = combine(this#flattenFlow) {
it.toList()
}
coroutineScope.launch(Dispatcher.IO) {
parent.parentState.collect {
if (it is ParentState.Normal) {
it.flattenFlow().collect {childStateList ->
childStateList.onEach {childState ->
if (childState is ChildState.Terminated) {
// Do something when state in Terminated..
} else {
// Do something else for any other state...
}
}
}
}
}
}

By default, coroutine scope is using Job() in CoroutineContext, Job() will cancel coroutine execution or any running child on canceled or exception thrown.
To keep other child execution remain active, you can use special Job, which is SupervisorJob()
CoroutineScope(SupervisorJob() + Dispatchers.IO)
Also, let's create a brand new scope for each child
val childScope = CoroutineScope(Dispatchers.IO)
childScope.launch {
child.childState.collect {
....
}
}
and you should cancel those childScope instead the coroutineContext.
Therefore, your code will looks like below
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
parent.parentState.collect {
if(it is ParentState.Normal){
it.children.forEach{ child ->
val childScope = CoroutineScope(Dispatchers.IO)
childScope.launch(Dispatcher.IO){
child.childState.collect{
if(it is ChildState.Terminated){
//when this line executed all the collectors stop until I change the states for each one of them..
childScope.cancel()
} else{
// Do something else for any other state...
}
}
}
}
}
}
}

Related

Unsubscribe from Kotlin Flow without cancelling current scope

I am subscribing to a Kotlin SharedFlow of booleans within a repeatOnLifecycle block in Android. I want to subscribe until I receive the first true boolean and act on it.
As soon as the first true boolean is received I need to unsubscribe and run the processing funtion within the lifecyce scope so it gets cancelled when the lifecycle transitions to an invalid state.
When I call cancel on the current scope and embed the processing code in a NonCancellable context it will not be cancelled on lifecycle events.
I think I would want something like a takeWhile inlcuding the first element that did not match the predicate.
Below are some sample flows where I want to collect all elements until the $ sign:
true $ ...
false, true $ ...
false, false, true $ ...
Sample code:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
flowOfInterest.collectLatest {
if (it) {
stopCollection()
doOnTrue()
} else {
doOnFalse()
}
}
}
}
What is the correct/simplest way to achieve this behavior?
Thanks!
You can use the first function to continue collecting until the given predicate returns true.
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
flowOfInterest.first {
if(it) doOnTrue() else doOnFalse()
it
}
}
}
Answering my own question here. What I needed was something like a takeWhile operator that includes the first non-matching element. Such an opeator can be created using the transformWhile operator like this:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
flowOfInterest.transformWhile {
emit(it)
!it
}.collectLatest {
if (it) {
doOnTrue()
} else {
doOnFalse()
}
}
}
}
This is not as nice and compact as I had hoped, but it works.
Edit: Alternatively, you can use Arpit Shukla's answer and perform the actions in the predicate of the 'first' function.
You can launch a new coroutine inside repeatOnLifecycle function scope, because launch function returns a Job so you can use it to cancel your job later.
Moreover, if you want to listen multiple flows inside repeatOnLifecycle scope, you'll need to launch a child coroutine to let them run in parallel. So they won't block each other and canceling a flow will not affect other flows.
Example code:
lateinit var job: Job
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
job = launch {
flowOfInterest.collectLatest {
if (it) {
stopCollection()
doOnTrue()
} else {
doOnFalse()
}
}
}
}
}
func stopCollection() {
job.cancel()
}
You can also use takeWhile to define condition which controls collecting from flow.
flowOfInterest.takeWhile { !it }.collect {
//execute smth
}
If your flow is "cold" after reaching the state, when the condition is not met, the flow will stop emitting at all if there is no more observers. Otherwise, it will continue (e.g. if you use shared flow), but your current observer (which you applied to the question) will stop collecting.
As a workaround to your specific case, if your flow is not "cold", you can call
flowOfInterest.take(1).collect { ... }
to receive the element, on which the predicate returns false.
You can also use additional boolean value that you will modify in collect block when you will reach some condition, which will be the predicate for takeWhile.

Kotlin Flow not collected anymore after working initially

Basically I want to make a network request when initiated by the user, collect the Flow returned by the repository and run some code depending on the result. My current setup looks like this:
Viewmodel
private val _requestResult = MutableSharedFlow<Result<Data>>()
val requestResult = _requestResult.filterNotNull().shareIn(
scope = viewModelScope,
started = SharingStarted.WhileViewSubscribed,
replay = 0
)
fun makeRequest() {
viewModelScope.launch {
repository.makeRequest().collect { _requestResult.emit(it) }
}
}
Fragment
buttonLayout.listener = object : BottomButtonLayout.Listener {
override fun onButtonClick() {
viewModel.makeRequest()
}
}
lifecycleScope.launchWhenCreated {
viewModel.requestResult.collect { result ->
when (result) {
Result.Loading -> {
doStuff()
}
is Result.Success -> {
doDifferentStuff(result.data)
}
is Result.Failure -> {
handleError()
}
}
}
}
The first time the request is made everything seems to work. But starting with the second time the collect block in the fragment does not run anymore. The request is still made, the repository returns the flow as expected, the collect block in the viewmodel runs and emit() also seems to be executed successfully.
So what could be the problem here? Something about the coroutine scopes? Admittedly I lack any sort of deeper understanding of the matter at hand.
Also is there a more efficient way of accomplishing what I'm attempting using Kotlin Flows in general? Collecting a flow and then emitting the same flow again seems a bit counterintuitive.
Thanks in advance:)
According to the documentation there are two recommended alternatives:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
//your thing
}
}
I rather the other alternative:
viewLifecycleOwner.lifecycleScope.launch {
viewModel.makeReques().flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.collect {
// Process the value.
}
}
I like the flowWithLifecycle shorter syntax and less boiler plate. Be carefull thar is bloking so you cant have anything after that.
The oficial docs
https://developer.android.com/topic/libraries/architecture/coroutines
Please be aware you need the lifecycle aware library.

Kotlin Coroutine Flow: Limit the number of collector

Is there a way to limit the number of collector in a function that returns a Flow using flow builder?
I have this public method in a ViewModel
fun fetchAssets(limit: String) {
viewModelScope.launch {
withContext(Dispatchers.IO){
getAssetsUseCase(AppConfigs.ASSET_PARAMS, limit).onEach {
when (it) {
is RequestStatus.Loading -> {
_assetState.tryEmit(AssetState.FetchLoading)
}
is RequestStatus.Success -> {
_assetState.tryEmit(AssetState.FetchSuccess(it.data.assetDataDomain))
}
is RequestStatus.Failed -> {
_assetState.tryEmit(AssetState.FetchFailed(it.message))
}
}
}.collect()
}
}
}
This method is called on ViewModel's init block, but can also be called manually on UI.
This flow emits value every 10 seconds.
Repository
override fun fetchAssets(
query: String,
limit: String
) = flow {
while (true) {
try {
interceptor.baseUrl = AppConfigs.ASSET_BASE_URL
emit(RequestStatus.Loading())
val domainModel = mapper.mapToDomainModel(service.getAssetItems(query, limit))
emit(RequestStatus.Success(domainModel))
} catch (e: HttpException) {
emit(RequestStatus.Failed(e))
} catch (e: IOException) {
emit(RequestStatus.Failed(e))
}
delay(10_000)
}
}
Unfortunately every time fetch() was invoke from UI, I noticed that it creates another collectors thus can ended up having tons of collector which is really bad and incorrect.
The idea is having a flow that emits value every 10 seconds but can also be invoke manually via UI for immediate data update without having multiple collectors.
You seem to misunderstand what does it mean to collect the flow or you misuse the collect operation. By collecting the flow we mean we observe it for changes. But you try to use collect() to introduce changes to the flow, which can't really work. It just starts another flow in the background.
You should collect the flow only once, so keep it inside init or wherever it is appropriate for your case. Then you need to update the logic of the flow to make it possible to trigger reloading on demand. There are many ways to do it and the solution will differ depending whether you need to reset the timer on manual update or not. For example, we can use the channel to notify the flow about the need to reload:
val reloadChannel = Channel<Unit>(Channel.CONFLATED)
fun fetchAssets(
query: String,
limit: String
) = flow {
while (true) {
try {
...
}
withTimeoutOrNull(10.seconds) { reloadChannel.receive() } // replace `delay()` with this
}
}
fun reload() {
reloadChannel.trySend(Unit)
}
Whenever you need to trigger the manual reload, do not start another flow or invoke another collect() operation, but instead just invoke reload(). Then the flow that is already being collected, will start reloading and will emit state changes.
This solution resets the timer on manual reload, which I believe is better for the user experience.
I ended up moving the timer on ViewModel as I can request on demand fetch while also not having multiple collectors that runs at the same time.
private var job: Job? = null
private val _assetState = defaultMutableSharedFlow<AssetState>()
fun getAssetState() = _assetState.asSharedFlow()
init {
job = viewModelScope.launch {
while(true) {
if (lifecycleState == LifeCycleState.ON_START || lifecycleState == LifeCycleState.ON_RESUME)
fetchAssets()
delay(10_000)
}
}
}
fun fetchAssets() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
getAssetsUseCase(
AppConfigs.ASSET_BASE_URL,
AppConfigs.ASSET_PARAMS,
AppConfigs.ASSET_SIZES[AppConfigs.ASSET_LIMIT_INDEX]
).onEach {
when(it){
is RequestStatus.Loading -> {
_assetState.tryEmit(AssetState.FetchLoading)
}
is RequestStatus.Success -> {
_assetState.tryEmit(AssetState.FetchSuccess(it.data.assetDataDomain))
}
is RequestStatus.Failed -> {
_assetState.tryEmit(AssetState.FetchFailed(it.message))
}
}
}.collect()
}
}
}
override fun onCleared() {
job?.cancel()
super.onCleared()
}
Please correct me if this one is a code smell.

How to pause/stop collecting/emitting data in a Flow while app minimised?

I have a UseCase and remote repository that return Flow in a loop and I collect the result of UseCase in the ViewModel like this:
viewModelScope.launch {
useCase.updatePeriodically().collect { result ->
when (result.status) {
Result.Status.ERROR -> {
errorModel.value = result.errorModel
}
Result.Status.SUCCESS -> {
items.value = result.data
}
Result.Status.LOADING -> {
loading.value = true
}
}
}
}
the problem is when the app is in the background (minimized) flow continues working. so can I pause it when the app is in the background and resume it when the app comes back to the foreground?
and also I don't want to observe the data in my view (fragment or activity).
I'd play around with the stateIn operator and the way I'm currently consuming the flow in the view.
Something like:
val state = useCase.updatePeriodically().map { ... }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed, initialValue)
And consume it from the View like:
viewModel.flowWithLifecycle(this, Lifecycle.State.STARTED)
.onEach {
}
.launchIn(lifecycleScope)
For other potential ways on how to collect flows from the UI: https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
EDIT:
If you don't want to consume it from the view, you still have to signal for the VM that your View is in the background currently.
Something like:
private var job: Job? = null
fun start(){
job = viewModelScope.launch {
state.collect { ... }
}
}
fun stop(){
job?.cancel()
}
Even if the viewModelScope is cancelled, the flow will continue to collect because it is not cooperative to cancellation.
To make a flow cancellable, you can do one of the following things:
In the collect lambda, call currentCoroutineContext().ensureActive() to make sure the context in which the flow is being collected is still active. This will however throw a CancellableException, which you will need to catch, if the coroutine scope was cancelled already (viewModel scope for your case.)
You can use cancellable() operator as follows:
myFlow.cancellable().collect { //do stuff here.. }
And you can call cancel() whenever you want to cancel the flow.
For official documentation on cancelling the flow see:
https://kotlinlang.org/docs/flow.html#flow-cancellation-checks
I believe you want something like this
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
state.collect {
}
}
}
Here's an execellent article on repeatOnLifecyle: https://medium.com/androiddevelopers/repeatonlifecycle-api-design-story-8670d1a7d333

Make part of coroutine continue past cancellation

I have a file managing class that can save a big file. The file manager class is an application singleton, so it outlives my UI classes. My Activity/Fragment can call the save suspend function of the file manager from a coroutine and then show success or failure in the UI. For example:
//In MyActivity:
private fun saveTheFile() = lifecycleScope.launch {
try {
myFileManager.saveBigFile()
myTextView.text = "Successfully saved file"
} catch (e: IOException) {
myTextView.text = "Failed to save file"
}
}
//In MyFileManager
suspend fun saveBigFile() {
//Set up the parameters
//...
withContext(Dispatchers.IO) {
//Save the file
//...
}
}
The problem with this approach is that I don't want the save operation to be aborted if the Activity is finished. If the activity is destroyed before the withContext block gets going, or if the withContext block has any suspension points in it, then saving will not be completed because the coroutine will be canceled.
What I want to happen is that the file is always saved. If the Activity is still around, then we can show UI updates on completion.
I thought one way to do it might be to start a new coroutineScope from the suspend function like this, but this scope still seems to get cancelled when its parent job is cancelled.
suspend fun saveBigFile() = coroutineScope {
//...
}
I thought another alternative might be to make this a regular function that updates some LiveData when it's finished. The Activity could observe the live data for the result, and since LiveData automatically removes lifecycle observers when they're destroyed, the Activity is not leaked to the FileManager. I'd like to avoid this pattern if the something less convoluted like the above can be done instead.
//In MyActivity:
private fun saveTheFile() {
val result = myFileManager.saveBigFile()
result.observe(this#MyActivity) {
myTextView.text = when (it) {
true -> "Successfully saved file"
else -> "Failed to save file"
}
}
}
//In MyFileManager
fun saveBigFile(): LiveData<Boolean> {
//Set up the parameters
//...
val liveData = MutableLiveData<Boolean>()
MainScope().launch {
val success = withContext(Dispatchers.IO) {
//Save the file
//...
}
liveData.value = success
}
return liveData
}
You can wrap the bit that you don't want to be cancelled with NonCancellable.
// May cancel here.
withContext(Dispatchers.IO + NonCancellable) {
// Will complete, even if cancelled.
}
// May cancel here.
If you have code whose lifetime is scoped to the lifetime of the whole application, then this is a use case for the GlobalScope. However, just saying GlobalScope.launch is not a good strategy because you could launch several concurrent file operations that may be in conflict (this depends on your app's details). The recommended way is to use a globally-scoped actor, in the role of an executor service.
Basically, you can say
#ObsoleteCoroutinesApi
val executor = GlobalScope.actor<() -> Unit>(Dispatchers.IO) {
for (task in channel) {
task()
}
}
And use it like this:
private fun saveTheFile() = lifecycleScope.launch {
executor.send {
try {
myFileManager.saveBigFile()
withContext(Main) {
myTextView.text = "Successfully saved file"
}
} catch (e: IOException) {
withContext(Main) {
myTextView.text = "Failed to save file"
}
}
}
}
Note that this is still not a great solution, it retains myTextView beyond its lifetime. Decoupling the UI notifications from the view is another topic, though.
actor is labeled as "obsolete coroutines API", but that's just an advance notice that it will be replaced with a more powerful alternative in a future version of Kotlin. It doesn't mean it's broken or unsupported.
I tried this, and it appears to do what I described that I wanted. The FileManager class has its own scope, though I suppose it could also be GlobalScope since it's a singleton class.
We launch a new job in its own scope from the coroutine. This is done from a separate function to remove any ambiguity about the scope of the job. I use async
for this other job so I can bubble up exceptions that the UI should respond to.
Then after launch, we await the async job back in the original scope. await() suspends until the job is completed and passes along any throws (in my case I want IOExceptions to bubble up for the UI to show an error message). So if the original scope is cancelled, its coroutine never waits for the result, but the launched job keeps rolling along until it completes normally. Any exceptions that we want to ensure are always handled should be handled within the async function. Otherwise, they won't bubble up if the original job is cancelled.
//In MyActivity:
private fun saveTheFile() = lifecycleScope.launch {
try {
myFileManager.saveBigFile()
myTextView.text = "Successfully saved file"
} catch (e: IOException) {
myTextView.text = "Failed to save file"
}
}
class MyFileManager private constructor(app: Application):
CoroutineScope by MainScope() {
suspend fun saveBigFile() {
//Set up the parameters
//...
val deferred = saveBigFileAsync()
deferred.await()
}
private fun saveBigFileAsync() = async(Dispatchers.IO) {
//Save the file
//...
}
}

Categories

Resources