|
| 1 | +package com.bumptech.glide.integration.ktx |
| 2 | + |
| 3 | +import android.graphics.drawable.Drawable |
| 4 | +import androidx.annotation.GuardedBy |
| 5 | +import com.bumptech.glide.RequestBuilder |
| 6 | +import com.bumptech.glide.intoDirect |
| 7 | +import com.bumptech.glide.load.DataSource |
| 8 | +import com.bumptech.glide.load.engine.GlideException |
| 9 | +import com.bumptech.glide.request.Request |
| 10 | +import com.bumptech.glide.request.RequestListener |
| 11 | +import com.bumptech.glide.request.target.SizeReadyCallback |
| 12 | +import com.bumptech.glide.request.target.Target |
| 13 | +import com.bumptech.glide.request.transition.Transition |
| 14 | +import com.bumptech.glide.requestManager |
| 15 | +import kotlinx.coroutines.channels.ProducerScope |
| 16 | +import kotlinx.coroutines.channels.awaitClose |
| 17 | +import kotlinx.coroutines.flow.Flow |
| 18 | +import kotlinx.coroutines.flow.callbackFlow |
| 19 | +import kotlinx.coroutines.launch |
| 20 | + |
| 21 | +@RequiresOptIn( |
| 22 | + level = RequiresOptIn.Level.ERROR, |
| 23 | + message = "Glide's flow integration is very experimental and subject to breaking API or behavior changes" |
| 24 | +) |
| 25 | +@Retention(AnnotationRetention.BINARY) |
| 26 | +@kotlin.annotation.Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) |
| 27 | +public annotation class ExperimentGlideFlows |
| 28 | + |
| 29 | +/** |
| 30 | + * The current status of a flow |
| 31 | + * |
| 32 | + * There is no well established graph that defines the valid Status transitions. Depending on |
| 33 | + * various factors like the parameters of the request, whether or not the resource is in the memory |
| 34 | + * cache, or even various calls to Glide's APIs, these may be emitted in different orders. As an |
| 35 | + * example, [RUNNING] is skipped if a request can be immediately completed from the memory cache. |
| 36 | + * |
| 37 | + * See [flow] for more details. |
| 38 | + */ |
| 39 | +@ExperimentGlideFlows |
| 40 | +public enum class Status { |
| 41 | + /** The load is not started or has been cleared. */ |
| 42 | + CLEARED, |
| 43 | + /** At least the primary load is still in progress. */ |
| 44 | + RUNNING, |
| 45 | + /** |
| 46 | + * The primary load or the error load ([RequestBuilder.error]) associated with the primary have |
| 47 | + * finished successfully. |
| 48 | + */ |
| 49 | + SUCCEEDED, |
| 50 | + /** The primary load has failed. One or more thumbnails may have succeeded. */ |
| 51 | + FAILED, |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * Identical to `flow(dimension, dimension)` |
| 56 | + */ |
| 57 | +@ExperimentGlideFlows |
| 58 | +public fun <ResourceT : Any> RequestBuilder<ResourceT>.flow( |
| 59 | + dimension: Int |
| 60 | +): Flow<GlideFlowInstant<ResourceT>> = flow(dimension, dimension) |
| 61 | + |
| 62 | +/** |
| 63 | + * Convert a load in Glide into a flow that emits placeholders and resources in the order they'd |
| 64 | + * be seen by a [Target]. |
| 65 | + * |
| 66 | + * Just like a [Target] there is no well defined end to a Glide request. Barring cancellation, the |
| 67 | + * flow should eventually reach [Status.SUCCEEDED] or [Status.FAILED] at least once. However |
| 68 | + * connectivity changes, calls to [com.bumptech.glide.RequestManager.pauseAllRequests] or |
| 69 | + * [com.bumptech.glide.RequestManager.resumeRequests], or the lifecycle associated with this request |
| 70 | + * may cause the request to be started multiple times. As long as the flow is active, callers will |
| 71 | + * receive emissions from every run. |
| 72 | + * |
| 73 | + * This flow will clear the associated Glide request when it's cancelled. This means that callers |
| 74 | + * must keep the flow active while any resource emitted by the flow is still in use. For UI |
| 75 | + * contexts, collecting the flow in the appropriate fragment or view model coroutine context is |
| 76 | + * sufficient as long as you avoid truncating methods like [kotlinx.coroutines.flow.take], |
| 77 | + * [kotlinx.coroutines.flow.takeWhile], etc. If you do use these methods, you must be sure that |
| 78 | + * you're no longer using or displaying the associated resource once the flow is no longer active |
| 79 | + * (ie [kotlinx.coroutines.flow.collect] finishes). One way to do this would be to mimic the UI |
| 80 | + * by creating and keeping active a coroutine context that collects from the flow while the resource |
| 81 | + * is in use. If this restriction is limiting for you, please file an issue on Github so we can |
| 82 | + * think of alternative options. |
| 83 | + */ |
| 84 | +@ExperimentGlideFlows |
| 85 | +public fun <ResourceT : Any> RequestBuilder<ResourceT>.flow( |
| 86 | + width: Int, height: Int |
| 87 | +): Flow<GlideFlowInstant<ResourceT>> = |
| 88 | + flow(ImmediateGlideSize(Size(width = width, height = height))) |
| 89 | + |
| 90 | +/** |
| 91 | + * A [Status] and value pair, where the value is either a [Placeholder] or a [Resource] depending |
| 92 | + * on how far the Glide load has progressed and/or how successful it's been. |
| 93 | + */ |
| 94 | +@ExperimentGlideFlows |
| 95 | +public sealed class GlideFlowInstant<ResourceT> { |
| 96 | + public abstract val status: Status |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * Wraps a [Status] and a placeholder [Drawable] (from [RequestBuilder.placeholder], |
| 101 | + * [RequestBuilder.fallback], [RequestBuilder.error] etc). |
| 102 | + */ |
| 103 | +@ExperimentGlideFlows |
| 104 | +public data class Placeholder<ResourceT>( |
| 105 | + public override val status: Status, public val placeholder: Drawable?, |
| 106 | +) : GlideFlowInstant<ResourceT>() { |
| 107 | + init { |
| 108 | + require( |
| 109 | + when(status) { |
| 110 | + Status.SUCCEEDED -> false |
| 111 | + Status.CLEARED -> true |
| 112 | + // Placeholder will be present prior to the first thumbnail succeeding |
| 113 | + Status.RUNNING -> true |
| 114 | + Status.FAILED -> true |
| 115 | + } |
| 116 | + ) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Wraps a [Status] and a resource loaded from the primary request, a [RequestBuilder.thumbnail] |
| 122 | + * request, or a [RequestBuilder.error] request. |
| 123 | + * |
| 124 | + * **Status.FAILED** is a perfectly valid status with this class. If the primary request fails, |
| 125 | + * but at least one thumbnail succeeds, the flow will emit `Resource(FAILED, resource)` to indicate |
| 126 | + * both that we have some value but also that the primary request has failed. |
| 127 | + */ |
| 128 | +@ExperimentGlideFlows |
| 129 | +public data class Resource<ResourceT>( |
| 130 | + public override val status: Status, public val resource: ResourceT, |
| 131 | +) : GlideFlowInstant<ResourceT>() { |
| 132 | + init { |
| 133 | + require( |
| 134 | + when (status) { |
| 135 | + Status.SUCCEEDED -> true |
| 136 | + // A load with thumbnail(s) where the thumbnail(s) have finished but not the main request |
| 137 | + Status.RUNNING -> true |
| 138 | + // The primary request of the load failed, but at least one thumbnail was successful. |
| 139 | + Status.FAILED -> true |
| 140 | + // Once the load is cleared, it can only show a placeholder |
| 141 | + Status.CLEARED -> false |
| 142 | + } |
| 143 | + ) |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +@ExperimentGlideFlows |
| 148 | +private fun <ResourceT : Any> RequestBuilder<ResourceT>.flow( |
| 149 | + size: ResolvableGlideSize, |
| 150 | +): Flow<GlideFlowInstant<ResourceT>> { |
| 151 | + val requestBuilder = this |
| 152 | + val requestManager = requestBuilder.requestManager() |
| 153 | + return callbackFlow { |
| 154 | + val target = FlowTarget(this, size) |
| 155 | + requestBuilder.intoDirect(target) |
| 156 | + awaitClose { |
| 157 | + launch { |
| 158 | + requestManager.clear(target) |
| 159 | + } |
| 160 | + } |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +/** |
| 165 | + * Observes a glide request using [Target] and [RequestListener] and tries to emit something |
| 166 | + * resembling a coherent set of placeholders and resources for it. |
| 167 | + * |
| 168 | + * Threading in this class is a bit complicated. As a general rule, the callback methods are |
| 169 | + * ordered by callers. So we have to handle being called from multiple threads, but we don't need |
| 170 | + * to try to handle callbacks being called in parallel. |
| 171 | + * |
| 172 | + * The primary area of concern around thread is that [resolvedSize] and [sizeReadyCallbacks] |
| 173 | + * must be updated atomically, but can be modified on different threads. |
| 174 | + * |
| 175 | + * [currentRequest] would normally be a concern because [Target]s can be cancelled on threads |
| 176 | + * other than where they were started. However in our case, [currentRequest] is set once when our |
| 177 | + * request is started (by us) and is only cancelled when the request finishes. So we just have |
| 178 | + * to avoid NPEs and make sure the state is reasonably up to date. |
| 179 | + * |
| 180 | + * [lastResource] is an unfortunate hack that tries to make sure that we emit [Status.FAILED] if |
| 181 | + * a thumbnail request succeeds, but then the primary request fails. In that case, we'd normally |
| 182 | + * already have emitted [Resource] with [Status.RUNNING] and the thumbnail value and then we'd |
| 183 | + * emit nothing else. That's not very satisfying for callers who expect some resolution. So instead |
| 184 | + * we track the last resource produced by thumbnails and emit that along with [Status.FAILED] when |
| 185 | + * we see that the primary request has failed. As a result we're not concerned with ordering with |
| 186 | + * regards to [lastResource], but it is possible the callbacks will be called on different threads, |
| 187 | + * so the value may be updated from different threads even if it's not concurrent. |
| 188 | + */ |
| 189 | +@ExperimentGlideFlows |
| 190 | +private class FlowTarget<ResourceT : Any>( |
| 191 | + private val scope: ProducerScope<GlideFlowInstant<ResourceT>>, |
| 192 | + private val size: ResolvableGlideSize, |
| 193 | +) : Target<ResourceT>, RequestListener<ResourceT> { |
| 194 | + @Volatile private var resolvedSize: Size? = null |
| 195 | + @Volatile private var currentRequest: Request? = null |
| 196 | + @Volatile private var lastResource: ResourceT? = null |
| 197 | + |
| 198 | + @GuardedBy("this") |
| 199 | + private val sizeReadyCallbacks = mutableListOf<SizeReadyCallback>() |
| 200 | + |
| 201 | + init { |
| 202 | + when (size) { |
| 203 | + // If we have a size, skip the coroutine, we can continue immediately. |
| 204 | + is ImmediateGlideSize -> resolvedSize = size.size |
| 205 | + // Otherwise, we do not want to block the flow while waiting on a size because one or more |
| 206 | + // requests in the chain may have a fixed size, even if the primary request does not. |
| 207 | + // Starting the Glide request right away allows any subrequest that has a fixed size to |
| 208 | + // begin immediately, shaving off some small amount of time. |
| 209 | + is AsyncGlideSize -> scope.launch { |
| 210 | + val localResolvedSize = size.asyncSize() |
| 211 | + val callbacksToNotify: List<SizeReadyCallback> |
| 212 | + synchronized(this) { |
| 213 | + resolvedSize = localResolvedSize |
| 214 | + callbacksToNotify = ArrayList(sizeReadyCallbacks) |
| 215 | + sizeReadyCallbacks.clear() |
| 216 | + } |
| 217 | + callbacksToNotify.forEach { |
| 218 | + it.onSizeReady(localResolvedSize.width, localResolvedSize.height) |
| 219 | + } |
| 220 | + } |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + override fun onStart() {} |
| 225 | + override fun onStop() {} |
| 226 | + override fun onDestroy() {} |
| 227 | + |
| 228 | + override fun onLoadStarted(placeholder: Drawable?) { |
| 229 | + lastResource = null |
| 230 | + scope.trySend(Placeholder(Status.RUNNING, placeholder)) |
| 231 | + } |
| 232 | + |
| 233 | + override fun onLoadFailed(errorDrawable: Drawable?) { |
| 234 | + scope.trySend(Placeholder(Status.FAILED, errorDrawable)) |
| 235 | + } |
| 236 | + |
| 237 | + override fun onResourceReady(resource: ResourceT, transition: Transition<in ResourceT>?) { |
| 238 | + lastResource = resource |
| 239 | + scope.trySend( |
| 240 | + Resource( |
| 241 | + // currentRequest is the entire request state, so we can use it to figure out if this |
| 242 | + // resource is from a thumbnail request (isComplete is false) or the primary request. |
| 243 | + if (currentRequest?.isComplete == true) Status.SUCCEEDED else Status.RUNNING, resource |
| 244 | + ) |
| 245 | + ) |
| 246 | + } |
| 247 | + |
| 248 | + override fun onLoadCleared(placeholder: Drawable?) { |
| 249 | + lastResource = null |
| 250 | + scope.trySend(Placeholder(Status.CLEARED, placeholder)) |
| 251 | + } |
| 252 | + |
| 253 | + override fun getSize(cb: SizeReadyCallback) { |
| 254 | + val localResolvedSize = resolvedSize |
| 255 | + if (localResolvedSize != null) { |
| 256 | + cb.onSizeReady(localResolvedSize.width, localResolvedSize.height) |
| 257 | + return |
| 258 | + } |
| 259 | + |
| 260 | + synchronized(this@FlowTarget) { |
| 261 | + val lockedResolvedSize = resolvedSize |
| 262 | + if (lockedResolvedSize != null) { |
| 263 | + cb.onSizeReady(lockedResolvedSize.width, lockedResolvedSize.height) |
| 264 | + } else { |
| 265 | + sizeReadyCallbacks.add(cb) |
| 266 | + } |
| 267 | + } |
| 268 | + } |
| 269 | + |
| 270 | + override fun removeCallback(cb: SizeReadyCallback) { |
| 271 | + synchronized(this) { |
| 272 | + sizeReadyCallbacks.remove(cb) |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + override fun setRequest(request: Request?) { |
| 277 | + currentRequest = request |
| 278 | + } |
| 279 | + |
| 280 | + override fun getRequest(): Request? { |
| 281 | + return currentRequest |
| 282 | + } |
| 283 | + |
| 284 | + override fun onLoadFailed( |
| 285 | + e: GlideException?, |
| 286 | + model: Any?, |
| 287 | + target: Target<ResourceT>?, |
| 288 | + isFirstResource: Boolean, |
| 289 | + ): Boolean { |
| 290 | + val localLastResource = lastResource |
| 291 | + val localRequest = currentRequest |
| 292 | + if (localLastResource != null && localRequest?.isComplete == false && !localRequest.isRunning) { |
| 293 | + scope.channel.trySend(Resource(Status.FAILED, localLastResource)) |
| 294 | + } |
| 295 | + return false |
| 296 | + } |
| 297 | + |
| 298 | + override fun onResourceReady( |
| 299 | + resource: ResourceT, |
| 300 | + model: Any?, |
| 301 | + target: Target<ResourceT>?, |
| 302 | + dataSource: DataSource?, |
| 303 | + isFirstResource: Boolean, |
| 304 | + ): Boolean { |
| 305 | + return false |
| 306 | + } |
| 307 | +} |
| 308 | + |
| 309 | +private data class Size(val width: Int, val height: Int) |
| 310 | + |
| 311 | +private sealed class ResolvableGlideSize |
| 312 | +private data class ImmediateGlideSize(val size: Size) : ResolvableGlideSize() |
| 313 | +private data class AsyncGlideSize(val asyncSize: suspend () -> Size) : ResolvableGlideSize() |
0 commit comments