Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove dependency on Dispatchers.Main.immediate for immediate dispatching in Compose. #2725

Open
wants to merge 30 commits into
base: main
Choose a base branch
from

Conversation

colinrtwhite
Copy link
Member

@colinrtwhite colinrtwhite commented Nov 29, 2024

Fixes an issue with Coil's public composables where its memory cache check could miss the first frame due to dispatching. This case occurs with Paparazzi/Roborazzi/AndroidX screenshot tests and is due to the dispatcher provided by rememberCoroutineScope() either not supporting immediate dispatching, Dispatchers.Main.immediate being unavailable, or Dispatchers.Main.immediate using a different underlying thread.

To fix this I tried a couple things:

  • Using CoroutineStart.UNDISPATCHED with a StateFlow. This works for initial composition, but does not resolve synchronously from the memory cache on subsequent requests (e.g. when the AsyncImage(model) changes).
  • Attempting to create a CoroutineDispatcher that re-enables dispatching after the first time the context's CoroutineDispatcher changes. As far as I can tell this isn't possible at the moment with CoroutineInterceptor's hooks.

I settled on using a custom CoroutineDispatcher that acts like Dispatchers.Unconfined until after the CoroutineContext's dispatcher changes. This is safe for Coil's internals as it's all thread safe. Aside from fixing the above issue this has a number of benefits:

  • This ensures we have consistent behaviour in all environments instead of falling back to Dispatchers.Unconfined if Dispatchers.Main.immediate is unavailable.
  • kotlinx-coroutines-swing no longer needs to be added manually as a dependency on JVM.

@colinrtwhite colinrtwhite force-pushed the colin/delayed_dispatch branch from cb33907 to cd47369 Compare December 3, 2024 17:26
@colinrtwhite colinrtwhite force-pushed the colin/delayed_dispatch branch 2 times, most recently from 083e410 to ea0bbbc Compare December 20, 2024 01:52
@colinrtwhite colinrtwhite force-pushed the colin/delayed_dispatch branch from e2cda61 to 3fe0ca5 Compare December 22, 2024 20:02
Copy link

@zach-klippenstein zach-klippenstein left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really clever, glad you were able to figure out a solution and even get rid of that swing dep!

val request = updateRequest(input.request, isPreview = false)
input.imageLoader.execute(request).toState()
val originalDispatcher = scope.coroutineContext.dispatcher ?: Dispatchers.Unconfined
val scope = ForwardingUnconfinedCoroutineScope(scope.coroutineContext)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took me a while to understand how ForwardingUnconfinedCoroutineScope and ForwardingUnconfinedCoroutineDispatcher interact. Could the code that wires them up with the launch be extracted into a helper function? That logic seems mostly unrelated to the other logic in this function, which is more high-level, and pulling it out might make this a bit more readable.

Eg. (names are arbitrary):

// Observe the latest request and execute any emissions.
rememberJob = scope.launchTemporarilyUnconfined {
  restartSignal.transformLatest<Unit, Nothing> {
    _input.collect { input ->
      withNormalDispatch {
        val previewHandler = previewHandler
        // ...
      }
    }
  }
}

fun CoroutineScope.launchTemporarilyUnconfined(
  block: suspend DeferringDispatchScope.() -> Unit
): Job {
  val originalDispatcher = coroutineContext.dispatcher ?: Dispatchers.Unconfined
  val forwardingScope = ForwardingUnconfinedCoroutineScope(scope.coroutineContext)
  forwardingScope.launch(Dispatchers.Unconfined, CoroutineStart.UNDISPATCHED) {
    DeferringDispatchScope(this, originalDispatcher).block()
  }
}

class DeferringDispatchScope internal constructor(
  scope: CoroutineScope,
  private val originalDispatcher: CoroutineDispatcher
): CoroutineScope by scope {
  // This could also just be a function passed as a normal param to launchDeferringDispatch's
  // block lambda if you don't want to bother with a separate class. 
  suspend fun <T> withNormalDispatch(block: suspend CoroutineScope.() -> T) =
    withContext(ForwardingUnconfinedCoroutineDispatcher(originalDispatcher), block)
}

but that might be a bit over-engineered, just an idea.

private val delegate: CoroutineDispatcher,
) : CoroutineDispatcher() {
private val _unconfined = atomic(true)
var unconfined by _unconfined

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider documenting that after setting this to false, it's ignored even if set to true again.

/**
* A special [CoroutineContext] implementation that lets us observe changes to its elements.
*/
internal class ForwardingCoroutineContext(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oo this is a neat trick.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants