From 042751b3a7a227a920ccdb01c34aa59e3c749282 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 28 Nov 2024 18:23:02 -0800 Subject: [PATCH 01/34] Remove dependency on Dispatchers.Main.immediate for immediate dispatching in Compose. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 4 +- .../DelayedDispatchCoroutineDispatcher.kt | 65 +++++++++++++++++++ .../kotlin/coil3/compose/internal/utils.kt | 47 +------------- gradle/libs.versions.toml | 1 - internal/test-roborazzi/build.gradle.kts | 1 - samples/shared/build.gradle.kts | 1 - 6 files changed, 69 insertions(+), 50 deletions(-) create mode 100644 coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 5dde717cf4..0177e0c3b0 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,7 +31,7 @@ import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState import coil3.compose.internal.onStateOf -import coil3.compose.internal.rememberImmediateCoroutineScope +import coil3.compose.internal.rememberDelayedDispatchCoroutineScope import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf @@ -136,7 +136,7 @@ private fun rememberAsyncImagePainter( val input = Input(state.imageLoader, request, state.modelEqualityDelegate) val painter = remember { AsyncImagePainter(input) } - painter.scope = rememberImmediateCoroutineScope() + painter.scope = rememberDelayedDispatchCoroutineScope() painter.transform = transform painter.onState = onState painter.contentScale = contentScale diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt new file mode 100644 index 0000000000..9022e18cd6 --- /dev/null +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt @@ -0,0 +1,65 @@ +package coil3.compose.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.Runnable + +/** + * Create a [CoroutineScope] will contain a [DelayedDispatchCoroutineDispatcher] if necessary. + */ +@Composable +internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { + val scope = rememberCoroutineScope() + return remember(scope) { + val currentContext = scope.coroutineContext + val currentDispatcher = scope.coroutineContext.dispatcher + if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { + CoroutineScope(currentContext + DelayedDispatchCoroutineDispatcher(currentDispatcher)) + } else { + scope + } + } +} + +/** + * A [CoroutineDispatcher] that delays dispatching until the current [CoroutineDispatcher] changes. + */ +internal class DelayedDispatchCoroutineDispatcher( + private val delegate: CoroutineDispatcher, +) : CoroutineDispatcher() { + private var dispatchEnabled = false + + private val currentDispatcher: CoroutineDispatcher + get() = if (dispatchEnabled) delegate else Dispatchers.Unconfined + + override fun isDispatchNeeded(context: CoroutineContext): Boolean { + // Enable dispatching when the context's dispatcher changes and it's not Dispatchers.Unconfined. + dispatchEnabled = dispatchEnabled || context.dispatcher.let { + it != null && it != this && it != Dispatchers.Unconfined + } + return currentDispatcher.isDispatchNeeded(context) + } + + override fun limitedParallelism(parallelism: Int, name: String?): CoroutineDispatcher { + return currentDispatcher.limitedParallelism(parallelism, name) + } + + override fun dispatch(context: CoroutineContext, block: Runnable) { + currentDispatcher.dispatch(context, block) + } + + @InternalCoroutinesApi + override fun dispatchYield(context: CoroutineContext, block: Runnable) { + currentDispatcher.dispatchYield(context, block) + } + + override fun toString(): String { + return "DelayedDispatchCoroutineDispatcher(delegate=$delegate)" + } +} diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt index a841557f71..1c03aa7b10 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt @@ -7,13 +7,11 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isUnspecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role @@ -35,12 +33,8 @@ import coil3.size.Scale import coil3.size.Size as CoilSize import coil3.size.SizeResolver import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.MainCoroutineDispatcher /** Create an [ImageRequest] from the [model]. */ @Composable @@ -219,43 +213,6 @@ internal fun Size.toIntSize() = IntSize(width.roundToInt(), height.roundToInt()) internal val Size.isPositive get() = width >= 0.5 && height >= 0.5 -// We need `Dispatchers.Main.immediate` to be able to execute immediately on the main thread so -// we can reach the loading state, set the placeholder, and maybe resolve from the memory cache. -// The default main dispatcher provided with Compose always dispatches, which will often cause -// one frame of delay. If `Dispatchers.Main.immediate` isn't available, fall back to -// `Dispatchers.Unconfined`, which will execute immediately even if we're not on the main -// thread. This will typically only occur in preview/test environments where image loading -// should execute synchronously. -private val immediateDispatcher: CoroutineDispatcher = try { - Dispatchers.Main.immediate.also { - // This will throw if the implementation is missing. - it.isDispatchNeeded(EmptyCoroutineContext) - } -} catch (_: Throwable) { - Dispatchers.Unconfined -} - @OptIn(ExperimentalStdlibApi::class) -private fun CoroutineContext.resolveImmediateDispatcher(): CoroutineDispatcher { - val dispatcher = get(CoroutineDispatcher) - if (dispatcher is MainCoroutineDispatcher) { - try { - return dispatcher.immediate - } catch (_: UnsupportedOperationException) {} - } - return immediateDispatcher -} - -@Composable -internal fun rememberImmediateCoroutineScope(): CoroutineScope { - val scope = rememberCoroutineScope() - val isPreview = LocalInspectionMode.current - return remember(scope, isPreview) { - val context = if (isPreview) { - scope.coroutineContext + Dispatchers.Unconfined - } else { - scope.coroutineContext.run { this + resolveImmediateDispatcher() } - } - CoroutineScope(context) - } -} +internal val CoroutineContext.dispatcher: CoroutineDispatcher? + get() = get(CoroutineDispatcher) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47635f5901..415e7473f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,7 +60,6 @@ androidx-vectordrawable-animated = "androidx.vectordrawable:vectordrawable-anima coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } -coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } google-material = "com.google.android.material:material:1.12.0" diff --git a/internal/test-roborazzi/build.gradle.kts b/internal/test-roborazzi/build.gradle.kts index 9f8ff39207..b8ca5a5834 100644 --- a/internal/test-roborazzi/build.gradle.kts +++ b/internal/test-roborazzi/build.gradle.kts @@ -36,7 +36,6 @@ kotlin { named("jvmCommonTest").dependencies { implementation(compose.desktop.uiTestJUnit4) implementation(libs.bundles.test.jvm) - implementation(libs.coroutines.swing) } jvmTest.dependencies { implementation(compose.desktop.currentOs) diff --git a/samples/shared/build.gradle.kts b/samples/shared/build.gradle.kts index 998f856fd1..1a658db8c4 100644 --- a/samples/shared/build.gradle.kts +++ b/samples/shared/build.gradle.kts @@ -42,7 +42,6 @@ kotlin { jvmMain { dependencies { api(projects.coilNetworkOkhttp) - api(libs.coroutines.swing) } } named("wasmJsMain") { From faeb7be973be608e90bf07bdba11d55ad08c6f29 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 28 Nov 2024 18:48:43 -0800 Subject: [PATCH 02/34] Fix API. --- .../compose/internal/DelayedDispatchCoroutineDispatcher.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt index 9022e18cd6..df43e772f1 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt @@ -30,7 +30,7 @@ internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { /** * A [CoroutineDispatcher] that delays dispatching until the current [CoroutineDispatcher] changes. */ -internal class DelayedDispatchCoroutineDispatcher( +private class DelayedDispatchCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher() { private var dispatchEnabled = false From a906110ffb67597c13ae351b54096c418001a9b6 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 28 Nov 2024 22:25:53 -0800 Subject: [PATCH 03/34] Docs. --- .../compose/internal/DelayedDispatchCoroutineDispatcher.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt index df43e772f1..ea2fd1c471 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt @@ -28,7 +28,8 @@ internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { } /** - * A [CoroutineDispatcher] that delays dispatching until the current [CoroutineDispatcher] changes. + * A [CoroutineDispatcher] that delays dispatching to [delegate] until after the current + * [CoroutineDispatcher] changes. */ private class DelayedDispatchCoroutineDispatcher( private val delegate: CoroutineDispatcher, From f70aed3309cf2ec371b8519e3f8e636ceab72f69 Mon Sep 17 00:00:00 2001 From: Colin White Date: Tue, 3 Dec 2024 11:49:20 -0800 Subject: [PATCH 04/34] Add tests. --- .../DelayedDispatchCoroutineDispatcher.kt | 2 +- .../DelayedDispatchCoroutineDispatcherTest.kt | 79 +++++++++++++++++++ .../kotlin/coil3/request/ImageRequestTest.kt | 4 +- 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt index ea2fd1c471..b5d727edf4 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt @@ -31,7 +31,7 @@ internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { * A [CoroutineDispatcher] that delays dispatching to [delegate] until after the current * [CoroutineDispatcher] changes. */ -private class DelayedDispatchCoroutineDispatcher( +internal class DelayedDispatchCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher() { private var dispatchEnabled = false diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt new file mode 100644 index 0000000000..e7db0bd5b8 --- /dev/null +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt @@ -0,0 +1,79 @@ +package coil3.compose.internal + +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext + +class DelayedDispatchCoroutineDispatcherTest { + private val testDispatcher = TestCoroutineDispatcher() + + @Test + fun `does not dispatch when suspended`() = runTestWithDelayedDispatch( + context = DelayedDispatchCoroutineDispatcher(testDispatcher), + ) { + delay(100.milliseconds) + assertFalse { testDispatcher.dispatched } + } + + @Test + fun `does not dispatch when CoroutineDispatcher is not changed`() = runTestWithDelayedDispatch( + context = DelayedDispatchCoroutineDispatcher(testDispatcher), + ) { + withContext(TestCoroutineContextMarker()) {} + assertFalse { testDispatcher.dispatched } + } + + @Test + fun `does not dispatch with EmptyCoroutineContext`() = runTestWithDelayedDispatch( + context = DelayedDispatchCoroutineDispatcher(testDispatcher), + ) { + withContext(EmptyCoroutineContext) {} + assertFalse { testDispatcher.dispatched } + } + + @Test + fun `does not dispatch with Dispatchers_Unconfined`() = runTestWithDelayedDispatch( + context = DelayedDispatchCoroutineDispatcher(testDispatcher), + ) { + withContext(Dispatchers.Unconfined) {} + assertFalse { testDispatcher.dispatched } + } + + @Test + fun `does dispatch with Dispatchers_Default`() = runTestWithDelayedDispatch( + context = DelayedDispatchCoroutineDispatcher(testDispatcher), + ) { + withContext(Dispatchers.Default) {} + assertTrue { testDispatcher.dispatched } + } + + private fun runTestWithDelayedDispatch( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend CoroutineScope.() -> Unit, + ) = runTest { withContext(context, testBody) } + + private class TestCoroutineContextMarker : AbstractCoroutineContextElement(Key) { + object Key : CoroutineContext.Key + } + + private class TestCoroutineDispatcher : CoroutineDispatcher() { + var dispatched = false + private set + + override fun dispatch(context: CoroutineContext, block: Runnable) { + dispatched = true + block.run() + } + } +} diff --git a/coil-core/src/androidUnitTest/kotlin/coil3/request/ImageRequestTest.kt b/coil-core/src/androidUnitTest/kotlin/coil3/request/ImageRequestTest.kt index e7f9795b06..d2abff529c 100644 --- a/coil-core/src/androidUnitTest/kotlin/coil3/request/ImageRequestTest.kt +++ b/coil-core/src/androidUnitTest/kotlin/coil3/request/ImageRequestTest.kt @@ -15,6 +15,7 @@ import coil3.test.utils.RobolectricTest import coil3.test.utils.context import coil3.transition.CrossfadeTransition import coil3.transition.Transition +import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext import kotlin.test.assertEquals import kotlin.test.assertIs @@ -189,8 +190,7 @@ class ImageRequestTest : RobolectricTest() { assertNotEquals(request1.extras, request2.extras) } - private class TestCoroutineContextMarker : CoroutineContext.Element { - override val key get() = Key + private class TestCoroutineContextMarker : AbstractCoroutineContextElement(Key) { object Key : CoroutineContext.Key } } From e586ac53df22af5d31413603fbf05f37ed1f1d13 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 17:13:17 -0500 Subject: [PATCH 05/34] Change strategy. --- .../kotlin/coil3/compose/internal/utils.kt | 27 ++++++- .../DelayedDispatchCoroutineDispatcherTest.kt | 79 ------------------- coil-core/api/coil-core.klib.api | 14 ++++ .../coil3/intercept/EngineInterceptor.kt | 8 ++ .../DelayedDispatchCoroutineDispatcher.kt | 32 ++------ .../src/commonMain/kotlin/coil3/util/utils.kt | 6 ++ .../DelayedDispatchCoroutineDispatcherTest.kt | 53 +++++++++++++ 7 files changed, 110 insertions(+), 109 deletions(-) delete mode 100644 coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt rename {coil-compose-core/src/commonMain/kotlin/coil3/compose/internal => coil-core/src/commonMain/kotlin/coil3/util}/DelayedDispatchCoroutineDispatcher.kt (57%) create mode 100644 coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt index 1c03aa7b10..2ed40fb785 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isUnspecified @@ -32,9 +33,12 @@ import coil3.size.Dimension import coil3.size.Scale import coil3.size.Size as CoilSize import coil3.size.SizeResolver +import coil3.util.DelayedDispatchCoroutineDispatcher import kotlin.coroutines.CoroutineContext import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers /** Create an [ImageRequest] from the [model]. */ @Composable @@ -163,6 +167,25 @@ internal class AsyncImageState( } } +/** Create a [CoroutineScope] will contain a [DelayedDispatchCoroutineDispatcher] if necessary. */ +@Composable +internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { + val scope = rememberCoroutineScope() + return remember(scope) { + val currentContext = scope.coroutineContext + val currentDispatcher = scope.coroutineContext.dispatcher + if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { + CoroutineScope(currentContext + DelayedDispatchCoroutineDispatcher(currentDispatcher)) + } else { + scope + } + } +} + +@OptIn(ExperimentalStdlibApi::class) +internal val CoroutineContext.dispatcher: CoroutineDispatcher? + get() = get(CoroutineDispatcher) + @Stable internal fun Modifier.contentDescription(contentDescription: String?): Modifier { if (contentDescription != null) { @@ -212,7 +235,3 @@ internal inline fun Float.takeOrElse(block: () -> Float) = if (isFinite()) this internal fun Size.toIntSize() = IntSize(width.roundToInt(), height.roundToInt()) internal val Size.isPositive get() = width >= 0.5 && height >= 0.5 - -@OptIn(ExperimentalStdlibApi::class) -internal val CoroutineContext.dispatcher: CoroutineDispatcher? - get() = get(CoroutineDispatcher) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt deleted file mode 100644 index e7db0bd5b8..0000000000 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcherTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package coil3.compose.internal - -import kotlin.coroutines.AbstractCoroutineContextElement -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Runnable -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext - -class DelayedDispatchCoroutineDispatcherTest { - private val testDispatcher = TestCoroutineDispatcher() - - @Test - fun `does not dispatch when suspended`() = runTestWithDelayedDispatch( - context = DelayedDispatchCoroutineDispatcher(testDispatcher), - ) { - delay(100.milliseconds) - assertFalse { testDispatcher.dispatched } - } - - @Test - fun `does not dispatch when CoroutineDispatcher is not changed`() = runTestWithDelayedDispatch( - context = DelayedDispatchCoroutineDispatcher(testDispatcher), - ) { - withContext(TestCoroutineContextMarker()) {} - assertFalse { testDispatcher.dispatched } - } - - @Test - fun `does not dispatch with EmptyCoroutineContext`() = runTestWithDelayedDispatch( - context = DelayedDispatchCoroutineDispatcher(testDispatcher), - ) { - withContext(EmptyCoroutineContext) {} - assertFalse { testDispatcher.dispatched } - } - - @Test - fun `does not dispatch with Dispatchers_Unconfined`() = runTestWithDelayedDispatch( - context = DelayedDispatchCoroutineDispatcher(testDispatcher), - ) { - withContext(Dispatchers.Unconfined) {} - assertFalse { testDispatcher.dispatched } - } - - @Test - fun `does dispatch with Dispatchers_Default`() = runTestWithDelayedDispatch( - context = DelayedDispatchCoroutineDispatcher(testDispatcher), - ) { - withContext(Dispatchers.Default) {} - assertTrue { testDispatcher.dispatched } - } - - private fun runTestWithDelayedDispatch( - context: CoroutineContext = EmptyCoroutineContext, - testBody: suspend CoroutineScope.() -> Unit, - ) = runTest { withContext(context, testBody) } - - private class TestCoroutineContextMarker : AbstractCoroutineContextElement(Key) { - object Key : CoroutineContext.Key - } - - private class TestCoroutineDispatcher : CoroutineDispatcher() { - var dispatched = false - private set - - override fun dispatch(context: CoroutineContext, block: Runnable) { - dispatched = true - block.run() - } - } -} diff --git a/coil-core/api/coil-core.klib.api b/coil-core/api/coil-core.klib.api index 317d9ca436..30403195cd 100644 --- a/coil-core/api/coil-core.klib.api +++ b/coil-core/api/coil-core.klib.api @@ -810,6 +810,20 @@ final class coil3.util/DebugLogger : coil3.util/Logger { // coil3.util/DebugLogg final fun log(kotlin/String, coil3.util/Logger.Level, kotlin/String?, kotlin/Throwable?) // coil3.util/DebugLogger.log|log(kotlin.String;coil3.util.Logger.Level;kotlin.String?;kotlin.Throwable?){}[0] } +final class coil3.util/DelayedDispatchCoroutineDispatcher : kotlinx.coroutines/CoroutineDispatcher { // coil3.util/DelayedDispatchCoroutineDispatcher|null[0] + constructor (kotlinx.coroutines/CoroutineDispatcher) // coil3.util/DelayedDispatchCoroutineDispatcher.|(kotlinx.coroutines.CoroutineDispatcher){}[0] + + final var dispatchEnabled // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchEnabled|{}dispatchEnabled[0] + final fun (): kotlin/Boolean // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchEnabled.|(){}[0] + final fun (kotlin/Boolean) // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchEnabled.|(kotlin.Boolean){}[0] + + final fun dispatch(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/DelayedDispatchCoroutineDispatcher.dispatch|dispatch(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] + final fun dispatchYield(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchYield|dispatchYield(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] + final fun isDispatchNeeded(kotlin.coroutines/CoroutineContext): kotlin/Boolean // coil3.util/DelayedDispatchCoroutineDispatcher.isDispatchNeeded|isDispatchNeeded(kotlin.coroutines.CoroutineContext){}[0] + final fun limitedParallelism(kotlin/Int, kotlin/String?): kotlinx.coroutines/CoroutineDispatcher // coil3.util/DelayedDispatchCoroutineDispatcher.limitedParallelism|limitedParallelism(kotlin.Int;kotlin.String?){}[0] + final fun toString(): kotlin/String // coil3.util/DelayedDispatchCoroutineDispatcher.toString|toString(){}[0] +} + final class coil3/BitmapImage : coil3/Image { // coil3/BitmapImage|null[0] final val bitmap // coil3/BitmapImage.bitmap|{}bitmap[0] final fun (): org.jetbrains.skia/Bitmap // coil3/BitmapImage.bitmap.|(){}[0] diff --git a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt index 60c82b6f1d..705fb26e47 100644 --- a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt +++ b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt @@ -24,11 +24,13 @@ import coil3.request.SuccessResult import coil3.request.allowConversionToBitmap import coil3.request.transformations import coil3.transform.Transformation +import coil3.util.DelayedDispatchCoroutineDispatcher import coil3.util.ErrorResult import coil3.util.Logger import coil3.util.SystemCallbacks import coil3.util.addFirst import coil3.util.closeQuietly +import coil3.util.dispatcher import coil3.util.eventListener import coil3.util.foldIndices import coil3.util.isPlaceholderCached @@ -71,6 +73,12 @@ internal class EngineInterceptor( return memoryCacheService.newResult(chain, request, cacheKey, cacheValue) } + // Re-enable dispatching before starting to fetch. + val dispatcher = coroutineContext.dispatcher + if (dispatcher is DelayedDispatchCoroutineDispatcher) { + dispatcher.dispatchEnabled = true + } + // Slow path: fetch, decode, transform, and cache the image. return withContext(request.fetcherCoroutineContext) { // Fetch and decode the image. diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt b/coil-core/src/commonMain/kotlin/coil3/util/DelayedDispatchCoroutineDispatcher.kt similarity index 57% rename from coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt rename to coil-core/src/commonMain/kotlin/coil3/util/DelayedDispatchCoroutineDispatcher.kt index b5d727edf4..4b106384ac 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineDispatcher.kt +++ b/coil-core/src/commonMain/kotlin/coil3/util/DelayedDispatchCoroutineDispatcher.kt @@ -1,40 +1,20 @@ -package coil3.compose.internal +package coil3.util -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import coil3.annotation.InternalCoilApi import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable /** - * Create a [CoroutineScope] will contain a [DelayedDispatchCoroutineDispatcher] if necessary. + * A [CoroutineDispatcher] that does not dispatch to [delegate] while [dispatchEnabled] is false. */ -@Composable -internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { - val scope = rememberCoroutineScope() - return remember(scope) { - val currentContext = scope.coroutineContext - val currentDispatcher = scope.coroutineContext.dispatcher - if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { - CoroutineScope(currentContext + DelayedDispatchCoroutineDispatcher(currentDispatcher)) - } else { - scope - } - } -} - -/** - * A [CoroutineDispatcher] that delays dispatching to [delegate] until after the current - * [CoroutineDispatcher] changes. - */ -internal class DelayedDispatchCoroutineDispatcher( +@InternalCoilApi +class DelayedDispatchCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher() { - private var dispatchEnabled = false + var dispatchEnabled = false private val currentDispatcher: CoroutineDispatcher get() = if (dispatchEnabled) delegate else Dispatchers.Unconfined diff --git a/coil-core/src/commonMain/kotlin/coil3/util/utils.kt b/coil-core/src/commonMain/kotlin/coil3/util/utils.kt index 3f716aaa83..8dd4a6f783 100644 --- a/coil-core/src/commonMain/kotlin/coil3/util/utils.kt +++ b/coil-core/src/commonMain/kotlin/coil3/util/utils.kt @@ -12,8 +12,10 @@ import coil3.intercept.RealInterceptorChain import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.NullRequestDataException +import kotlin.coroutines.CoroutineContext import kotlin.experimental.ExperimentalNativeApi import kotlin.reflect.KClass +import kotlinx.coroutines.CoroutineDispatcher import okio.Closeable internal expect fun println(level: Logger.Level, tag: String, message: String) @@ -97,3 +99,7 @@ internal expect class WeakReference(referred: T) { } internal expect fun Image.prepareToDraw() + +@OptIn(ExperimentalStdlibApi::class) +internal val CoroutineContext.dispatcher: CoroutineDispatcher? + get() = get(CoroutineDispatcher) diff --git a/coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt b/coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt new file mode 100644 index 0000000000..e8d94ae452 --- /dev/null +++ b/coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt @@ -0,0 +1,53 @@ +package coil3.util + +import kotlin.coroutines.CoroutineContext +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext + +class DelayedDispatchCoroutineDispatcherTest { + private val testDispatcher = TestCoroutineDispatcher() + private val delayedDispatcher = DelayedDispatchCoroutineDispatcher(testDispatcher) + + @Test + fun `does not dispatch when suspended by default`() = runTestWithDelayedDispatch { + delay(100.milliseconds) + assertFalse { testDispatcher.dispatched } + } + + @Test + fun `does not dispatch when dispatchEnabled=false`() = runTestWithDelayedDispatch { + delayedDispatcher.dispatchEnabled = false + withContext(Dispatchers.Default) {} + assertFalse { testDispatcher.dispatched } + } + + @Test + fun `does dispatch when dispatchEnabled=true`() = runTestWithDelayedDispatch { + delayedDispatcher.dispatchEnabled = true + withContext(Dispatchers.Default) {} + assertTrue { testDispatcher.dispatched } + } + + private fun runTestWithDelayedDispatch( + testBody: suspend CoroutineScope.() -> Unit, + ) = runTest { withContext(delayedDispatcher, testBody) } + + private class TestCoroutineDispatcher : CoroutineDispatcher() { + var dispatched = false + private set + + override fun dispatch(context: CoroutineContext, block: Runnable) { + dispatched = true + block.run() + } + } +} From 0a872e7299e59825eddbbb7bfd3bf8a9ab6094f8 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 17:36:47 -0500 Subject: [PATCH 06/34] Rename dispatcher. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 4 ++-- .../kotlin/coil3/compose/internal/utils.kt | 8 +++---- coil-core/api/coil-core.klib.api | 24 +++++++++---------- .../coil3/intercept/EngineInterceptor.kt | 6 ++--- ...orwardingUnconfinedCoroutineDispatcher.kt} | 17 +++++++------ ...rdingUnconfinedCoroutineDispatcherTest.kt} | 18 +++++++------- 6 files changed, 38 insertions(+), 39 deletions(-) rename coil-core/src/commonMain/kotlin/coil3/util/{DelayedDispatchCoroutineDispatcher.kt => ForwardingUnconfinedCoroutineDispatcher.kt} (65%) rename coil-core/src/commonTest/kotlin/coil3/util/{DelayedDispatchCoroutineDispatcherTest.kt => ForwardingUnconfinedCoroutineDispatcherTest.kt} (66%) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 0177e0c3b0..8bcd50d5c1 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,7 +31,7 @@ import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState import coil3.compose.internal.onStateOf -import coil3.compose.internal.rememberDelayedDispatchCoroutineScope +import coil3.compose.internal.rememberForwardingUnconfinedCoroutineScope import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf @@ -136,7 +136,7 @@ private fun rememberAsyncImagePainter( val input = Input(state.imageLoader, request, state.modelEqualityDelegate) val painter = remember { AsyncImagePainter(input) } - painter.scope = rememberDelayedDispatchCoroutineScope() + painter.scope = rememberForwardingUnconfinedCoroutineScope() painter.transform = transform painter.onState = onState painter.contentScale = contentScale diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt index 2ed40fb785..aa5aaa61c5 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt @@ -33,7 +33,7 @@ import coil3.size.Dimension import coil3.size.Scale import coil3.size.Size as CoilSize import coil3.size.SizeResolver -import coil3.util.DelayedDispatchCoroutineDispatcher +import coil3.util.ForwardingUnconfinedCoroutineDispatcher import kotlin.coroutines.CoroutineContext import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineDispatcher @@ -167,15 +167,15 @@ internal class AsyncImageState( } } -/** Create a [CoroutineScope] will contain a [DelayedDispatchCoroutineDispatcher] if necessary. */ +/** Create a [CoroutineScope] that will contain a [ForwardingUnconfinedCoroutineDispatcher] if necessary. */ @Composable -internal fun rememberDelayedDispatchCoroutineScope(): CoroutineScope { +internal fun rememberForwardingUnconfinedCoroutineScope(): CoroutineScope { val scope = rememberCoroutineScope() return remember(scope) { val currentContext = scope.coroutineContext val currentDispatcher = scope.coroutineContext.dispatcher if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { - CoroutineScope(currentContext + DelayedDispatchCoroutineDispatcher(currentDispatcher)) + CoroutineScope(currentContext + ForwardingUnconfinedCoroutineDispatcher(currentDispatcher)) } else { scope } diff --git a/coil-core/api/coil-core.klib.api b/coil-core/api/coil-core.klib.api index 30403195cd..bdc6a898db 100644 --- a/coil-core/api/coil-core.klib.api +++ b/coil-core/api/coil-core.klib.api @@ -810,18 +810,18 @@ final class coil3.util/DebugLogger : coil3.util/Logger { // coil3.util/DebugLogg final fun log(kotlin/String, coil3.util/Logger.Level, kotlin/String?, kotlin/Throwable?) // coil3.util/DebugLogger.log|log(kotlin.String;coil3.util.Logger.Level;kotlin.String?;kotlin.Throwable?){}[0] } -final class coil3.util/DelayedDispatchCoroutineDispatcher : kotlinx.coroutines/CoroutineDispatcher { // coil3.util/DelayedDispatchCoroutineDispatcher|null[0] - constructor (kotlinx.coroutines/CoroutineDispatcher) // coil3.util/DelayedDispatchCoroutineDispatcher.|(kotlinx.coroutines.CoroutineDispatcher){}[0] - - final var dispatchEnabled // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchEnabled|{}dispatchEnabled[0] - final fun (): kotlin/Boolean // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchEnabled.|(){}[0] - final fun (kotlin/Boolean) // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchEnabled.|(kotlin.Boolean){}[0] - - final fun dispatch(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/DelayedDispatchCoroutineDispatcher.dispatch|dispatch(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] - final fun dispatchYield(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/DelayedDispatchCoroutineDispatcher.dispatchYield|dispatchYield(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] - final fun isDispatchNeeded(kotlin.coroutines/CoroutineContext): kotlin/Boolean // coil3.util/DelayedDispatchCoroutineDispatcher.isDispatchNeeded|isDispatchNeeded(kotlin.coroutines.CoroutineContext){}[0] - final fun limitedParallelism(kotlin/Int, kotlin/String?): kotlinx.coroutines/CoroutineDispatcher // coil3.util/DelayedDispatchCoroutineDispatcher.limitedParallelism|limitedParallelism(kotlin.Int;kotlin.String?){}[0] - final fun toString(): kotlin/String // coil3.util/DelayedDispatchCoroutineDispatcher.toString|toString(){}[0] +final class coil3.util/ForwardingUnconfinedCoroutineDispatcher : kotlinx.coroutines/CoroutineDispatcher { // coil3.util/ForwardingUnconfinedCoroutineDispatcher|null[0] + constructor (kotlinx.coroutines/CoroutineDispatcher) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.|(kotlinx.coroutines.CoroutineDispatcher){}[0] + + final var unconfined // coil3.util/ForwardingUnconfinedCoroutineDispatcher.unconfined|{}unconfined[0] + final fun (): kotlin/Boolean // coil3.util/ForwardingUnconfinedCoroutineDispatcher.unconfined.|(){}[0] + final fun (kotlin/Boolean) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.unconfined.|(kotlin.Boolean){}[0] + + final fun dispatch(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.dispatch|dispatch(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] + final fun dispatchYield(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.dispatchYield|dispatchYield(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] + final fun isDispatchNeeded(kotlin.coroutines/CoroutineContext): kotlin/Boolean // coil3.util/ForwardingUnconfinedCoroutineDispatcher.isDispatchNeeded|isDispatchNeeded(kotlin.coroutines.CoroutineContext){}[0] + final fun limitedParallelism(kotlin/Int, kotlin/String?): kotlinx.coroutines/CoroutineDispatcher // coil3.util/ForwardingUnconfinedCoroutineDispatcher.limitedParallelism|limitedParallelism(kotlin.Int;kotlin.String?){}[0] + final fun toString(): kotlin/String // coil3.util/ForwardingUnconfinedCoroutineDispatcher.toString|toString(){}[0] } final class coil3/BitmapImage : coil3/Image { // coil3/BitmapImage|null[0] diff --git a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt index 705fb26e47..af35373fab 100644 --- a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt +++ b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt @@ -24,8 +24,8 @@ import coil3.request.SuccessResult import coil3.request.allowConversionToBitmap import coil3.request.transformations import coil3.transform.Transformation -import coil3.util.DelayedDispatchCoroutineDispatcher import coil3.util.ErrorResult +import coil3.util.ForwardingUnconfinedCoroutineDispatcher import coil3.util.Logger import coil3.util.SystemCallbacks import coil3.util.addFirst @@ -75,8 +75,8 @@ internal class EngineInterceptor( // Re-enable dispatching before starting to fetch. val dispatcher = coroutineContext.dispatcher - if (dispatcher is DelayedDispatchCoroutineDispatcher) { - dispatcher.dispatchEnabled = true + if (dispatcher is ForwardingUnconfinedCoroutineDispatcher) { + dispatcher.unconfined = false } // Slow path: fetch, decode, transform, and cache the image. diff --git a/coil-core/src/commonMain/kotlin/coil3/util/DelayedDispatchCoroutineDispatcher.kt b/coil-core/src/commonMain/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcher.kt similarity index 65% rename from coil-core/src/commonMain/kotlin/coil3/util/DelayedDispatchCoroutineDispatcher.kt rename to coil-core/src/commonMain/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcher.kt index 4b106384ac..121c1c7cbe 100644 --- a/coil-core/src/commonMain/kotlin/coil3/util/DelayedDispatchCoroutineDispatcher.kt +++ b/coil-core/src/commonMain/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcher.kt @@ -8,22 +8,21 @@ import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable /** - * A [CoroutineDispatcher] that does not dispatch to [delegate] while [dispatchEnabled] is false. + * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true + * and [delegate] when [unconfined] is false. */ @InternalCoilApi -class DelayedDispatchCoroutineDispatcher( +class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher() { - var dispatchEnabled = false + + /** Delegates to [Dispatchers.Unconfined] while true. */ + var unconfined = true private val currentDispatcher: CoroutineDispatcher - get() = if (dispatchEnabled) delegate else Dispatchers.Unconfined + get() = if (unconfined) Dispatchers.Unconfined else delegate override fun isDispatchNeeded(context: CoroutineContext): Boolean { - // Enable dispatching when the context's dispatcher changes and it's not Dispatchers.Unconfined. - dispatchEnabled = dispatchEnabled || context.dispatcher.let { - it != null && it != this && it != Dispatchers.Unconfined - } return currentDispatcher.isDispatchNeeded(context) } @@ -41,6 +40,6 @@ class DelayedDispatchCoroutineDispatcher( } override fun toString(): String { - return "DelayedDispatchCoroutineDispatcher(delegate=$delegate)" + return "ForwardingUnconfinedCoroutineDispatcher(delegate=$delegate)" } } diff --git a/coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt b/coil-core/src/commonTest/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcherTest.kt similarity index 66% rename from coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt rename to coil-core/src/commonTest/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcherTest.kt index e8d94ae452..685d657ef8 100644 --- a/coil-core/src/commonTest/kotlin/coil3/util/DelayedDispatchCoroutineDispatcherTest.kt +++ b/coil-core/src/commonTest/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -13,33 +13,33 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext -class DelayedDispatchCoroutineDispatcherTest { +class ForwardingUnconfinedCoroutineDispatcherTest { private val testDispatcher = TestCoroutineDispatcher() - private val delayedDispatcher = DelayedDispatchCoroutineDispatcher(testDispatcher) + private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) @Test - fun `does not dispatch when suspended by default`() = runTestWithDelayedDispatch { + fun `does not dispatch when suspended by default`() = test { delay(100.milliseconds) assertFalse { testDispatcher.dispatched } } @Test - fun `does not dispatch when dispatchEnabled=false`() = runTestWithDelayedDispatch { - delayedDispatcher.dispatchEnabled = false + fun `does not dispatch when unconfined=true`() = test { + forwardingDispatcher.unconfined = true withContext(Dispatchers.Default) {} assertFalse { testDispatcher.dispatched } } @Test - fun `does dispatch when dispatchEnabled=true`() = runTestWithDelayedDispatch { - delayedDispatcher.dispatchEnabled = true + fun `does dispatch when unconfined=false`() = test { + forwardingDispatcher.unconfined = false withContext(Dispatchers.Default) {} assertTrue { testDispatcher.dispatched } } - private fun runTestWithDelayedDispatch( + private fun test( testBody: suspend CoroutineScope.() -> Unit, - ) = runTest { withContext(delayedDispatcher, testBody) } + ) = runTest { withContext(forwardingDispatcher, testBody) } private class TestCoroutineDispatcher : CoroutineDispatcher() { var dispatched = false From eb00783480a7fb70032585e9bc7181adafc28293 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 17:54:01 -0500 Subject: [PATCH 07/34] Slight tweak. --- .../src/commonMain/kotlin/coil3/compose/internal/utils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt index aa5aaa61c5..f5aca77a2f 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt @@ -173,7 +173,7 @@ internal fun rememberForwardingUnconfinedCoroutineScope(): CoroutineScope { val scope = rememberCoroutineScope() return remember(scope) { val currentContext = scope.coroutineContext - val currentDispatcher = scope.coroutineContext.dispatcher + val currentDispatcher = currentContext.dispatcher if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { CoroutineScope(currentContext + ForwardingUnconfinedCoroutineDispatcher(currentDispatcher)) } else { From d167d518af5ffe52ddd53657abad2c1ac7769640 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 19:01:09 -0500 Subject: [PATCH 08/34] Rename and change implementation. --- .../api/coil-compose-core.klib.api | 2 + ...nconfinedCoroutineDispatcherAndroidTest.kt | 48 +++++++++++++++++++ ...ForwardingUnconfinedCoroutineDispatcher.kt | 33 ++++++++++--- .../kotlin/coil3/compose/internal/utils.kt | 27 ++--------- ...ardingUnconfinedCoroutineDispatcherTest.kt | 27 ++++++----- coil-core/api/coil-core.klib.api | 20 +++----- .../coil3/intercept/EngineInterceptor.kt | 7 +-- .../kotlin/coil3/util/Unconfined.kt | 14 ++++++ 8 files changed, 116 insertions(+), 62 deletions(-) create mode 100644 coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt rename {coil-core/src/commonMain/kotlin/coil3/util => coil-compose-core/src/commonMain/kotlin/coil3/compose/internal}/ForwardingUnconfinedCoroutineDispatcher.kt (55%) rename {coil-core/src/commonTest/kotlin/coil3/util => coil-compose-core/src/commonTest/kotlin/coil3/compose/internal}/ForwardingUnconfinedCoroutineDispatcherTest.kt (59%) create mode 100644 coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt diff --git a/coil-compose-core/api/coil-compose-core.klib.api b/coil-compose-core/api/coil-compose-core.klib.api index fd5127f8b7..027b6e7146 100644 --- a/coil-compose-core/api/coil-compose-core.klib.api +++ b/coil-compose-core/api/coil-compose-core.klib.api @@ -183,6 +183,7 @@ final class coil3.compose/ImagePainter : androidx.compose.ui.graphics.painter/Pa final val coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop|#static{}coil3_compose_internal_AsyncImageState$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop|#static{}coil3_compose_internal_ContentPainterElement$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop|#static{}coil3_compose_internal_ContentPainterNode$stableprop[0] +final val coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop // coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop|#static{}coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop[0] final val coil3.compose/LocalAsyncImageModelEqualityDelegate // coil3.compose/LocalAsyncImageModelEqualityDelegate|{}LocalAsyncImageModelEqualityDelegate[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // coil3.compose/LocalAsyncImageModelEqualityDelegate.|(){}[0] final val coil3.compose/LocalAsyncImagePreviewHandler // coil3.compose/LocalAsyncImagePreviewHandler|{}LocalAsyncImagePreviewHandler[0] @@ -204,6 +205,7 @@ final fun (coil3/Image).coil3.compose/asPainter(coil3/PlatformContext, androidx. final fun coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter|coil3_compose_internal_AsyncImageState$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter|coil3_compose_internal_ContentPainterElement$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter|coil3_compose_internal_ContentPainterNode$stableprop_getter(){}[0] +final fun coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter|coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter(){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun coil3.compose/DrawScopeSizeResolver(): coil3.compose/DrawScopeSizeResolver // coil3.compose/DrawScopeSizeResolver|DrawScopeSizeResolver(){}[0] diff --git a/coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt b/coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt new file mode 100644 index 0000000000..87f404e051 --- /dev/null +++ b/coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt @@ -0,0 +1,48 @@ +package coil3.compose.internal + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.test.utils.ComposeTestActivity +import coil3.util.Unconfined +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test + +class ForwardingUnconfinedCoroutineDispatcherAndroidTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun scopeDoesNotDispatch() { + composeTestRule.setContent { + val scope = rememberForwardingUnconfinedCoroutineScope() + (scope.coroutineContext.dispatcher as Unconfined).unconfined = true + + LaunchedEffect(Unit) { + var immediate = false + scope.launch { + immediate = true + } + assertTrue { immediate } + } + } + } + @Test + fun scopeDoesDispatch() { + composeTestRule.setContent { + val scope = rememberForwardingUnconfinedCoroutineScope() + (scope.coroutineContext.dispatcher as Unconfined).unconfined = false + + LaunchedEffect(Unit) { + var immediate = false + scope.launch { + immediate = true + } + assertFalse { immediate } + } + } + } +} diff --git a/coil-core/src/commonMain/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt similarity index 55% rename from coil-core/src/commonMain/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcher.kt rename to coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index 121c1c7cbe..b57846c847 100644 --- a/coil-core/src/commonMain/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -1,23 +1,42 @@ -package coil3.util +package coil3.compose.internal -import coil3.annotation.InternalCoilApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable +/** + * Create a [CoroutineScope] that will contain a [ForwardingUnconfinedCoroutineDispatcher] if necessary. + */ +@Composable +internal fun rememberForwardingUnconfinedCoroutineScope(): CoroutineScope { + val scope = rememberCoroutineScope() + return remember(scope) { + val currentContext = scope.coroutineContext + val currentDispatcher = currentContext.dispatcher + if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { + CoroutineScope(currentContext + ForwardingUnconfinedCoroutineDispatcher(currentDispatcher)) + } else { + scope + } + } +} + /** * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true * and [delegate] when [unconfined] is false. */ -@InternalCoilApi -class ForwardingUnconfinedCoroutineDispatcher( +internal class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, -) : CoroutineDispatcher() { +) : CoroutineDispatcher(), Unconfined { - /** Delegates to [Dispatchers.Unconfined] while true. */ - var unconfined = true + override var unconfined = true private val currentDispatcher: CoroutineDispatcher get() = if (unconfined) Dispatchers.Unconfined else delegate diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt index f5aca77a2f..1c03aa7b10 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/utils.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.isUnspecified @@ -33,12 +32,9 @@ import coil3.size.Dimension import coil3.size.Scale import coil3.size.Size as CoilSize import coil3.size.SizeResolver -import coil3.util.ForwardingUnconfinedCoroutineDispatcher import kotlin.coroutines.CoroutineContext import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers /** Create an [ImageRequest] from the [model]. */ @Composable @@ -167,25 +163,6 @@ internal class AsyncImageState( } } -/** Create a [CoroutineScope] that will contain a [ForwardingUnconfinedCoroutineDispatcher] if necessary. */ -@Composable -internal fun rememberForwardingUnconfinedCoroutineScope(): CoroutineScope { - val scope = rememberCoroutineScope() - return remember(scope) { - val currentContext = scope.coroutineContext - val currentDispatcher = currentContext.dispatcher - if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { - CoroutineScope(currentContext + ForwardingUnconfinedCoroutineDispatcher(currentDispatcher)) - } else { - scope - } - } -} - -@OptIn(ExperimentalStdlibApi::class) -internal val CoroutineContext.dispatcher: CoroutineDispatcher? - get() = get(CoroutineDispatcher) - @Stable internal fun Modifier.contentDescription(contentDescription: String?): Modifier { if (contentDescription != null) { @@ -235,3 +212,7 @@ internal inline fun Float.takeOrElse(block: () -> Float) = if (isFinite()) this internal fun Size.toIntSize() = IntSize(width.roundToInt(), height.roundToInt()) internal val Size.isPositive get() = width >= 0.5 && height >= 0.5 + +@OptIn(ExperimentalStdlibApi::class) +internal val CoroutineContext.dispatcher: CoroutineDispatcher? + get() = get(CoroutineDispatcher) diff --git a/coil-core/src/commonTest/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt similarity index 59% rename from coil-core/src/commonTest/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcherTest.kt rename to coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt index 685d657ef8..82fcd465cd 100644 --- a/coil-core/src/commonTest/kotlin/coil3/util/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -1,52 +1,53 @@ -package coil3.util +package coil3.compose.internal import kotlin.coroutines.CoroutineContext import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue +import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext class ForwardingUnconfinedCoroutineDispatcherTest { + private val scheduler = TestCoroutineScheduler() private val testDispatcher = TestCoroutineDispatcher() private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) @Test - fun `does not dispatch when suspended by default`() = test { + fun `does not dispatch when suspended by default`() = runTestWithForwardingDispatcher { delay(100.milliseconds) - assertFalse { testDispatcher.dispatched } + assertEquals(0, testDispatcher.dispatchCount) } @Test - fun `does not dispatch when unconfined=true`() = test { + fun `does not dispatch when unconfined=true`() = runTestWithForwardingDispatcher { forwardingDispatcher.unconfined = true withContext(Dispatchers.Default) {} - assertFalse { testDispatcher.dispatched } + assertEquals(0, testDispatcher.dispatchCount) } @Test - fun `does dispatch when unconfined=false`() = test { + fun `does dispatch when unconfined=false`() = runTestWithForwardingDispatcher { forwardingDispatcher.unconfined = false withContext(Dispatchers.Default) {} - assertTrue { testDispatcher.dispatched } + assertEquals(1, testDispatcher.dispatchCount) } - private fun test( + private fun runTestWithForwardingDispatcher( testBody: suspend CoroutineScope.() -> Unit, - ) = runTest { withContext(forwardingDispatcher, testBody) } + ) = runTest(forwardingDispatcher, testBody = testBody) private class TestCoroutineDispatcher : CoroutineDispatcher() { - var dispatched = false + var dispatchCount = 0 private set override fun dispatch(context: CoroutineContext, block: Runnable) { - dispatched = true + dispatchCount++ block.run() } } diff --git a/coil-core/api/coil-core.klib.api b/coil-core/api/coil-core.klib.api index bdc6a898db..79e86ceba9 100644 --- a/coil-core/api/coil-core.klib.api +++ b/coil-core/api/coil-core.klib.api @@ -276,6 +276,12 @@ abstract interface coil3.util/Logger { // coil3.util/Logger|null[0] } } +abstract interface coil3.util/Unconfined { // coil3.util/Unconfined|null[0] + abstract var unconfined // coil3.util/Unconfined.unconfined|{}unconfined[0] + abstract fun (): kotlin/Boolean // coil3.util/Unconfined.unconfined.|(){}[0] + abstract fun (kotlin/Boolean) // coil3.util/Unconfined.unconfined.|(kotlin.Boolean){}[0] +} + abstract interface coil3/Image { // coil3/Image|null[0] abstract val height // coil3/Image.height|{}height[0] abstract fun (): kotlin/Int // coil3/Image.height.|(){}[0] @@ -810,20 +816,6 @@ final class coil3.util/DebugLogger : coil3.util/Logger { // coil3.util/DebugLogg final fun log(kotlin/String, coil3.util/Logger.Level, kotlin/String?, kotlin/Throwable?) // coil3.util/DebugLogger.log|log(kotlin.String;coil3.util.Logger.Level;kotlin.String?;kotlin.Throwable?){}[0] } -final class coil3.util/ForwardingUnconfinedCoroutineDispatcher : kotlinx.coroutines/CoroutineDispatcher { // coil3.util/ForwardingUnconfinedCoroutineDispatcher|null[0] - constructor (kotlinx.coroutines/CoroutineDispatcher) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.|(kotlinx.coroutines.CoroutineDispatcher){}[0] - - final var unconfined // coil3.util/ForwardingUnconfinedCoroutineDispatcher.unconfined|{}unconfined[0] - final fun (): kotlin/Boolean // coil3.util/ForwardingUnconfinedCoroutineDispatcher.unconfined.|(){}[0] - final fun (kotlin/Boolean) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.unconfined.|(kotlin.Boolean){}[0] - - final fun dispatch(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.dispatch|dispatch(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] - final fun dispatchYield(kotlin.coroutines/CoroutineContext, kotlinx.coroutines/Runnable) // coil3.util/ForwardingUnconfinedCoroutineDispatcher.dispatchYield|dispatchYield(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.Runnable){}[0] - final fun isDispatchNeeded(kotlin.coroutines/CoroutineContext): kotlin/Boolean // coil3.util/ForwardingUnconfinedCoroutineDispatcher.isDispatchNeeded|isDispatchNeeded(kotlin.coroutines.CoroutineContext){}[0] - final fun limitedParallelism(kotlin/Int, kotlin/String?): kotlinx.coroutines/CoroutineDispatcher // coil3.util/ForwardingUnconfinedCoroutineDispatcher.limitedParallelism|limitedParallelism(kotlin.Int;kotlin.String?){}[0] - final fun toString(): kotlin/String // coil3.util/ForwardingUnconfinedCoroutineDispatcher.toString|toString(){}[0] -} - final class coil3/BitmapImage : coil3/Image { // coil3/BitmapImage|null[0] final val bitmap // coil3/BitmapImage.bitmap|{}bitmap[0] final fun (): org.jetbrains.skia/Bitmap // coil3/BitmapImage.bitmap.|(){}[0] diff --git a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt index af35373fab..85f21045dc 100644 --- a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt +++ b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt @@ -25,9 +25,9 @@ import coil3.request.allowConversionToBitmap import coil3.request.transformations import coil3.transform.Transformation import coil3.util.ErrorResult -import coil3.util.ForwardingUnconfinedCoroutineDispatcher import coil3.util.Logger import coil3.util.SystemCallbacks +import coil3.util.Unconfined import coil3.util.addFirst import coil3.util.closeQuietly import coil3.util.dispatcher @@ -74,10 +74,7 @@ internal class EngineInterceptor( } // Re-enable dispatching before starting to fetch. - val dispatcher = coroutineContext.dispatcher - if (dispatcher is ForwardingUnconfinedCoroutineDispatcher) { - dispatcher.unconfined = false - } + (coroutineContext.dispatcher as? Unconfined)?.unconfined = true // Slow path: fetch, decode, transform, and cache the image. return withContext(request.fetcherCoroutineContext) { diff --git a/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt b/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt new file mode 100644 index 0000000000..2cd7e65589 --- /dev/null +++ b/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt @@ -0,0 +1,14 @@ +package coil3.util + +import coil3.annotation.InternalCoilApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * A [CoroutineDispatcher] feature that delegates to [Dispatchers.Unconfined] while [unconfined] is true. + */ +@InternalCoilApi +interface Unconfined { + /** Delegates to [Dispatchers.Unconfined] while true. */ + var unconfined: Boolean +} From 8146de6412506d4c9fe3f2ba60d23b665ea93c25 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 19:03:07 -0500 Subject: [PATCH 09/34] Remove Android test. --- ...nconfinedCoroutineDispatcherAndroidTest.kt | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt diff --git a/coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt b/coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt deleted file mode 100644 index 87f404e051..0000000000 --- a/coil-compose-core/src/androidInstrumentedTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherAndroidTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package coil3.compose.internal - -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import coil3.test.utils.ComposeTestActivity -import coil3.util.Unconfined -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlinx.coroutines.launch -import org.junit.Rule -import org.junit.Test - -class ForwardingUnconfinedCoroutineDispatcherAndroidTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Test - fun scopeDoesNotDispatch() { - composeTestRule.setContent { - val scope = rememberForwardingUnconfinedCoroutineScope() - (scope.coroutineContext.dispatcher as Unconfined).unconfined = true - - LaunchedEffect(Unit) { - var immediate = false - scope.launch { - immediate = true - } - assertTrue { immediate } - } - } - } - @Test - fun scopeDoesDispatch() { - composeTestRule.setContent { - val scope = rememberForwardingUnconfinedCoroutineScope() - (scope.coroutineContext.dispatcher as Unconfined).unconfined = false - - LaunchedEffect(Unit) { - var immediate = false - scope.launch { - immediate = true - } - assertFalse { immediate } - } - } - } -} From 10169b34d570d68a124efb8997ab64d4c866df5a Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 19:04:26 -0500 Subject: [PATCH 10/34] Fix tests. --- .../internal/ForwardingUnconfinedCoroutineDispatcherTest.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt index 82fcd465cd..5319d5f950 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -9,12 +9,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable import kotlinx.coroutines.delay -import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext class ForwardingUnconfinedCoroutineDispatcherTest { - private val scheduler = TestCoroutineScheduler() private val testDispatcher = TestCoroutineDispatcher() private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) @@ -40,7 +38,7 @@ class ForwardingUnconfinedCoroutineDispatcherTest { private fun runTestWithForwardingDispatcher( testBody: suspend CoroutineScope.() -> Unit, - ) = runTest(forwardingDispatcher, testBody = testBody) + ) = runTest { withContext(forwardingDispatcher, testBody) } private class TestCoroutineDispatcher : CoroutineDispatcher() { var dispatchCount = 0 From 080198a66675afae1d8e6891451a002984f617f7 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 19:07:52 -0500 Subject: [PATCH 11/34] Docs. --- coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt b/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt index 2cd7e65589..c4c706b226 100644 --- a/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt +++ b/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt @@ -5,10 +5,10 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers /** - * A [CoroutineDispatcher] feature that delegates to [Dispatchers.Unconfined] while [unconfined] is true. + * A [CoroutineDispatcher] feature that delegates to [Dispatchers.Unconfined] while + * [unconfined] is true. */ @InternalCoilApi interface Unconfined { - /** Delegates to [Dispatchers.Unconfined] while true. */ var unconfined: Boolean } From 3ff04e54e29167a6796263aa325b0d7753b66738 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 19:59:12 -0500 Subject: [PATCH 12/34] Improve test coverage. --- ...ardingUnconfinedCoroutineDispatcherTest.kt | 95 +++++++++++++++++++ .../coil3/intercept/EngineInterceptor.kt | 2 +- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt index 5319d5f950..f89c5eb44d 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -1,8 +1,25 @@ package coil3.compose.internal +import coil3.ImageLoader +import coil3.compose.AsyncImagePainter +import coil3.decode.DataSource +import coil3.decode.DecodeResult +import coil3.decode.Decoder +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.ImageRequest +import coil3.request.Options +import coil3.request.SuccessResult +import coil3.test.utils.FakeImage +import coil3.test.utils.context +import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -11,6 +28,7 @@ import kotlinx.coroutines.Runnable import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext +import okio.Buffer class ForwardingUnconfinedCoroutineDispatcherTest { private val testDispatcher = TestCoroutineDispatcher() @@ -36,6 +54,42 @@ class ForwardingUnconfinedCoroutineDispatcherTest { assertEquals(1, testDispatcher.dispatchCount) } + /** This test emulates the context that [AsyncImagePainter] launches its request into. */ + @Test + fun `imageLoader does not dispatch if context does not change`() = runTestWithForwardingDispatcher { + assertIs(coroutineContext.dispatcher) + + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(Unit) + .fetcherFactory(TestFetcher.Factory()) + .decoderFactory(TestDecoder.Factory()) + .coroutineContext(EmptyCoroutineContext) + .build() + val result = imageLoader.execute(request) + + assertIs(result) + assertEquals(0, testDispatcher.dispatchCount) + } + + /** This test emulates the context that [AsyncImagePainter] launches its request into. */ + @Test + fun `imageLoader does dispatch if context changes`() = runTestWithForwardingDispatcher { + assertIs(coroutineContext.dispatcher) + + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(Unit) + .fetcherFactory(TestFetcher.Factory()) + .decoderFactory(TestDecoder.Factory()) + .decoderCoroutineContext(Dispatchers.Default) + .build() + val result = imageLoader.execute(request) + + assertIs(result) + assertEquals(1, testDispatcher.dispatchCount) + } + private fun runTestWithForwardingDispatcher( testBody: suspend CoroutineScope.() -> Unit, ) = runTest { withContext(forwardingDispatcher, testBody) } @@ -49,4 +103,45 @@ class ForwardingUnconfinedCoroutineDispatcherTest { block.run() } } + + private class TestFetcher( + private val options: Options, + ) : Fetcher { + + override suspend fun fetch(): FetchResult { + return SourceFetchResult( + source = ImageSource(Buffer(), options.fileSystem), + mimeType = null, + dataSource = DataSource.MEMORY, + ) + } + + class Factory : Fetcher.Factory { + override fun create( + data: Unit, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = TestFetcher(options) + } + } + + private class TestDecoder( + private val options: Options, + ) : Decoder { + + override suspend fun decode(): DecodeResult { + return DecodeResult( + image = FakeImage(), + isSampled = false, + ) + } + + class Factory : Decoder.Factory { + override fun create( + result: SourceFetchResult, + options: Options, + imageLoader: ImageLoader, + ): Decoder = TestDecoder(options) + } + } } diff --git a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt index 85f21045dc..890c86ebeb 100644 --- a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt +++ b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt @@ -74,7 +74,7 @@ internal class EngineInterceptor( } // Re-enable dispatching before starting to fetch. - (coroutineContext.dispatcher as? Unconfined)?.unconfined = true + (coroutineContext.dispatcher as? Unconfined)?.unconfined = false // Slow path: fetch, decode, transform, and cache the image. return withContext(request.fetcherCoroutineContext) { From 94661bb33801ac3fa67fdcc7a988a4bd793549e8 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 20:16:32 -0500 Subject: [PATCH 13/34] Fix test. --- .../internal/ForwardingUnconfinedCoroutineDispatcherTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt index f89c5eb44d..2f29255f54 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -13,6 +13,7 @@ import coil3.request.ImageRequest import coil3.request.Options import coil3.request.SuccessResult import coil3.test.utils.FakeImage +import coil3.test.utils.RobolectricTest import coil3.test.utils.context import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext @@ -30,7 +31,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import okio.Buffer -class ForwardingUnconfinedCoroutineDispatcherTest { +class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { private val testDispatcher = TestCoroutineDispatcher() private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) From 6d4a8e313cd35b0f211959320f5f623dde8e7662 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 20:23:49 -0500 Subject: [PATCH 14/34] Remove note. --- coil-compose/README.md | 3 --- docs/upgrading_to_coil3.md | 3 --- 2 files changed, 6 deletions(-) diff --git a/coil-compose/README.md b/coil-compose/README.md index 2ac6864127..40308a71aa 100644 --- a/coil-compose/README.md +++ b/coil-compose/README.md @@ -17,9 +17,6 @@ AsyncImage( `model` can either be the `ImageRequest.data` value - or the `ImageRequest` itself. `contentDescription` sets the text used by accessibility services to describe what this image represents. -!!! Note - If you use Compose on JVM/desktop you should import `org.jetbrains.kotlinx:kotlinx-coroutines-swing:`. Coil relies on `Dispatchers.Main.immediate` to resolve images from the memory cache synchronously and `kotlinx-coroutines-swing` provides support for that on JVM (non-Android) platforms. - ## AsyncImage `AsyncImage` is a composable that executes an image request asynchronously and renders the result. It supports the same arguments as the standard `Image` composable and additionally, it supports setting `placeholder`/`error`/`fallback` painters and `onLoading`/`onSuccess`/`onError` callbacks. Here's an example that loads an image with a circle crop, crossfade, and sets a placeholder: diff --git a/docs/upgrading_to_coil3.md b/docs/upgrading_to_coil3.md index 85f50b8394..31709c58f5 100644 --- a/docs/upgrading_to_coil3.md +++ b/docs/upgrading_to_coil3.md @@ -47,9 +47,6 @@ The `coil-compose` artifact's APIs are mostly unchanged. You can continue using - `AsyncImagePainter`'s default `SizeResolver` no longer waits for the first `onDraw` call to get the size of the canvas. Instead, `AsyncImagePainter` defaults to `Size.ORIGINAL`. - The Compose `modelEqualityDelegate` delegate is now set via a composition local, `LocalAsyncImageModelEqualityDelegate`, instead of as a parameter to `AsyncImage`/`SubcomposeAsyncImage`/`rememberAsyncImagePainter`. -!!! Note - If you use Coil on a JVM (non-Android) platform, you should add a dependency on a [coroutines main dispatcher](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html). On desktop you likely want to import `org.jetbrains.kotlinx:kotlinx-coroutines-swing`. If it's not imported then `ImageRequest`s won't be dispatched immediately and will have one frame of delay before setting the `ImageRequest.placeholder` or resolving from the memory cache. - ## General Other important behavior changes include: From 5378aba8863b68f12b34ed98736b910c2e0c9efe Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 20:40:26 -0500 Subject: [PATCH 15/34] Improve tests. --- ...rwardingUnconfinedCoroutineDispatcherTest.kt | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt index 2f29255f54..31b0a70a7d 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -22,6 +22,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Duration.Companion.milliseconds +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,23 +36,17 @@ class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { private val testDispatcher = TestCoroutineDispatcher() private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) - @Test - fun `does not dispatch when suspended by default`() = runTestWithForwardingDispatcher { - delay(100.milliseconds) - assertEquals(0, testDispatcher.dispatchCount) - } - @Test fun `does not dispatch when unconfined=true`() = runTestWithForwardingDispatcher { forwardingDispatcher.unconfined = true - withContext(Dispatchers.Default) {} + delay(100.milliseconds) assertEquals(0, testDispatcher.dispatchCount) } @Test fun `does dispatch when unconfined=false`() = runTestWithForwardingDispatcher { forwardingDispatcher.unconfined = false - withContext(Dispatchers.Default) {} + delay(100.milliseconds) assertEquals(1, testDispatcher.dispatchCount) } @@ -96,11 +91,11 @@ class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { ) = runTest { withContext(forwardingDispatcher, testBody) } private class TestCoroutineDispatcher : CoroutineDispatcher() { - var dispatchCount = 0 - private set + private var _dispatchCount by atomic(0) + val dispatchCount get() = _dispatchCount override fun dispatch(context: CoroutineContext, block: Runnable) { - dispatchCount++ + _dispatchCount++ block.run() } } From 7b2fd2b1ae2bee5f2dd6e8c1f72d863b2c230a29 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 23:07:32 -0500 Subject: [PATCH 16/34] Fix tests. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 37 +++++++++---------- ...ForwardingUnconfinedCoroutineDispatcher.kt | 14 +++++-- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 8bcd50d5c1..40d221bb38 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,7 +31,7 @@ import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState import coil3.compose.internal.onStateOf -import coil3.compose.internal.rememberForwardingUnconfinedCoroutineScope +import coil3.compose.internal.rememberUnconfinedCoroutineScope import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf @@ -51,8 +51,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. @@ -136,7 +136,7 @@ private fun rememberAsyncImagePainter( val input = Input(state.imageLoader, request, state.modelEqualityDelegate) val painter = remember { AsyncImagePainter(input) } - painter.scope = rememberForwardingUnconfinedCoroutineScope() + painter.scope = rememberUnconfinedCoroutineScope() painter.transform = transform painter.onState = onState painter.contentScale = contentScale @@ -213,23 +213,22 @@ class AsyncImagePainter internal constructor( (painter as? RememberObserver)?.onRemembered() // Observe the latest request and execute any emissions. - rememberJob = scope.launch { - restartSignal - .flatMapLatest { _input } - .mapLatest { input -> - val previewHandler = previewHandler - if (previewHandler != null) { - // If we're in inspection mode use the preview renderer. - val request = updateRequest(input.request, isPreview = true) - previewHandler.handle(input.imageLoader, request) - } else { - // Else, execute the request as normal. - val request = updateRequest(input.request, isPreview = false) - input.imageLoader.execute(request).toState() - } + rememberJob = restartSignal + .flatMapLatest { _input } + .mapLatest { input -> + val previewHandler = previewHandler + val state = if (previewHandler != null) { + // If we're in inspection mode use the preview renderer. + val request = updateRequest(input.request, isPreview = true) + previewHandler.handle(input.imageLoader, request) + } else { + // Else, execute the request as normal. + val request = updateRequest(input.request, isPreview = false) + input.imageLoader.execute(request).toState() } - .collect(::updateState) - } + updateState(state) + } + .launchIn(scope) } override fun onForgotten() { diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index b57846c847..bff48b9769 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -15,16 +15,22 @@ import kotlinx.coroutines.Runnable * Create a [CoroutineScope] that will contain a [ForwardingUnconfinedCoroutineDispatcher] if necessary. */ @Composable -internal fun rememberForwardingUnconfinedCoroutineScope(): CoroutineScope { +internal fun rememberUnconfinedCoroutineScope(): CoroutineScope { val scope = rememberCoroutineScope() return remember(scope) { val currentContext = scope.coroutineContext val currentDispatcher = currentContext.dispatcher - if (currentDispatcher != null && currentDispatcher != Dispatchers.Unconfined) { - CoroutineScope(currentContext + ForwardingUnconfinedCoroutineDispatcher(currentDispatcher)) + + if (currentDispatcher == Dispatchers.Unconfined) { + return@remember scope + } + + val newDispatcher = if (currentDispatcher != null) { + ForwardingUnconfinedCoroutineDispatcher(currentDispatcher) } else { - scope + Dispatchers.Unconfined } + return@remember CoroutineScope(currentContext + newDispatcher) } } From 2346e27d9ca88b8fe661e4104baaf949a3c6d406 Mon Sep 17 00:00:00 2001 From: Colin White Date: Thu, 19 Dec 2024 23:54:15 -0500 Subject: [PATCH 17/34] Fix mutating shared variable across contexts. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 33 +++++++++------- ...ForwardingUnconfinedCoroutineDispatcher.kt | 38 ++++++------------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 40d221bb38..3eaf311384 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ColorFilter @@ -30,11 +31,12 @@ import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState +import coil3.compose.internal.dispatcher import coil3.compose.internal.onStateOf -import coil3.compose.internal.rememberUnconfinedCoroutineScope import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf +import coil3.compose.internal.withForwardingUnconfinedDispatcher import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult @@ -43,6 +45,7 @@ import coil3.size.Precision import coil3.size.SizeResolver import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -53,6 +56,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.plus /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. @@ -136,7 +140,7 @@ private fun rememberAsyncImagePainter( val input = Input(state.imageLoader, request, state.modelEqualityDelegate) val painter = remember { AsyncImagePainter(input) } - painter.scope = rememberUnconfinedCoroutineScope() + painter.scope = rememberCoroutineScope() painter.transform = transform painter.onState = onState painter.contentScale = contentScale @@ -213,22 +217,25 @@ class AsyncImagePainter internal constructor( (painter as? RememberObserver)?.onRemembered() // Observe the latest request and execute any emissions. + val dispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined rememberJob = restartSignal .flatMapLatest { _input } .mapLatest { input -> - val previewHandler = previewHandler - val state = if (previewHandler != null) { - // If we're in inspection mode use the preview renderer. - val request = updateRequest(input.request, isPreview = true) - previewHandler.handle(input.imageLoader, request) - } else { - // Else, execute the request as normal. - val request = updateRequest(input.request, isPreview = false) - input.imageLoader.execute(request).toState() + withForwardingUnconfinedDispatcher(dispatcher) { + val previewHandler = previewHandler + val state = if (previewHandler != null) { + // If we're in inspection mode use the preview renderer. + val request = updateRequest(input.request, isPreview = true) + previewHandler.handle(input.imageLoader, request) + } else { + // Else, execute the request as normal. + val request = updateRequest(input.request, isPreview = false) + input.imageLoader.execute(request).toState() + } + updateState(state) } - updateState(state) } - .launchIn(scope) + .launchIn(scope + Dispatchers.Unconfined) } override fun onForgotten() { diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index bff48b9769..7ab5cd2aef 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -1,8 +1,5 @@ package coil3.compose.internal -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineDispatcher @@ -10,28 +7,16 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable - -/** - * Create a [CoroutineScope] that will contain a [ForwardingUnconfinedCoroutineDispatcher] if necessary. - */ -@Composable -internal fun rememberUnconfinedCoroutineScope(): CoroutineScope { - val scope = rememberCoroutineScope() - return remember(scope) { - val currentContext = scope.coroutineContext - val currentDispatcher = currentContext.dispatcher - - if (currentDispatcher == Dispatchers.Unconfined) { - return@remember scope - } - - val newDispatcher = if (currentDispatcher != null) { - ForwardingUnconfinedCoroutineDispatcher(currentDispatcher) - } else { - Dispatchers.Unconfined - } - return@remember CoroutineScope(currentContext + newDispatcher) - } +import kotlinx.coroutines.withContext + +internal suspend inline fun withForwardingUnconfinedDispatcher( + delegate: CoroutineDispatcher, + noinline block: suspend CoroutineScope.() -> T, +) { + val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(delegate) + forwardingDispatcher.unconfined = true + withContext(forwardingDispatcher, block) + forwardingDispatcher.unconfined = true } /** @@ -42,12 +27,13 @@ internal class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher(), Unconfined { - override var unconfined = true + override var unconfined = false private val currentDispatcher: CoroutineDispatcher get() = if (unconfined) Dispatchers.Unconfined else delegate override fun isDispatchNeeded(context: CoroutineContext): Boolean { + println("isDispatchNeeded $currentDispatcher") return currentDispatcher.isDispatchNeeded(context) } From 829d7f9fb29deeb02af837b1405de79e8a354fc8 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 00:18:51 -0500 Subject: [PATCH 18/34] Fix tests. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 42 +++++---- ...ForwardingUnconfinedCoroutineDispatcher.kt | 12 --- ...ardingUnconfinedCoroutineDispatcherTest.kt | 87 ++++++++++--------- 3 files changed, 73 insertions(+), 68 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 3eaf311384..e75426c285 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,12 +31,12 @@ import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState +import coil3.compose.internal.ForwardingUnconfinedCoroutineDispatcher import coil3.compose.internal.dispatcher import coil3.compose.internal.onStateOf import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf -import coil3.compose.internal.withForwardingUnconfinedDispatcher import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult @@ -53,10 +53,10 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. @@ -217,22 +217,30 @@ class AsyncImagePainter internal constructor( (painter as? RememberObserver)?.onRemembered() // Observe the latest request and execute any emissions. - val dispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined rememberJob = restartSignal - .flatMapLatest { _input } - .mapLatest { input -> - withForwardingUnconfinedDispatcher(dispatcher) { - val previewHandler = previewHandler - val state = if (previewHandler != null) { - // If we're in inspection mode use the preview renderer. - val request = updateRequest(input.request, isPreview = true) - previewHandler.handle(input.imageLoader, request) - } else { - // Else, execute the request as normal. - val request = updateRequest(input.request, isPreview = false) - input.imageLoader.execute(request).toState() + .transformLatest { + _input.collect { input -> + val delegate = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined + val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(delegate) + + try { + forwardingDispatcher.unconfined = true + withContext(ForwardingUnconfinedCoroutineDispatcher(delegate)) { + val previewHandler = previewHandler + val state = if (previewHandler != null) { + // If we're in inspection mode use the preview renderer. + val request = updateRequest(input.request, isPreview = true) + previewHandler.handle(input.imageLoader, request) + } else { + // Else, execute the request as normal. + val request = updateRequest(input.request, isPreview = false) + input.imageLoader.execute(request).toState() + } + updateState(state) + } + } finally { + forwardingDispatcher.unconfined = true } - updateState(state) } } .launchIn(scope + Dispatchers.Unconfined) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index 7ab5cd2aef..e42b856d6f 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -3,21 +3,9 @@ package coil3.compose.internal import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable -import kotlinx.coroutines.withContext - -internal suspend inline fun withForwardingUnconfinedDispatcher( - delegate: CoroutineDispatcher, - noinline block: suspend CoroutineScope.() -> T, -) { - val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(delegate) - forwardingDispatcher.unconfined = true - withContext(forwardingDispatcher, block) - forwardingDispatcher.unconfined = true -} /** * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt index 31b0a70a7d..dd5a9b2d77 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt @@ -24,7 +24,6 @@ import kotlin.test.assertIs import kotlin.time.Duration.Companion.milliseconds import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable import kotlinx.coroutines.delay @@ -37,58 +36,68 @@ class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) @Test - fun `does not dispatch when unconfined=true`() = runTestWithForwardingDispatcher { + fun `does not dispatch when unconfined=true`() = runTest { forwardingDispatcher.unconfined = true - delay(100.milliseconds) - assertEquals(0, testDispatcher.dispatchCount) + + withContext(forwardingDispatcher) { + delay(100.milliseconds) + assertEquals(0, testDispatcher.dispatchCount) + } } @Test - fun `does dispatch when unconfined=false`() = runTestWithForwardingDispatcher { + fun `does dispatch when unconfined=false`() = runTest { forwardingDispatcher.unconfined = false - delay(100.milliseconds) - assertEquals(1, testDispatcher.dispatchCount) + + withContext(forwardingDispatcher) { + delay(100.milliseconds) + assertEquals(2, testDispatcher.dispatchCount) + } } /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @Test - fun `imageLoader does not dispatch if context does not change`() = runTestWithForwardingDispatcher { - assertIs(coroutineContext.dispatcher) - - val imageLoader = ImageLoader(context) - val request = ImageRequest.Builder(context) - .data(Unit) - .fetcherFactory(TestFetcher.Factory()) - .decoderFactory(TestDecoder.Factory()) - .coroutineContext(EmptyCoroutineContext) - .build() - val result = imageLoader.execute(request) - - assertIs(result) - assertEquals(0, testDispatcher.dispatchCount) + fun `imageLoader does not dispatch if context does not change`() = runTest { + forwardingDispatcher.unconfined = true + + withContext(forwardingDispatcher) { + assertIs(coroutineContext.dispatcher) + + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(Unit) + .fetcherFactory(TestFetcher.Factory()) + .decoderFactory(TestDecoder.Factory()) + .coroutineContext(EmptyCoroutineContext) + .build() + val result = imageLoader.execute(request) + + assertIs(result) + assertEquals(0, testDispatcher.dispatchCount) + } } /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @Test - fun `imageLoader does dispatch if context changes`() = runTestWithForwardingDispatcher { - assertIs(coroutineContext.dispatcher) - - val imageLoader = ImageLoader(context) - val request = ImageRequest.Builder(context) - .data(Unit) - .fetcherFactory(TestFetcher.Factory()) - .decoderFactory(TestDecoder.Factory()) - .decoderCoroutineContext(Dispatchers.Default) - .build() - val result = imageLoader.execute(request) - - assertIs(result) - assertEquals(1, testDispatcher.dispatchCount) - } + fun `imageLoader does dispatch if context changes`() = runTest { + forwardingDispatcher.unconfined = true + + withContext(forwardingDispatcher) { + assertIs(coroutineContext.dispatcher) - private fun runTestWithForwardingDispatcher( - testBody: suspend CoroutineScope.() -> Unit, - ) = runTest { withContext(forwardingDispatcher, testBody) } + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(Unit) + .fetcherFactory(TestFetcher.Factory()) + .decoderFactory(TestDecoder.Factory()) + .decoderCoroutineContext(Dispatchers.Default) + .build() + val result = imageLoader.execute(request) + + assertIs(result) + assertEquals(1, testDispatcher.dispatchCount) + } + } private class TestCoroutineDispatcher : CoroutineDispatcher() { private var _dispatchCount by atomic(0) From dbded59201f13db255367b5e340a993ba831baf7 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 00:24:34 -0500 Subject: [PATCH 19/34] Fix tests. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 10 +++++----- .../ForwardingUnconfinedCoroutineDispatcher.kt | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index e75426c285..873caa3fd5 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -223,9 +223,8 @@ class AsyncImagePainter internal constructor( val delegate = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(delegate) - try { - forwardingDispatcher.unconfined = true - withContext(ForwardingUnconfinedCoroutineDispatcher(delegate)) { + withContext(forwardingDispatcher) { + try { val previewHandler = previewHandler val state = if (previewHandler != null) { // If we're in inspection mode use the preview renderer. @@ -237,9 +236,10 @@ class AsyncImagePainter internal constructor( input.imageLoader.execute(request).toState() } updateState(state) + } finally { + // Optimization to avoid dispatching when there's nothing left to do. + forwardingDispatcher.unconfined = true } - } finally { - forwardingDispatcher.unconfined = true } } } diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index e42b856d6f..367eca69f7 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -15,13 +15,12 @@ internal class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher(), Unconfined { - override var unconfined = false + override var unconfined = true private val currentDispatcher: CoroutineDispatcher get() = if (unconfined) Dispatchers.Unconfined else delegate override fun isDispatchNeeded(context: CoroutineContext): Boolean { - println("isDispatchNeeded $currentDispatcher") return currentDispatcher.isDispatchNeeded(context) } From 394b22d24db58599924ca8e6e00829f7339f7cf4 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 00:41:06 -0500 Subject: [PATCH 20/34] Tweak solution. --- .../commonMain/kotlin/coil3/RealImageLoader.kt | 3 ++- .../kotlin/coil3/intercept/EngineInterceptor.kt | 11 +++-------- .../src/commonMain/kotlin/coil3/util/utils.kt | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt b/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt index 8be04286b2..ab8d05b82f 100644 --- a/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt +++ b/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt @@ -28,6 +28,7 @@ import coil3.util.SystemCallbacks import coil3.util.emoji import coil3.util.log import coil3.util.mapNotNullIndices +import coil3.util.withContextAndDisableUnconfined import kotlin.coroutines.coroutineContext import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException @@ -130,7 +131,7 @@ internal class RealImageLoader( eventListener.resolveSizeEnd(request, size) // Execute the interceptor chain. - val result = withContext(request.interceptorCoroutineContext) { + val result = withContextAndDisableUnconfined(request.interceptorCoroutineContext) { RealInterceptorChain( initialRequest = request, interceptors = components.interceptors, diff --git a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt index 890c86ebeb..8126a0d304 100644 --- a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt +++ b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt @@ -27,19 +27,17 @@ import coil3.transform.Transformation import coil3.util.ErrorResult import coil3.util.Logger import coil3.util.SystemCallbacks -import coil3.util.Unconfined import coil3.util.addFirst import coil3.util.closeQuietly -import coil3.util.dispatcher import coil3.util.eventListener import coil3.util.foldIndices import coil3.util.isPlaceholderCached import coil3.util.log import coil3.util.prepareToDraw +import coil3.util.withContextAndDisableUnconfined import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.withContext /** The last interceptor in the chain which executes the [ImageRequest]. */ internal class EngineInterceptor( @@ -73,11 +71,8 @@ internal class EngineInterceptor( return memoryCacheService.newResult(chain, request, cacheKey, cacheValue) } - // Re-enable dispatching before starting to fetch. - (coroutineContext.dispatcher as? Unconfined)?.unconfined = false - // Slow path: fetch, decode, transform, and cache the image. - return withContext(request.fetcherCoroutineContext) { + return withContextAndDisableUnconfined(request.fetcherCoroutineContext) { // Fetch and decode the image. val result = execute(request, mappedData, options, eventListener) @@ -132,7 +127,7 @@ internal class EngineInterceptor( // Decode the data. when (fetchResult) { - is SourceFetchResult -> withContext(request.decoderCoroutineContext) { + is SourceFetchResult -> withContextAndDisableUnconfined(request.decoderCoroutineContext) { decode(fetchResult, components, request, mappedData, options, eventListener) } is ImageFetchResult -> { diff --git a/coil-core/src/commonMain/kotlin/coil3/util/utils.kt b/coil-core/src/commonMain/kotlin/coil3/util/utils.kt index 8dd4a6f783..31b3f1d084 100644 --- a/coil-core/src/commonMain/kotlin/coil3/util/utils.kt +++ b/coil-core/src/commonMain/kotlin/coil3/util/utils.kt @@ -13,9 +13,13 @@ import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.NullRequestDataException import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext import kotlin.experimental.ExperimentalNativeApi import kotlin.reflect.KClass import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okio.Closeable internal expect fun println(level: Logger.Level, tag: String, message: String) @@ -103,3 +107,15 @@ internal expect fun Image.prepareToDraw() @OptIn(ExperimentalStdlibApi::class) internal val CoroutineContext.dispatcher: CoroutineDispatcher? get() = get(CoroutineDispatcher) + +internal suspend fun withContextAndDisableUnconfined( + context: CoroutineContext, + block: suspend CoroutineScope.() -> T +): T { + val dispatcher = coroutineContext.dispatcher + if (dispatcher is Unconfined) { + dispatcher.unconfined = dispatcher.unconfined && context.dispatcher + .let { it == null || it == Dispatchers.Unconfined } + } + return withContext(context, block) +} From 553e5272c8d193fb81e52d91bb6209c08bcb0da0 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 00:50:24 -0500 Subject: [PATCH 21/34] Fix style. --- coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt b/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt index ab8d05b82f..d2c43daf11 100644 --- a/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt +++ b/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.job -import kotlinx.coroutines.withContext internal class RealImageLoader( val options: Options, From 8356177535956c215dfad6c32d563901b6e1c720 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 02:12:19 -0500 Subject: [PATCH 22/34] Guard against race conditions. --- .../internal/ForwardingUnconfinedCoroutineDispatcher.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index 367eca69f7..295ab02c73 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -2,6 +2,7 @@ package coil3.compose.internal import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi @@ -15,10 +16,11 @@ internal class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher(), Unconfined { - override var unconfined = true + private var _unconfined = atomic(true) + override var unconfined by _unconfined private val currentDispatcher: CoroutineDispatcher - get() = if (unconfined) Dispatchers.Unconfined else delegate + get() = if (_unconfined.value) Dispatchers.Unconfined else delegate override fun isDispatchNeeded(context: CoroutineContext): Boolean { return currentDispatcher.isDispatchNeeded(context) From f193279f81d9d082efb5349f41958a8601898352 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 02:19:38 -0500 Subject: [PATCH 23/34] Fix build. --- .../internal/ForwardingUnconfinedCoroutineDispatcher.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt index 295ab02c73..98b07fe7aa 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt @@ -15,8 +15,7 @@ import kotlinx.coroutines.Runnable internal class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher(), Unconfined { - - private var _unconfined = atomic(true) + private val _unconfined = atomic(true) override var unconfined by _unconfined private val currentDispatcher: CoroutineDispatcher From 2b11ee5d072824b8b2237c76be69c293e099bc42 Mon Sep 17 00:00:00 2001 From: Colin White Date: Fri, 20 Dec 2024 23:51:04 -0500 Subject: [PATCH 24/34] Improve solution. --- .../api/coil-compose-core.klib.api | 2 + .../kotlin/coil3/compose/AsyncImagePainter.kt | 39 ++++++++----------- .../internal/ForwardingCoroutineContext.kt | 33 ++++++++++++++++ ... => ForwardingUnconfinedCoroutineScope.kt} | 35 +++++++++++++++-- ...ForwardingUnconfinedCoroutineScopeTest.kt} | 14 +++---- coil-core/api/coil-core.klib.api | 6 --- .../kotlin/coil3/RealImageLoader.kt | 4 +- .../coil3/intercept/EngineInterceptor.kt | 6 +-- .../kotlin/coil3/util/Unconfined.kt | 14 ------- .../src/commonMain/kotlin/coil3/util/utils.kt | 22 ----------- 10 files changed, 93 insertions(+), 82 deletions(-) create mode 100644 coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt rename coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/{ForwardingUnconfinedCoroutineDispatcher.kt => ForwardingUnconfinedCoroutineScope.kt} (54%) rename coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/{ForwardingUnconfinedCoroutineDispatcherTest.kt => ForwardingUnconfinedCoroutineScopeTest.kt} (93%) delete mode 100644 coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt diff --git a/coil-compose-core/api/coil-compose-core.klib.api b/coil-compose-core/api/coil-compose-core.klib.api index 027b6e7146..ad234f1681 100644 --- a/coil-compose-core/api/coil-compose-core.klib.api +++ b/coil-compose-core/api/coil-compose-core.klib.api @@ -183,6 +183,7 @@ final class coil3.compose/ImagePainter : androidx.compose.ui.graphics.painter/Pa final val coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop|#static{}coil3_compose_internal_AsyncImageState$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop|#static{}coil3_compose_internal_ContentPainterElement$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop|#static{}coil3_compose_internal_ContentPainterNode$stableprop[0] +final val coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop // coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop|#static{}coil3_compose_internal_ForwardingCoroutineContext$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop // coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop|#static{}coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop[0] final val coil3.compose/LocalAsyncImageModelEqualityDelegate // coil3.compose/LocalAsyncImageModelEqualityDelegate|{}LocalAsyncImageModelEqualityDelegate[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // coil3.compose/LocalAsyncImageModelEqualityDelegate.|(){}[0] @@ -205,6 +206,7 @@ final fun (coil3/Image).coil3.compose/asPainter(coil3/PlatformContext, androidx. final fun coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter|coil3_compose_internal_AsyncImageState$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter|coil3_compose_internal_ContentPainterElement$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter|coil3_compose_internal_ContentPainterNode$stableprop_getter(){}[0] +final fun coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter|coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter|coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter(){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 873caa3fd5..16f974d0cf 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,12 +31,13 @@ import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState -import coil3.compose.internal.ForwardingUnconfinedCoroutineDispatcher +import coil3.compose.internal.ForwardingUnconfinedCoroutineScope import coil3.compose.internal.dispatcher import coil3.compose.internal.onStateOf import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf +import coil3.compose.internal.withForwardingUnconfinedCoroutineDispatcher import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult @@ -55,8 +56,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.plus -import kotlinx.coroutines.withContext /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. @@ -216,34 +215,28 @@ class AsyncImagePainter internal constructor( override fun onRemembered() = trace("AsyncImagePainter.onRemembered") { (painter as? RememberObserver)?.onRemembered() + val originalDispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined + // Observe the latest request and execute any emissions. rememberJob = restartSignal .transformLatest { _input.collect { input -> - val delegate = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined - val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(delegate) - - withContext(forwardingDispatcher) { - try { - val previewHandler = previewHandler - val state = if (previewHandler != null) { - // If we're in inspection mode use the preview renderer. - val request = updateRequest(input.request, isPreview = true) - previewHandler.handle(input.imageLoader, request) - } else { - // Else, execute the request as normal. - val request = updateRequest(input.request, isPreview = false) - input.imageLoader.execute(request).toState() - } - updateState(state) - } finally { - // Optimization to avoid dispatching when there's nothing left to do. - forwardingDispatcher.unconfined = true + withForwardingUnconfinedCoroutineDispatcher(originalDispatcher) { + val previewHandler = previewHandler + val state = if (previewHandler != null) { + // If we're in inspection mode use the preview renderer. + val request = updateRequest(input.request, isPreview = true) + previewHandler.handle(input.imageLoader, request) + } else { + // Else, execute the request as normal. + val request = updateRequest(input.request, isPreview = false) + input.imageLoader.execute(request).toState() } + updateState(state) } } } - .launchIn(scope + Dispatchers.Unconfined) + .launchIn(ForwardingUnconfinedCoroutineScope(scope.coroutineContext + Dispatchers.Unconfined)) } override fun onForgotten() { diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt new file mode 100644 index 0000000000..561533b30c --- /dev/null +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt @@ -0,0 +1,33 @@ +package coil3.compose.internal + +import kotlin.coroutines.CoroutineContext + +internal class ForwardingCoroutineContext( + private val delegate: CoroutineContext, + private val onNewContext: (old: CoroutineContext, new: CoroutineContext) -> Unit, +) : CoroutineContext by delegate { + + override fun minusKey(key: CoroutineContext.Key<*>): CoroutineContext { + val new = delegate.minusKey(key) + onNewContext(this, new) + return ForwardingCoroutineContext(new, onNewContext) + } + + override operator fun plus(context: CoroutineContext): CoroutineContext { + val new = delegate + context + onNewContext(this, new) + return ForwardingCoroutineContext(new, onNewContext) + } + + override fun equals(other: Any?): Boolean { + return delegate == other + } + + override fun hashCode(): Int { + return delegate.hashCode() + } + + override fun toString(): String { + return delegate.toString() + } +} diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt similarity index 54% rename from coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt rename to coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt index 98b07fe7aa..7c0e7609ee 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcher.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt @@ -1,12 +1,26 @@ package coil3.compose.internal -import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable +import kotlinx.coroutines.withContext + +internal fun ForwardingUnconfinedCoroutineScope( + context: CoroutineContext, +) = CoroutineScope( + context = ForwardingCoroutineContext(context) { old, new -> + val oldDispatcher = old.dispatcher + val newDispatcher = new.dispatcher + if (oldDispatcher is ForwardingUnconfinedCoroutineDispatcher && oldDispatcher != newDispatcher) { + oldDispatcher.unconfined = oldDispatcher.unconfined && + (newDispatcher == null || newDispatcher == Dispatchers.Unconfined) + } + } +) /** * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true @@ -14,9 +28,9 @@ import kotlinx.coroutines.Runnable */ internal class ForwardingUnconfinedCoroutineDispatcher( private val delegate: CoroutineDispatcher, -) : CoroutineDispatcher(), Unconfined { +) : CoroutineDispatcher() { private val _unconfined = atomic(true) - override var unconfined by _unconfined + var unconfined by _unconfined private val currentDispatcher: CoroutineDispatcher get() = if (_unconfined.value) Dispatchers.Unconfined else delegate @@ -42,3 +56,18 @@ internal class ForwardingUnconfinedCoroutineDispatcher( return "ForwardingUnconfinedCoroutineDispatcher(delegate=$delegate)" } } + +internal suspend inline fun withForwardingUnconfinedCoroutineDispatcher( + originalDispatcher: CoroutineDispatcher, + crossinline block: suspend CoroutineScope.() -> T +): T { + val unconfinedDispatcher = ForwardingUnconfinedCoroutineDispatcher(originalDispatcher) + return withContext(unconfinedDispatcher) { + try { + block() + } finally { + // Optimization to avoid dispatching when there's nothing left to do. + unconfinedDispatcher.unconfined = true + } + } +} diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt similarity index 93% rename from coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt rename to coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt index dd5a9b2d77..9b5db68a86 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineDispatcherTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt @@ -15,7 +15,6 @@ import coil3.request.SuccessResult import coil3.test.utils.FakeImage import coil3.test.utils.RobolectricTest import coil3.test.utils.context -import coil3.util.Unconfined import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test @@ -27,11 +26,12 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import okio.Buffer -class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { +class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { private val testDispatcher = TestCoroutineDispatcher() private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) @@ -60,9 +60,7 @@ class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { fun `imageLoader does not dispatch if context does not change`() = runTest { forwardingDispatcher.unconfined = true - withContext(forwardingDispatcher) { - assertIs(coroutineContext.dispatcher) - + ForwardingUnconfinedCoroutineScope(forwardingDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) @@ -74,7 +72,7 @@ class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { assertIs(result) assertEquals(0, testDispatcher.dispatchCount) - } + }.join() } /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @@ -82,9 +80,7 @@ class ForwardingUnconfinedCoroutineDispatcherTest : RobolectricTest() { fun `imageLoader does dispatch if context changes`() = runTest { forwardingDispatcher.unconfined = true - withContext(forwardingDispatcher) { - assertIs(coroutineContext.dispatcher) - + ForwardingUnconfinedCoroutineScope(forwardingDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) diff --git a/coil-core/api/coil-core.klib.api b/coil-core/api/coil-core.klib.api index 79e86ceba9..317d9ca436 100644 --- a/coil-core/api/coil-core.klib.api +++ b/coil-core/api/coil-core.klib.api @@ -276,12 +276,6 @@ abstract interface coil3.util/Logger { // coil3.util/Logger|null[0] } } -abstract interface coil3.util/Unconfined { // coil3.util/Unconfined|null[0] - abstract var unconfined // coil3.util/Unconfined.unconfined|{}unconfined[0] - abstract fun (): kotlin/Boolean // coil3.util/Unconfined.unconfined.|(){}[0] - abstract fun (kotlin/Boolean) // coil3.util/Unconfined.unconfined.|(kotlin.Boolean){}[0] -} - abstract interface coil3/Image { // coil3/Image|null[0] abstract val height // coil3/Image.height|{}height[0] abstract fun (): kotlin/Int // coil3/Image.height.|(){}[0] diff --git a/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt b/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt index d2c43daf11..8be04286b2 100644 --- a/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt +++ b/coil-core/src/commonMain/kotlin/coil3/RealImageLoader.kt @@ -28,7 +28,6 @@ import coil3.util.SystemCallbacks import coil3.util.emoji import coil3.util.log import coil3.util.mapNotNullIndices -import coil3.util.withContextAndDisableUnconfined import kotlin.coroutines.coroutineContext import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException @@ -41,6 +40,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.job +import kotlinx.coroutines.withContext internal class RealImageLoader( val options: Options, @@ -130,7 +130,7 @@ internal class RealImageLoader( eventListener.resolveSizeEnd(request, size) // Execute the interceptor chain. - val result = withContextAndDisableUnconfined(request.interceptorCoroutineContext) { + val result = withContext(request.interceptorCoroutineContext) { RealInterceptorChain( initialRequest = request, interceptors = components.interceptors, diff --git a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt index 8126a0d304..60c82b6f1d 100644 --- a/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt +++ b/coil-core/src/commonMain/kotlin/coil3/intercept/EngineInterceptor.kt @@ -34,10 +34,10 @@ import coil3.util.foldIndices import coil3.util.isPlaceholderCached import coil3.util.log import coil3.util.prepareToDraw -import coil3.util.withContextAndDisableUnconfined import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext /** The last interceptor in the chain which executes the [ImageRequest]. */ internal class EngineInterceptor( @@ -72,7 +72,7 @@ internal class EngineInterceptor( } // Slow path: fetch, decode, transform, and cache the image. - return withContextAndDisableUnconfined(request.fetcherCoroutineContext) { + return withContext(request.fetcherCoroutineContext) { // Fetch and decode the image. val result = execute(request, mappedData, options, eventListener) @@ -127,7 +127,7 @@ internal class EngineInterceptor( // Decode the data. when (fetchResult) { - is SourceFetchResult -> withContextAndDisableUnconfined(request.decoderCoroutineContext) { + is SourceFetchResult -> withContext(request.decoderCoroutineContext) { decode(fetchResult, components, request, mappedData, options, eventListener) } is ImageFetchResult -> { diff --git a/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt b/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt deleted file mode 100644 index c4c706b226..0000000000 --- a/coil-core/src/commonMain/kotlin/coil3/util/Unconfined.kt +++ /dev/null @@ -1,14 +0,0 @@ -package coil3.util - -import coil3.annotation.InternalCoilApi -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers - -/** - * A [CoroutineDispatcher] feature that delegates to [Dispatchers.Unconfined] while - * [unconfined] is true. - */ -@InternalCoilApi -interface Unconfined { - var unconfined: Boolean -} diff --git a/coil-core/src/commonMain/kotlin/coil3/util/utils.kt b/coil-core/src/commonMain/kotlin/coil3/util/utils.kt index 31b3f1d084..3f716aaa83 100644 --- a/coil-core/src/commonMain/kotlin/coil3/util/utils.kt +++ b/coil-core/src/commonMain/kotlin/coil3/util/utils.kt @@ -12,14 +12,8 @@ import coil3.intercept.RealInterceptorChain import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.NullRequestDataException -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.coroutineContext import kotlin.experimental.ExperimentalNativeApi import kotlin.reflect.KClass -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import okio.Closeable internal expect fun println(level: Logger.Level, tag: String, message: String) @@ -103,19 +97,3 @@ internal expect class WeakReference(referred: T) { } internal expect fun Image.prepareToDraw() - -@OptIn(ExperimentalStdlibApi::class) -internal val CoroutineContext.dispatcher: CoroutineDispatcher? - get() = get(CoroutineDispatcher) - -internal suspend fun withContextAndDisableUnconfined( - context: CoroutineContext, - block: suspend CoroutineScope.() -> T -): T { - val dispatcher = coroutineContext.dispatcher - if (dispatcher is Unconfined) { - dispatcher.unconfined = dispatcher.unconfined && context.dispatcher - .let { it == null || it == Dispatchers.Unconfined } - } - return withContext(context, block) -} From 9d3381cc2cd362713c6b51fccd501d229f401d79 Mon Sep 17 00:00:00 2001 From: Colin White Date: Sat, 21 Dec 2024 00:08:45 -0500 Subject: [PATCH 25/34] Docs. --- .../ForwardingUnconfinedCoroutineScope.kt | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt index 7c0e7609ee..bcde3c9bd7 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt @@ -9,6 +9,10 @@ import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable import kotlinx.coroutines.withContext +/** + * A [CoroutineScope] with a special [CoroutineContext] that enables [ForwardingUnconfinedCoroutineDispatcher] + * to enable dispatching after it is replaced in the context. + */ internal fun ForwardingUnconfinedCoroutineScope( context: CoroutineContext, ) = CoroutineScope( @@ -22,6 +26,24 @@ internal fun ForwardingUnconfinedCoroutineScope( } ) +/** + * Calls [block] with a [ForwardingUnconfinedCoroutineDispatcher] replacing the current dispatcher. + */ +internal suspend inline fun withForwardingUnconfinedCoroutineDispatcher( + originalDispatcher: CoroutineDispatcher, + crossinline block: suspend CoroutineScope.() -> T +): T { + val unconfinedDispatcher = ForwardingUnconfinedCoroutineDispatcher(originalDispatcher) + return withContext(unconfinedDispatcher) { + try { + block() + } finally { + // Optimization to avoid dispatching when there's nothing left to do. + unconfinedDispatcher.unconfined = true + } + } +} + /** * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true * and [delegate] when [unconfined] is false. @@ -56,18 +78,3 @@ internal class ForwardingUnconfinedCoroutineDispatcher( return "ForwardingUnconfinedCoroutineDispatcher(delegate=$delegate)" } } - -internal suspend inline fun withForwardingUnconfinedCoroutineDispatcher( - originalDispatcher: CoroutineDispatcher, - crossinline block: suspend CoroutineScope.() -> T -): T { - val unconfinedDispatcher = ForwardingUnconfinedCoroutineDispatcher(originalDispatcher) - return withContext(unconfinedDispatcher) { - try { - block() - } finally { - // Optimization to avoid dispatching when there's nothing left to do. - unconfinedDispatcher.unconfined = true - } - } -} From 0e7aa843ebe8a3801c79b5013840c3a8d6cf4888 Mon Sep 17 00:00:00 2001 From: Colin White Date: Sat, 21 Dec 2024 16:36:50 -0500 Subject: [PATCH 26/34] Remove unnecessary optimization. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 8 ++++---- .../ForwardingUnconfinedCoroutineScope.kt | 19 ------------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 16f974d0cf..072777142c 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,13 +31,13 @@ import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState +import coil3.compose.internal.ForwardingUnconfinedCoroutineDispatcher import coil3.compose.internal.ForwardingUnconfinedCoroutineScope import coil3.compose.internal.dispatcher import coil3.compose.internal.onStateOf import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf -import coil3.compose.internal.withForwardingUnconfinedCoroutineDispatcher import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult @@ -56,6 +56,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.withContext /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. @@ -215,13 +216,12 @@ class AsyncImagePainter internal constructor( override fun onRemembered() = trace("AsyncImagePainter.onRemembered") { (painter as? RememberObserver)?.onRemembered() - val originalDispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined - // Observe the latest request and execute any emissions. + val originalDispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined rememberJob = restartSignal .transformLatest { _input.collect { input -> - withForwardingUnconfinedCoroutineDispatcher(originalDispatcher) { + withContext(ForwardingUnconfinedCoroutineDispatcher(originalDispatcher)) { val previewHandler = previewHandler val state = if (previewHandler != null) { // If we're in inspection mode use the preview renderer. diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt index bcde3c9bd7..ad53806d40 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.Runnable -import kotlinx.coroutines.withContext /** * A [CoroutineScope] with a special [CoroutineContext] that enables [ForwardingUnconfinedCoroutineDispatcher] @@ -26,24 +25,6 @@ internal fun ForwardingUnconfinedCoroutineScope( } ) -/** - * Calls [block] with a [ForwardingUnconfinedCoroutineDispatcher] replacing the current dispatcher. - */ -internal suspend inline fun withForwardingUnconfinedCoroutineDispatcher( - originalDispatcher: CoroutineDispatcher, - crossinline block: suspend CoroutineScope.() -> T -): T { - val unconfinedDispatcher = ForwardingUnconfinedCoroutineDispatcher(originalDispatcher) - return withContext(unconfinedDispatcher) { - try { - block() - } finally { - // Optimization to avoid dispatching when there's nothing left to do. - unconfinedDispatcher.unconfined = true - } - } -} - /** * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true * and [delegate] when [unconfined] is false. From 8ac95cc594f934244e5a4e90755b081bfa6078fb Mon Sep 17 00:00:00 2001 From: Colin White Date: Sun, 22 Dec 2024 16:33:54 -0500 Subject: [PATCH 27/34] Pass context through. --- .../internal/ForwardingUnconfinedCoroutineScopeTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt index 9b5db68a86..3f686956c0 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt @@ -60,7 +60,7 @@ class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { fun `imageLoader does not dispatch if context does not change`() = runTest { forwardingDispatcher.unconfined = true - ForwardingUnconfinedCoroutineScope(forwardingDispatcher).launch { + ForwardingUnconfinedCoroutineScope(coroutineContext + forwardingDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) @@ -80,7 +80,7 @@ class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { fun `imageLoader does dispatch if context changes`() = runTest { forwardingDispatcher.unconfined = true - ForwardingUnconfinedCoroutineScope(forwardingDispatcher).launch { + ForwardingUnconfinedCoroutineScope(coroutineContext + forwardingDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) From ebddb12335eeb22f16192d01d60a6e57366b9cb6 Mon Sep 17 00:00:00 2001 From: Colin White Date: Sun, 22 Dec 2024 16:35:18 -0500 Subject: [PATCH 28/34] Docs. --- .../coil3/compose/internal/ForwardingCoroutineContext.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt index 561533b30c..232f8e7958 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt @@ -2,6 +2,9 @@ package coil3.compose.internal import kotlin.coroutines.CoroutineContext +/** + * A special [CoroutineContext] implementation that lets us observe changes to its elements. + */ internal class ForwardingCoroutineContext( private val delegate: CoroutineContext, private val onNewContext: (old: CoroutineContext, new: CoroutineContext) -> Unit, From 63c3f6c442f56279bb81c12a505a3a82093e0e27 Mon Sep 17 00:00:00 2001 From: Colin White Date: Sun, 22 Dec 2024 22:08:41 -0500 Subject: [PATCH 29/34] Fix missing join. --- .../compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt index 3f686956c0..ef2299afd6 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt @@ -92,7 +92,7 @@ class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { assertIs(result) assertEquals(1, testDispatcher.dispatchCount) - } + }.join() } private class TestCoroutineDispatcher : CoroutineDispatcher() { From 5e53a1bee9d98f1e5bbd4f2485880f2e4b7d61ea Mon Sep 17 00:00:00 2001 From: Colin White Date: Mon, 23 Dec 2024 16:38:43 -0500 Subject: [PATCH 30/34] Don't pay for dispatch. --- .../kotlin/coil3/compose/AsyncImagePainter.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 072777142c..48367e5006 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -46,6 +46,7 @@ import coil3.size.Precision import coil3.size.SizeResolver import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -54,8 +55,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** @@ -218,8 +220,9 @@ class AsyncImagePainter internal constructor( // Observe the latest request and execute any emissions. val originalDispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined - rememberJob = restartSignal - .transformLatest { + val scope = ForwardingUnconfinedCoroutineScope(scope.coroutineContext) + rememberJob = scope.launch(Dispatchers.Unconfined, CoroutineStart.UNDISPATCHED) { + restartSignal.transformLatest { _input.collect { input -> withContext(ForwardingUnconfinedCoroutineDispatcher(originalDispatcher)) { val previewHandler = previewHandler @@ -235,8 +238,8 @@ class AsyncImagePainter internal constructor( updateState(state) } } - } - .launchIn(ForwardingUnconfinedCoroutineScope(scope.coroutineContext + Dispatchers.Unconfined)) + }.collect() + } } override fun onForgotten() { From eda1be0aa9ee2ca152fd7a4ae6b742ba612283e3 Mon Sep 17 00:00:00 2001 From: Colin White Date: Tue, 14 Jan 2025 17:30:22 -0800 Subject: [PATCH 31/34] Rename dispatcher. --- .../api/coil-compose-core.klib.api | 6 +- .../kotlin/coil3/compose/AsyncImagePainter.kt | 16 +-- .../internal/DelayedDispatchCoroutineScope.kt | 98 +++++++++++++++++++ .../internal/ForwardingCoroutineContext.kt | 18 ++-- .../ForwardingUnconfinedCoroutineScope.kt | 61 ------------ ...t => DelayedDispatchCoroutineScopeTest.kt} | 20 ++-- 6 files changed, 127 insertions(+), 92 deletions(-) create mode 100644 coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScope.kt delete mode 100644 coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt rename coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/{ForwardingUnconfinedCoroutineScopeTest.kt => DelayedDispatchCoroutineScopeTest.kt} (87%) diff --git a/coil-compose-core/api/coil-compose-core.klib.api b/coil-compose-core/api/coil-compose-core.klib.api index ad234f1681..6d2e1bd6bb 100644 --- a/coil-compose-core/api/coil-compose-core.klib.api +++ b/coil-compose-core/api/coil-compose-core.klib.api @@ -183,8 +183,9 @@ final class coil3.compose/ImagePainter : androidx.compose.ui.graphics.painter/Pa final val coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop|#static{}coil3_compose_internal_AsyncImageState$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop|#static{}coil3_compose_internal_ContentPainterElement$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop|#static{}coil3_compose_internal_ContentPainterNode$stableprop[0] +final val coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop|#static{}coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop[0] +final val coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop|#static{}coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop // coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop|#static{}coil3_compose_internal_ForwardingCoroutineContext$stableprop[0] -final val coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop // coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop|#static{}coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop[0] final val coil3.compose/LocalAsyncImageModelEqualityDelegate // coil3.compose/LocalAsyncImageModelEqualityDelegate|{}LocalAsyncImageModelEqualityDelegate[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // coil3.compose/LocalAsyncImageModelEqualityDelegate.|(){}[0] final val coil3.compose/LocalAsyncImagePreviewHandler // coil3.compose/LocalAsyncImagePreviewHandler|{}LocalAsyncImagePreviewHandler[0] @@ -206,8 +207,9 @@ final fun (coil3/Image).coil3.compose/asPainter(coil3/PlatformContext, androidx. final fun coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter|coil3_compose_internal_AsyncImageState$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter|coil3_compose_internal_ContentPainterElement$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter|coil3_compose_internal_ContentPainterNode$stableprop_getter(){}[0] +final fun coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop_getter|coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop_getter(){}[0] +final fun coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop_getter|coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter|coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter(){}[0] -final fun coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter|coil3_compose_internal_ForwardingUnconfinedCoroutineDispatcher$stableprop_getter(){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun coil3.compose/DrawScopeSizeResolver(): coil3.compose/DrawScopeSizeResolver // coil3.compose/DrawScopeSizeResolver|DrawScopeSizeResolver(){}[0] diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 48367e5006..0af20b865b 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,13 +31,13 @@ import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState -import coil3.compose.internal.ForwardingUnconfinedCoroutineDispatcher -import coil3.compose.internal.ForwardingUnconfinedCoroutineScope -import coil3.compose.internal.dispatcher +import coil3.compose.internal.DelayedDispatchCoroutineScope +import coil3.compose.internal.launchUndispatched import coil3.compose.internal.onStateOf import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf +import coil3.compose.internal.withDelayedDispatch import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult @@ -46,8 +46,6 @@ import coil3.size.Precision import coil3.size.SizeResolver import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST @@ -57,8 +55,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.transformLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext /** * Return an [AsyncImagePainter] that executes an [ImageRequest] asynchronously and renders the result. @@ -219,12 +215,10 @@ class AsyncImagePainter internal constructor( (painter as? RememberObserver)?.onRemembered() // Observe the latest request and execute any emissions. - val originalDispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined - val scope = ForwardingUnconfinedCoroutineScope(scope.coroutineContext) - rememberJob = scope.launch(Dispatchers.Unconfined, CoroutineStart.UNDISPATCHED) { + rememberJob = DelayedDispatchCoroutineScope(scope.coroutineContext).launchUndispatched { restartSignal.transformLatest { _input.collect { input -> - withContext(ForwardingUnconfinedCoroutineDispatcher(originalDispatcher)) { + withDelayedDispatch { val previewHandler = previewHandler val state = if (previewHandler != null) { // If we're in inspection mode use the preview renderer. diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScope.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScope.kt new file mode 100644 index 0000000000..c69100f7d8 --- /dev/null +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScope.kt @@ -0,0 +1,98 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package coil3.compose.internal + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A [CoroutineScope] that does not dispatch until the [CoroutineDispatcher] in its + * [CoroutineContext] changes. + */ +internal fun DelayedDispatchCoroutineScope( + context: CoroutineContext, +) = CoroutineScope(DelayedDispatchCoroutineContext(context)) + +/** + * A special [CoroutineContext] implementation that automatically enables + * [DelayedDispatchCoroutineDispatcher] dispatching if the context's [CoroutineDispatcher] changes. + */ +internal class DelayedDispatchCoroutineContext( + context: CoroutineContext, + val originalDispatcher: CoroutineDispatcher = context.dispatcher ?: Dispatchers.Unconfined, +) : ForwardingCoroutineContext(context) { + + override fun newContext( + old: CoroutineContext, + new: CoroutineContext, + ): ForwardingCoroutineContext { + val oldDispatcher = old.dispatcher + val newDispatcher = new.dispatcher + if (oldDispatcher is DelayedDispatchCoroutineDispatcher && oldDispatcher != newDispatcher) { + oldDispatcher.unconfined = oldDispatcher.unconfined && + (newDispatcher == null || newDispatcher == Dispatchers.Unconfined) + } + + return DelayedDispatchCoroutineContext(new, originalDispatcher) + } +} + +/** Launch [block] without dispatching. */ +internal inline fun CoroutineScope.launchUndispatched( + noinline block: suspend CoroutineScope.() -> Unit, +) = launch(Dispatchers.Unconfined, CoroutineStart.UNDISPATCHED, block) + +/** + * Execute [block] without dispatching until the scope's [CoroutineDispatcher] changes. + * Must be called from inside a [DelayedDispatchCoroutineScope]. + */ +internal suspend inline fun withDelayedDispatch( + noinline block: suspend CoroutineScope.() -> T, +): T { + val originalDispatcher = (coroutineContext as DelayedDispatchCoroutineContext).originalDispatcher + return withContext(DelayedDispatchCoroutineDispatcher(originalDispatcher), block) +} + +/** + * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true + * and [delegate] when [unconfined] is false. + */ +internal class DelayedDispatchCoroutineDispatcher( + private val delegate: CoroutineDispatcher, +) : CoroutineDispatcher() { + private val _unconfined = atomic(true) + var unconfined by _unconfined + + private val currentDispatcher: CoroutineDispatcher + get() = if (_unconfined.value) Dispatchers.Unconfined else delegate + + override fun isDispatchNeeded(context: CoroutineContext): Boolean { + return currentDispatcher.isDispatchNeeded(context) + } + + override fun limitedParallelism(parallelism: Int, name: String?): CoroutineDispatcher { + return currentDispatcher.limitedParallelism(parallelism, name) + } + + override fun dispatch(context: CoroutineContext, block: Runnable) { + currentDispatcher.dispatch(context, block) + } + + @InternalCoroutinesApi + override fun dispatchYield(context: CoroutineContext, block: Runnable) { + currentDispatcher.dispatchYield(context, block) + } + + override fun toString(): String { + return "DelayedDispatchCoroutineDispatcher(delegate=$delegate)" + } +} diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt index 232f8e7958..1e8fc6417a 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingCoroutineContext.kt @@ -3,23 +3,25 @@ package coil3.compose.internal import kotlin.coroutines.CoroutineContext /** - * A special [CoroutineContext] implementation that lets us observe changes to its elements. + * A special [CoroutineContext] implementation that observes changes to its elements. */ -internal class ForwardingCoroutineContext( +internal abstract class ForwardingCoroutineContext( private val delegate: CoroutineContext, - private val onNewContext: (old: CoroutineContext, new: CoroutineContext) -> Unit, ) : CoroutineContext by delegate { + abstract fun newContext( + old: CoroutineContext, + new: CoroutineContext, + ): ForwardingCoroutineContext + override fun minusKey(key: CoroutineContext.Key<*>): CoroutineContext { val new = delegate.minusKey(key) - onNewContext(this, new) - return ForwardingCoroutineContext(new, onNewContext) + return newContext(this, new) } override operator fun plus(context: CoroutineContext): CoroutineContext { val new = delegate + context - onNewContext(this, new) - return ForwardingCoroutineContext(new, onNewContext) + return newContext(this, new) } override fun equals(other: Any?): Boolean { @@ -31,6 +33,6 @@ internal class ForwardingCoroutineContext( } override fun toString(): String { - return delegate.toString() + return "ForwardingCoroutineContext(delegate=$delegate)" } } diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt deleted file mode 100644 index ad53806d40..0000000000 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScope.kt +++ /dev/null @@ -1,61 +0,0 @@ -package coil3.compose.internal - -import kotlin.coroutines.CoroutineContext -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.Runnable - -/** - * A [CoroutineScope] with a special [CoroutineContext] that enables [ForwardingUnconfinedCoroutineDispatcher] - * to enable dispatching after it is replaced in the context. - */ -internal fun ForwardingUnconfinedCoroutineScope( - context: CoroutineContext, -) = CoroutineScope( - context = ForwardingCoroutineContext(context) { old, new -> - val oldDispatcher = old.dispatcher - val newDispatcher = new.dispatcher - if (oldDispatcher is ForwardingUnconfinedCoroutineDispatcher && oldDispatcher != newDispatcher) { - oldDispatcher.unconfined = oldDispatcher.unconfined && - (newDispatcher == null || newDispatcher == Dispatchers.Unconfined) - } - } -) - -/** - * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true - * and [delegate] when [unconfined] is false. - */ -internal class ForwardingUnconfinedCoroutineDispatcher( - private val delegate: CoroutineDispatcher, -) : CoroutineDispatcher() { - private val _unconfined = atomic(true) - var unconfined by _unconfined - - private val currentDispatcher: CoroutineDispatcher - get() = if (_unconfined.value) Dispatchers.Unconfined else delegate - - override fun isDispatchNeeded(context: CoroutineContext): Boolean { - return currentDispatcher.isDispatchNeeded(context) - } - - override fun limitedParallelism(parallelism: Int, name: String?): CoroutineDispatcher { - return currentDispatcher.limitedParallelism(parallelism, name) - } - - override fun dispatch(context: CoroutineContext, block: Runnable) { - currentDispatcher.dispatch(context, block) - } - - @InternalCoroutinesApi - override fun dispatchYield(context: CoroutineContext, block: Runnable) { - currentDispatcher.dispatchYield(context, block) - } - - override fun toString(): String { - return "ForwardingUnconfinedCoroutineDispatcher(delegate=$delegate)" - } -} diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScopeTest.kt similarity index 87% rename from coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt rename to coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScopeTest.kt index ef2299afd6..7efa947246 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/ForwardingUnconfinedCoroutineScopeTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScopeTest.kt @@ -31,15 +31,15 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import okio.Buffer -class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { +class DelayedDispatchCoroutineScopeTest : RobolectricTest() { private val testDispatcher = TestCoroutineDispatcher() - private val forwardingDispatcher = ForwardingUnconfinedCoroutineDispatcher(testDispatcher) + private val delayedDispatcher = DelayedDispatchCoroutineDispatcher(testDispatcher) @Test fun `does not dispatch when unconfined=true`() = runTest { - forwardingDispatcher.unconfined = true + delayedDispatcher.unconfined = true - withContext(forwardingDispatcher) { + withContext(delayedDispatcher) { delay(100.milliseconds) assertEquals(0, testDispatcher.dispatchCount) } @@ -47,9 +47,9 @@ class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { @Test fun `does dispatch when unconfined=false`() = runTest { - forwardingDispatcher.unconfined = false + delayedDispatcher.unconfined = false - withContext(forwardingDispatcher) { + withContext(delayedDispatcher) { delay(100.milliseconds) assertEquals(2, testDispatcher.dispatchCount) } @@ -58,9 +58,9 @@ class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @Test fun `imageLoader does not dispatch if context does not change`() = runTest { - forwardingDispatcher.unconfined = true + delayedDispatcher.unconfined = true - ForwardingUnconfinedCoroutineScope(coroutineContext + forwardingDispatcher).launch { + DelayedDispatchCoroutineScope(coroutineContext + delayedDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) @@ -78,9 +78,9 @@ class ForwardingUnconfinedCoroutineScopeTest : RobolectricTest() { /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @Test fun `imageLoader does dispatch if context changes`() = runTest { - forwardingDispatcher.unconfined = true + delayedDispatcher.unconfined = true - ForwardingUnconfinedCoroutineScope(coroutineContext + forwardingDispatcher).launch { + DelayedDispatchCoroutineScope(coroutineContext + delayedDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) From a78ede842dbfa6eab40ec37a0f266f6f6bd38427 Mon Sep 17 00:00:00 2001 From: Colin White Date: Tue, 14 Jan 2025 17:31:44 -0800 Subject: [PATCH 32/34] Rename file. --- .../{DelayedDispatchCoroutineScope.kt => DelayedDispatch.kt} | 0 ...ayedDispatchCoroutineScopeTest.kt => DelayedDispatchTest.kt} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/{DelayedDispatchCoroutineScope.kt => DelayedDispatch.kt} (100%) rename coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/{DelayedDispatchCoroutineScopeTest.kt => DelayedDispatchTest.kt} (98%) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScope.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatch.kt similarity index 100% rename from coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScope.kt rename to coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatch.kt diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScopeTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchTest.kt similarity index 98% rename from coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScopeTest.kt rename to coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchTest.kt index 7efa947246..4e96687123 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchCoroutineScopeTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchTest.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import okio.Buffer -class DelayedDispatchCoroutineScopeTest : RobolectricTest() { +class DelayedDispatchTest : RobolectricTest() { private val testDispatcher = TestCoroutineDispatcher() private val delayedDispatcher = DelayedDispatchCoroutineDispatcher(testDispatcher) From 0b9e4ee87412b2c6a42ff951305684355e6d52e4 Mon Sep 17 00:00:00 2001 From: Colin White Date: Tue, 14 Jan 2025 17:36:49 -0800 Subject: [PATCH 33/34] Rename. --- .../api/coil-compose-core.klib.api | 8 +++---- .../kotlin/coil3/compose/AsyncImagePainter.kt | 8 +++---- ...DelayedDispatch.kt => DeferredDispatch.kt} | 24 +++++++++---------- ...ispatchTest.kt => DeferredDispatchTest.kt} | 20 ++++++++-------- 4 files changed, 30 insertions(+), 30 deletions(-) rename coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/{DelayedDispatch.kt => DeferredDispatch.kt} (76%) rename coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/{DelayedDispatchTest.kt => DeferredDispatchTest.kt} (88%) diff --git a/coil-compose-core/api/coil-compose-core.klib.api b/coil-compose-core/api/coil-compose-core.klib.api index 6d2e1bd6bb..98b11da685 100644 --- a/coil-compose-core/api/coil-compose-core.klib.api +++ b/coil-compose-core/api/coil-compose-core.klib.api @@ -183,8 +183,8 @@ final class coil3.compose/ImagePainter : androidx.compose.ui.graphics.painter/Pa final val coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop|#static{}coil3_compose_internal_AsyncImageState$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop|#static{}coil3_compose_internal_ContentPainterElement$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop|#static{}coil3_compose_internal_ContentPainterNode$stableprop[0] -final val coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop|#static{}coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop[0] -final val coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop|#static{}coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop[0] +final val coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineContext$stableprop // coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineContext$stableprop|#static{}coil3_compose_internal_DeferredDispatchCoroutineContext$stableprop[0] +final val coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineDispatcher$stableprop // coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineDispatcher$stableprop|#static{}coil3_compose_internal_DeferredDispatchCoroutineDispatcher$stableprop[0] final val coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop // coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop|#static{}coil3_compose_internal_ForwardingCoroutineContext$stableprop[0] final val coil3.compose/LocalAsyncImageModelEqualityDelegate // coil3.compose/LocalAsyncImageModelEqualityDelegate|{}LocalAsyncImageModelEqualityDelegate[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // coil3.compose/LocalAsyncImageModelEqualityDelegate.|(){}[0] @@ -207,8 +207,8 @@ final fun (coil3/Image).coil3.compose/asPainter(coil3/PlatformContext, androidx. final fun coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_AsyncImageState$stableprop_getter|coil3_compose_internal_AsyncImageState$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterElement$stableprop_getter|coil3_compose_internal_ContentPainterElement$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ContentPainterNode$stableprop_getter|coil3_compose_internal_ContentPainterNode$stableprop_getter(){}[0] -final fun coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop_getter|coil3_compose_internal_DelayedDispatchCoroutineContext$stableprop_getter(){}[0] -final fun coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop_getter|coil3_compose_internal_DelayedDispatchCoroutineDispatcher$stableprop_getter(){}[0] +final fun coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineContext$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineContext$stableprop_getter|coil3_compose_internal_DeferredDispatchCoroutineContext$stableprop_getter(){}[0] +final fun coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineDispatcher$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_DeferredDispatchCoroutineDispatcher$stableprop_getter|coil3_compose_internal_DeferredDispatchCoroutineDispatcher$stableprop_getter(){}[0] final fun coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter(): kotlin/Int // coil3.compose.internal/coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter|coil3_compose_internal_ForwardingCoroutineContext$stableprop_getter(){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, androidx.compose.ui.graphics.painter/Painter?, kotlin/Function1?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;androidx.compose.ui.graphics.painter.Painter?;kotlin.Function1?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun coil3.compose/AsyncImage(kotlin/Any?, kotlin/String?, coil3/ImageLoader, androidx.compose.ui/Modifier?, kotlin/Function1?, kotlin/Function1?, androidx.compose.ui/Alignment?, androidx.compose.ui.layout/ContentScale?, kotlin/Float, androidx.compose.ui.graphics/ColorFilter?, androidx.compose.ui.graphics/FilterQuality, kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // coil3.compose/AsyncImage|AsyncImage(kotlin.Any?;kotlin.String?;coil3.ImageLoader;androidx.compose.ui.Modifier?;kotlin.Function1?;kotlin.Function1?;androidx.compose.ui.Alignment?;androidx.compose.ui.layout.ContentScale?;kotlin.Float;androidx.compose.ui.graphics.ColorFilter?;androidx.compose.ui.graphics.FilterQuality;kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt index 0af20b865b..823c680f04 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/AsyncImagePainter.kt @@ -31,13 +31,13 @@ import coil3.compose.AsyncImagePainter.Companion.DefaultTransform import coil3.compose.AsyncImagePainter.Input import coil3.compose.AsyncImagePainter.State import coil3.compose.internal.AsyncImageState -import coil3.compose.internal.DelayedDispatchCoroutineScope +import coil3.compose.internal.DeferredDispatchCoroutineScope import coil3.compose.internal.launchUndispatched import coil3.compose.internal.onStateOf import coil3.compose.internal.requestOf import coil3.compose.internal.toScale import coil3.compose.internal.transformOf -import coil3.compose.internal.withDelayedDispatch +import coil3.compose.internal.withDeferredDispatch import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.ImageResult @@ -215,10 +215,10 @@ class AsyncImagePainter internal constructor( (painter as? RememberObserver)?.onRemembered() // Observe the latest request and execute any emissions. - rememberJob = DelayedDispatchCoroutineScope(scope.coroutineContext).launchUndispatched { + rememberJob = DeferredDispatchCoroutineScope(scope.coroutineContext).launchUndispatched { restartSignal.transformLatest { _input.collect { input -> - withDelayedDispatch { + withDeferredDispatch { val previewHandler = previewHandler val state = if (previewHandler != null) { // If we're in inspection mode use the preview renderer. diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatch.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt similarity index 76% rename from coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatch.kt rename to coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt index c69100f7d8..2f5f5bef3f 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DelayedDispatch.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt @@ -18,15 +18,15 @@ import kotlinx.coroutines.withContext * A [CoroutineScope] that does not dispatch until the [CoroutineDispatcher] in its * [CoroutineContext] changes. */ -internal fun DelayedDispatchCoroutineScope( +internal fun DeferredDispatchCoroutineScope( context: CoroutineContext, -) = CoroutineScope(DelayedDispatchCoroutineContext(context)) +) = CoroutineScope(DeferredDispatchCoroutineContext(context)) /** * A special [CoroutineContext] implementation that automatically enables - * [DelayedDispatchCoroutineDispatcher] dispatching if the context's [CoroutineDispatcher] changes. + * [DeferredDispatchCoroutineDispatcher] dispatching if the context's [CoroutineDispatcher] changes. */ -internal class DelayedDispatchCoroutineContext( +internal class DeferredDispatchCoroutineContext( context: CoroutineContext, val originalDispatcher: CoroutineDispatcher = context.dispatcher ?: Dispatchers.Unconfined, ) : ForwardingCoroutineContext(context) { @@ -37,12 +37,12 @@ internal class DelayedDispatchCoroutineContext( ): ForwardingCoroutineContext { val oldDispatcher = old.dispatcher val newDispatcher = new.dispatcher - if (oldDispatcher is DelayedDispatchCoroutineDispatcher && oldDispatcher != newDispatcher) { + if (oldDispatcher is DeferredDispatchCoroutineDispatcher && oldDispatcher != newDispatcher) { oldDispatcher.unconfined = oldDispatcher.unconfined && (newDispatcher == null || newDispatcher == Dispatchers.Unconfined) } - return DelayedDispatchCoroutineContext(new, originalDispatcher) + return DeferredDispatchCoroutineContext(new, originalDispatcher) } } @@ -53,20 +53,20 @@ internal inline fun CoroutineScope.launchUndispatched( /** * Execute [block] without dispatching until the scope's [CoroutineDispatcher] changes. - * Must be called from inside a [DelayedDispatchCoroutineScope]. + * Must be called from inside a [DeferredDispatchCoroutineScope]. */ -internal suspend inline fun withDelayedDispatch( +internal suspend inline fun withDeferredDispatch( noinline block: suspend CoroutineScope.() -> T, ): T { - val originalDispatcher = (coroutineContext as DelayedDispatchCoroutineContext).originalDispatcher - return withContext(DelayedDispatchCoroutineDispatcher(originalDispatcher), block) + val originalDispatcher = (coroutineContext as DeferredDispatchCoroutineContext).originalDispatcher + return withContext(DeferredDispatchCoroutineDispatcher(originalDispatcher), block) } /** * A [CoroutineDispatcher] that delegates to [Dispatchers.Unconfined] while [unconfined] is true * and [delegate] when [unconfined] is false. */ -internal class DelayedDispatchCoroutineDispatcher( +internal class DeferredDispatchCoroutineDispatcher( private val delegate: CoroutineDispatcher, ) : CoroutineDispatcher() { private val _unconfined = atomic(true) @@ -93,6 +93,6 @@ internal class DelayedDispatchCoroutineDispatcher( } override fun toString(): String { - return "DelayedDispatchCoroutineDispatcher(delegate=$delegate)" + return "DeferredDispatchCoroutineDispatcher(delegate=$delegate)" } } diff --git a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchTest.kt b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DeferredDispatchTest.kt similarity index 88% rename from coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchTest.kt rename to coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DeferredDispatchTest.kt index 4e96687123..9560075b93 100644 --- a/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DelayedDispatchTest.kt +++ b/coil-compose-core/src/commonTest/kotlin/coil3/compose/internal/DeferredDispatchTest.kt @@ -31,15 +31,15 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import okio.Buffer -class DelayedDispatchTest : RobolectricTest() { +class DeferredDispatchTest : RobolectricTest() { private val testDispatcher = TestCoroutineDispatcher() - private val delayedDispatcher = DelayedDispatchCoroutineDispatcher(testDispatcher) + private val deferredDispatcher = DeferredDispatchCoroutineDispatcher(testDispatcher) @Test fun `does not dispatch when unconfined=true`() = runTest { - delayedDispatcher.unconfined = true + deferredDispatcher.unconfined = true - withContext(delayedDispatcher) { + withContext(deferredDispatcher) { delay(100.milliseconds) assertEquals(0, testDispatcher.dispatchCount) } @@ -47,9 +47,9 @@ class DelayedDispatchTest : RobolectricTest() { @Test fun `does dispatch when unconfined=false`() = runTest { - delayedDispatcher.unconfined = false + deferredDispatcher.unconfined = false - withContext(delayedDispatcher) { + withContext(deferredDispatcher) { delay(100.milliseconds) assertEquals(2, testDispatcher.dispatchCount) } @@ -58,9 +58,9 @@ class DelayedDispatchTest : RobolectricTest() { /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @Test fun `imageLoader does not dispatch if context does not change`() = runTest { - delayedDispatcher.unconfined = true + deferredDispatcher.unconfined = true - DelayedDispatchCoroutineScope(coroutineContext + delayedDispatcher).launch { + DeferredDispatchCoroutineScope(coroutineContext + deferredDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) @@ -78,9 +78,9 @@ class DelayedDispatchTest : RobolectricTest() { /** This test emulates the context that [AsyncImagePainter] launches its request into. */ @Test fun `imageLoader does dispatch if context changes`() = runTest { - delayedDispatcher.unconfined = true + deferredDispatcher.unconfined = true - DelayedDispatchCoroutineScope(coroutineContext + delayedDispatcher).launch { + DeferredDispatchCoroutineScope(coroutineContext + deferredDispatcher).launch { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(Unit) From 57e18ff9f66c49215e9e6c23b71ee9480a58bb0a Mon Sep 17 00:00:00 2001 From: Colin White Date: Tue, 14 Jan 2025 17:44:56 -0800 Subject: [PATCH 34/34] Make originalDispatcher arg explicit. --- .../kotlin/coil3/compose/internal/DeferredDispatch.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt index 2f5f5bef3f..1ec7f1f2ed 100644 --- a/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt +++ b/coil-compose-core/src/commonMain/kotlin/coil3/compose/internal/DeferredDispatch.kt @@ -20,7 +20,10 @@ import kotlinx.coroutines.withContext */ internal fun DeferredDispatchCoroutineScope( context: CoroutineContext, -) = CoroutineScope(DeferredDispatchCoroutineContext(context)) +): CoroutineScope { + val originalDispatcher = context.dispatcher ?: Dispatchers.Unconfined + return CoroutineScope(DeferredDispatchCoroutineContext(context, originalDispatcher)) +} /** * A special [CoroutineContext] implementation that automatically enables @@ -28,7 +31,7 @@ internal fun DeferredDispatchCoroutineScope( */ internal class DeferredDispatchCoroutineContext( context: CoroutineContext, - val originalDispatcher: CoroutineDispatcher = context.dispatcher ?: Dispatchers.Unconfined, + val originalDispatcher: CoroutineDispatcher, ) : ForwardingCoroutineContext(context) { override fun newContext(