Search code examples
androidkotlinmemory-leakssingletoncoroutine

Custom CoroutineScope inside util classes


i have PrinterUtil class that i think can cause memory leak and want to refactor it, but i'm not sure if the new implementation of coroutine is correct

PrintUtil have method printImage that accept listImage, then the listImage get looped and for each loop, it will create new coroutineScope instead of creating new coroutine inside loop, i think it would be better to create global variable of coroutineScope and call async inside the loop

  1. does the old method really can cause memoy leak?
  2. and is the new method is correct and will get cleared accordingly?

the old method

  Object PrintUtil{
    fun printImage(listImage : List<Image>) = CoroutineScope(Dispatchers.Main).launch {
        listImage.foreach{
          val isSuccess = async{try{
              //call suspend method of printing process, throw error if problem occur
            }catch(e : Exception){Log.e("error printing",e.message); false}
          }.await() 
        }
    }
  }

The new method

Object PrintUtil{
  val job = Job()
  val scope = CoroutineScope(Dispatcher.IO + job)

  fun printImage(listImage : List<Image>){
    listImage.foreach{
      val isSuccess = scope.async{ 
        try{
          //call suspend method of printing process, throw error if problem occur
        }catche(e : Exception){
          Log.e("error printing",e.message)
           false
        }
      }.await()
    }      
  }

  //execute on activity onDestroy
  fun cancel(){
    job.cancel()
  }

}

Solution

  • First, let's assume that what you described as suspend method of printing process looks like this:

    suspend fun printProcess(image: Image) 
    

    Memory leaks in the sense that you accumulate obsolete objects over time that will never get removed from memory can not occur in either of your variants. The worst thing that can happen is that the launched coroutines won't be cancelled and will continue to run until they are finished. Until then they will still occupy memory and all referenced objects will not be cleaned up by the garbage collection. This may be a relevant factor for the Image objects: They will still remain in memory until all coroutines are completed. That also includes all other objects that an Image object may reference. It depends on what Image actually is. Since all of that will get cleaned up eventually, technically speaking this is not a memory leak. It may be undesirable behavior, though.

    So cancelling the coroutines is a good way to prevent that. However, using job.cancel() will not only cancel the coroutines, it will also cancel the scope since you created the coroutine scope with that job. That means all further calls to printImage won't do anything since nothing can be executed anymore in that scope. You chose to call cancel in your activity's onDestroy, but should your activity be recreated (what typically happens when just the device configuration was changed, by a rotation f.e.) the PrintUtil object will still be there but it won't work anymore. To fix this you shoud call job.cancelChildren() instead. That will only cancel any coroutines launched in the scope, not the scope itself. Any further calls to printImage will just work as expected.

    You can actually also call scope.coroutineContext.cancelChildren() so you don't need the job. That variable and its usage can then be entirely removed.

    Cancelling all coroutines like this is only the first step, though. Since cancelling coroutines in Kotlin is cooperative, for this to have any effect printProcess must honor such requests. It can, for example, call yield at appropriate intervals. If it launches any new coroutines they should use ensureActive() (as any coroutine should that wishes to honor cancellation requests).

    Having said all that, I think you should reconsider making PrintUtil a global object in the first place. You already identified the dependence on an activity, but as it is you will cancel all coroutine in PrintUtil, not just those initiated from the activity that is getting destroyed. You may only have one activity at the moment so it won't matter much, but it would be better to formalize the dependence on an activity. This way you also cannot forget to call cancel in onDestroy. I would suggest the following:

    class ImagePrinter(lifecycleScope: CoroutineScope) {
        private val scope = lifecycleScope + Dispatchers.IO
    
        operator fun invoke(listImage: List<Image>) {
            listImage.forEach {
                // use scope here
            }
        }
    }
    

    Then you can create an object in each activity's onCreate like this:

    val printImage = ImagePrinter(lifecycleScope)
    

    lifecycleScope is a CoroutineScope that is automatically cancelled when the activity is destroyed, so there is no need for a cancel method. Make sure you use the gradle dependency androidx.lifecycle:lifecycle-runtime-ktx so you can access lifecycleScope.

    You may also have noticed that the function is now declared as operator fun invoke(...). This simplifies the usage of the object. If you want to print an image, just call this:

    printImage(listOfImages)
    

    If you want to use it in some other function just provide printImage::invoke as a parameter of type (List<Image>) -> Unit.

    Some last words on how you launch new coroutines: Although not very clear, what I think you want to achieve is that printImage should return immediately and successive calls should therefore execute concurrently at the same time. For any given list of images, however, you want this list to be sequentially iterated so the second image is only processed if printProcess of the first image has concluded. Then you should move the loop inside the coroutine:

    scope.launch {
        listImage.forEach {
            ensureActive()
            val isSuccess = try {
                printProcess(it)
            } catch (e: Exception) {
                Log.e("error printing", "${e.message}")
                false
            }
        }
    }
    

    As @broot already mentioned in the comments, never launch a new coroutine with async and immediately await it. That's pointless, the suspend function can be called directly as seen above.