Description
Use case (the problem)
- Some components, e.g. Android Fragments can be reused after it's "destruction".
Unlike Activities that afteronDestroy()
become really dead and garbage collected, fragments have a different lifecycle and can stay physically alive afteronDestroy()
/onDetach()
, for example cached for future use.
class MyFragment : Fragment(), CoroutineScope by MainScope() {
// ...
override fun onDestroy() {
super.onDestroy()
cancelCoroutineScope()
// ...
}
}
So, when we get an instance of such fragment that survived after destruction, we can no longer launch any coroutine in because the parent job is already canceled.
- Those who use MVP pattern in their apps, often inject Presenters as singletons that live as long as the entire app lives.
class MyPresenter<V> {
// ...
fun takeView(v: V) { /*...*/ }
fun dropView(v: V) { /*...*/ }
}
Naturally, presenters can be considered as coroutine scopes too.
class MyPresenter<MyView>: CoroutineScope by MainScope() {
//...
fun takeView(v: V) { /*...*/ } // enter scope
fun dropView(v: V) { cancelCoroutineScope() } // exit scope
}
But in practice, a presenter can be used with different views, and its scope is likely determined by the lifecycle of the view attached to it - a lifetime in the interval between the presenter's takeView()
and dropView()
.
Presenter drops view when a user rotates device or starts another activity etc. At the same time, the presenter should stop all running tasks. If it cancels coroutine scope once, we become unable to launch coroutines again.
- The same goes for
ViewModel
, and I'm sure you can come up with your own examples.
Workarounds
- Do not implement
CoroutineScope
itself.
One can create the coroutine scope inside a fragment/presenter and manage it explicitly by hands.
class MyPresenter<MyView> {
private var myScope: CoroutineScope? = null
fun takeView(v: V) { buildCoroutineScope() } // enter scope
fun dropView(v: V) { cancelCoroutineScope() } // exit scope
}
This might be error prone and requires writing some boilerplate code. But we all know that less code == less bugs.
- Do not cancel the parent job, but it's children.
class MyPresenter<MyView>: CoroutineScope by MainScope() {
//...
fun takeView(v: V) { /*...*/ } // enter scope
fun dropView(v: V) { cancelChildrenJobs() } // exit scope
}
The use of coroutineScope[Job]?.cancelChildren()
instead of coroutineScope[Job]?.cancel()
partially solves the issue: we get all jobs canceled except the parent.
Solution
Introduce new ReusableCoroutineScope
.
internal class ReusableContextScope(
context: CoroutineContext,
private val newJob: () -> Job
) : CoroutineScope {
private var reusableContext: CoroutineContext = context
override val coroutineContext: CoroutineContext
get() {
if (reusableContext[Job]?.isCancelled == true) {
reusableContext += newJob()
}
return reusableContext
}
}
@Suppress("FunctionName")
fun ReusableCoroutineScope(
context: CoroutineContext,
newJob: () -> Job = { SupervisorJob() }
): CoroutineScope =
ReusableContextScope(context, newJob)
One defines a newJob()
function that's going to be used as a generator of new jobs in cases when the coroutineContext's job became canceled.
Also, inspired by #829, we can also add an alternative ReusableMainScope()
factory function that will create a scope with Dispatchers.Main
and re-inject new SupervisorJob
to the context.
@Suppress("FunctionName")
fun ReusableMainScope(): CoroutineScope =
ReusableContextScope(Dispatchers.Main) { SupervisorJob() }
Benefits
- With this reusable scope, we can have a shiny nice short record for sensitive components.
class MyFragment : Fragment(), CoroutineScope by ReusableMainScope() {
// ...
override fun onDestroy() {
super.onDestroy()
cancelCoroutineScope()
}
}
- We don't care about managing jobs
- We have safe code
- Our coroutines still don't leak
- If we need to reuse some canceled/destroyed component, we just take and use it like if it were a fresh new object.