Home > Back-end >  Best practise for replacing current coroutine call in viewmodels
Best practise for replacing current coroutine call in viewmodels

Time:10-21

I have the following:

interface CartRepository {
  fun getCart(): Flow<CartState>
}

interface ProductRepository {
  fun getProductByEan(ean: String): Flow<Either<ServerError, Product?>>
}

class ScanViewModel(
  private val productRepository: ProductRepository,
  private val cartRepository: CartRepository
) :
  BaseViewModel<ScanUiState>(Initial) {
  fun fetchProduct(ean: String) = viewModelScope.launch {
    setState(Loading)

    productRepository
      .getProductByEan(ean)
      .combine(cartRepository.getCart(), combineToGridItem())
      .collect { result ->
        when (result) {
          is Either.Left -> {
            sendEvent(Error(R.string.error_barcode_product_not_found, null))
            setState(Initial)
          }
          is Either.Right -> {
            setState(ProductUpdated(result.right))
          }
        }
      }
    }
}

When a user scans a barcode fetchProduct is being called. Every time a new coroutine is being set up. And after a while, there are many running in the background and the combine is triggered when the cart state is updated on all of them, which can cause errors.

I want to cancel all old coroutines and only have the latest call running and update on cart change.

I know I can do the following by saving the job and canceling it before starting a new one. But is this really the way to go? Seems like I'm missing something.

var searchJob: Job? = null

private fun processImage(frame: Frame) {
  barcodeScanner.process(frame.toInputImage(this))
    .addOnSuccessListener { barcodes ->
      barcodes.firstOrNull()?.rawValue?.let { ean ->
        searchJob?.cancel()
        searchJob = viewModel.fetchProduct(ean)
      }
    }
    .addOnFailureListener {
      Timber.e(it)
      messageMaker.showError(
        binding.root,
        getString(R.string.unknown_error)
      )
    }
}

I could also have a MutableSharedFlow in my ViewModel to make sure the UI only react to the last product the user has been fetching:

  private val productFlow = MutableSharedFlow<Either<ServerError, Product?>>(replay = 1)

  init {
    viewModelScope.launch {
      productFlow.combine(
        mycroftRepository.getCart(),
        combineToGridItem()
      ).collect { result ->
        when (result) {
          is Either.Right -> {
            setState(ProductUpdated(result.right))
          }
          else -> {
            sendEvent(Error(R.string.error_barcode_product_not_found, null))
            setState(Initial)
          }
        }
      }
    }
  }

  fun fetchProduct(ean: String) = viewModelScope.launch {
    setState(Loading)

    repository.getProductByEan(ean).collect { result ->
      productFlow.emit(result)
    }
  }

What's considered best practice handling this scenario?

CodePudding user response:

I can't think of a simpler pattern for cancelling any previous Job when starting a new one.

If you're concerned about losing your stored job reference on screen rotation (you probably won't since Fragment instances are typically reused on rotation), you can move Job storage and cancellation into the ViewModel:

private var fetchProductJob: Job? = null

fun fetchProduct(ean: String) {
    fetchProductJob?.cancel()
    fetchProductJob = viewModelScope.launch {
        //...
    }
}

If you're repeatedly using this pattern, you could create a helper class like this. Not sure if there's a better way.

class SingleJobPipe(val scope: CoroutineScope) {
    private var job: Job? = null

    fun launch(
        context: CoroutineContext = EmptyCoroutineContext,
        start: CoroutineStart = CoroutineStart.DEFAULT,
        block: suspend CoroutineScope.() -> Unit
    ): Job = synchronized(this) {
        job?.cancel()
        scope.launch(context, start, block).also { job = it }
    }
}

// ...

private val fetchProductPipe = SingleJobPipe(viewModelScope)

fun fetchProduct(ean: String) = fetchProductPipe.launch {
        //...
    }
  • Related