From 21e64eb54a93cfe3ceb9c34d4c5c6541955a73a4 Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:31:59 +0200 Subject: [PATCH] Introduce a MainCoroutineDispatcher (#1744) * Introduce a `MainCoroutineDispatcher` This is almost entirely based on https://github.com/Kotlin/kotlinx.coroutines/blob/8c27d51025d56a7b8de8eec2fb234b0094ce84f1/ui/kotlinx-coroutines-android/src/HandlerDispatcher.kt, which is the implementation of `kotlinx-coroutines-android` * Lint * Move dispatcher to AndroidCompat --- .../coroutines/android/HandlerDispatcher.kt | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 AndroidCompat/src/main/java/kotlinx/coroutines/android/HandlerDispatcher.kt diff --git a/AndroidCompat/src/main/java/kotlinx/coroutines/android/HandlerDispatcher.kt b/AndroidCompat/src/main/java/kotlinx/coroutines/android/HandlerDispatcher.kt new file mode 100644 index 00000000..bac46bc7 --- /dev/null +++ b/AndroidCompat/src/main/java/kotlinx/coroutines/android/HandlerDispatcher.kt @@ -0,0 +1,181 @@ +@file:Suppress("UNUSED") + +package kotlinx.coroutines.android + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.NonDisposableHandle +import kotlinx.coroutines.cancel +import kotlinx.coroutines.internal.MainDispatcherFactory +import kotlin.coroutines.CoroutineContext + +/** + * Dispatches execution onto Android [Handler]. + * + * This class provides type-safety and a point for future extensions. + */ +@OptIn(InternalCoroutinesApi::class) +public sealed class HandlerDispatcher : + MainCoroutineDispatcher(), + Delay { + /** + * Returns dispatcher that executes coroutines immediately when it is already in the right context + * (current looper is the same as this handler's looper) without an additional [re-dispatch][CoroutineDispatcher.dispatch]. + * This dispatcher does not use [Handler.post] when current looper is the same as looper of the handler. + * + * Immediate dispatcher is safe from stack overflows and in case of nested invocations forms event-loop similar to [Dispatchers.Unconfined]. + * The event loop is an advanced topic and its implications can be found in [Dispatchers.Unconfined] documentation. + * + * Example of usage: + * ``` + * suspend fun updateUiElement(val text: String) { + * /* + * * If it is known that updateUiElement can be invoked both from the Main thread and from other threads, + * * `immediate` dispatcher is used as a performance optimization to avoid unnecessary dispatch. + * * + * * In that case, when `updateUiElement` is invoked from the Main thread, `uiElement.text` will be + * * invoked immediately without any dispatching, otherwise, the `Dispatchers.Main` dispatch cycle via + * * `Handler.post` will be triggered. + * */ + * withContext(Dispatchers.Main.immediate) { + * uiElement.text = text + * } + * // Do context-independent logic such as logging + * } + * ``` + */ + public abstract override val immediate: HandlerDispatcher +} + +@OptIn(InternalCoroutinesApi::class) +internal class AndroidDispatcherFactory : MainDispatcherFactory { + override fun createDispatcher(allFactories: List): MainCoroutineDispatcher { + val mainLooper = Looper.getMainLooper() ?: throw IllegalStateException("The main looper is not available") + return HandlerContext(mainLooper.asHandler()) + } + + override fun hintOnError(): String = "For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used" + + override val loadPriority: Int + get() = Int.MAX_VALUE / 2 +} + +/** + * Represents an arbitrary [Handler] as an implementation of [CoroutineDispatcher] + * with an optional [name] for nicer debugging + * + * ## Rejected execution + * + * If the underlying handler is closed and its message-scheduling methods start to return `false` on + * an attempt to submit a continuation task to the resulting dispatcher, + * then the [Job] of the affected task is [cancelled][Job.cancel] and the task is submitted to the + * [Dispatchers.IO], so that the affected coroutine can cleanup its resources and promptly complete. + */ +@JvmName("from") // this is for a nice Java API, see issue #255 +@JvmOverloads +public fun Handler.asCoroutineDispatcher(name: String? = null): HandlerDispatcher = + HandlerContext(this, name) + +private const val MAX_DELAY = Long.MAX_VALUE / 2 // cannot delay for too long on Android + +internal fun Looper.asHandler(): Handler = Handler(this) + +@JvmField +@Deprecated("Use Dispatchers.Main instead", level = DeprecationLevel.HIDDEN) +internal val Main: HandlerDispatcher? = runCatching { HandlerContext(Looper.getMainLooper().asHandler()) }.getOrNull() + +/** + * Implements [CoroutineDispatcher] on top of an arbitrary Android [Handler]. + */ +@OptIn(InternalCoroutinesApi::class) +internal class HandlerContext private constructor( + private val handler: Handler, + private val name: String?, + private val invokeImmediately: Boolean, +) : HandlerDispatcher(), + Delay { + /** + * Creates [CoroutineDispatcher] for the given Android [handler]. + * + * @param handler a handler. + * @param name an optional name for debugging. + */ + constructor( + handler: Handler, + name: String? = null, + ) : this(handler, name, false) + + override val immediate: HandlerContext = + if (invokeImmediately) { + this + } else { + HandlerContext(handler, name, true) + } + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = !invokeImmediately || Looper.myLooper() != handler.looper + + override fun dispatch( + context: CoroutineContext, + block: Runnable, + ) { + if (!handler.post(block)) { + cancelOnRejection(context, block) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun scheduleResumeAfterDelay( + timeMillis: Long, + continuation: CancellableContinuation, + ) { + val block = + Runnable { + with(continuation) { resumeUndispatched(Unit) } + } + if (handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))) { + continuation.invokeOnCancellation { handler.removeCallbacks(block) } + } else { + cancelOnRejection(continuation.context, block) + } + } + + override fun invokeOnTimeout( + timeMillis: Long, + block: Runnable, + context: CoroutineContext, + ): DisposableHandle { + if (handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))) { + return DisposableHandle { handler.removeCallbacks(block) } + } + cancelOnRejection(context, block) + return NonDisposableHandle + } + + private fun cancelOnRejection( + context: CoroutineContext, + block: Runnable, + ) { + context.cancel(CancellationException("The task was rejected, the handler underlying the dispatcher '${toString()}' was closed")) + Dispatchers.IO.dispatch(context, block) + } + + override fun toString(): String = + toStringInternalImpl() ?: run { + val str = name ?: handler.toString() + if (invokeImmediately) "$str.immediate" else str + } + + override fun equals(other: Any?): Boolean = + other is HandlerContext && other.handler === handler && other.invokeImmediately == invokeImmediately + + // inlining `Boolean.hashCode()` for Android compatibility, as requested by Animal Sniffer + override fun hashCode(): Int = System.identityHashCode(handler) xor if (invokeImmediately) 1231 else 1237 +}