Skip to content

Commit 7d9e1a3

Browse files
committedAug 16, 2022
Add an experimental integration with Flows
Currently the only Flow will emit the status of the load along with the current placeholder or resource, clearing the request when the Flow ends. In future versions we might consider making clearing the target optional so that resources could be used after the flow is over. We could also expose some additional helper functions to make it easier to work with the first run of a flow, or to exclude placeholders. For now this seems like a reasonable starting point.
1 parent 24e4b0f commit 7d9e1a3

File tree

11 files changed

+883
-5
lines changed

11 files changed

+883
-5
lines changed
 

‎gradle.properties

+5-2
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,16 @@ ANDROID_X_TEST_CORE_VERSION=1.4.0
5757
ANDROID_X_TEST_JUNIT_VERSION=1.1.3
5858
ANDROID_X_TEST_RULES_VERSION=1.4.0
5959
ANDROID_X_TEST_RUNNER_VERSION=1.4.0
60+
ANDROID_X_TEST_CORE_KTX_VERSION=1.4.0
61+
ANDROID_X_TEST_JUNIT_KTX_VERSION=1.1.3
6062
ANDROID_X_TRACING_VERSION=1.0.0
6163
ANDROID_X_VECTOR_DRAWABLE_ANIMATED_VERSION=1.1.0
6264
ANDROID_X_CORE_KTX_VERSION=1.8.0
6365
ANDROID_X_LIFECYCLE_KTX_VERSION=2.4.1
6466

6567
# org.jetbrains versions
66-
JETBRAINS_KOTLINX_COROUTINES_VERSION=1.6.3
68+
JETBRAINS_KOTLINX_COROUTINES_VERSION=1.6.4
69+
JETBRAINS_KOTLINX_COROUTINES_TEST_VERSION=1.6.4
6770
JETBRAINS_KOTLIN_VERSION=1.7.0
6871
JETBRAINS_KOTLIN_TEST_VERSION=1.7.0
6972

@@ -86,6 +89,6 @@ MOCKWEBSERVER_VERSION=3.0.0-RC1
8689
OK_HTTP_VERSION=3.10.0
8790
PMD_VERSION=6.0.0
8891
ROBOLECTRIC_VERSION=4.8.1
89-
TRUTH_VERSION=0.45
92+
TRUTH_VERSION=1.1.3
9093
VIOLATIONS_PLUGIN_VERSION=1.8
9194
VOLLEY_VERSION=1.2.0

‎integration/ktx/build.gradle

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
plugins {
2+
id 'com.android.library'
3+
id 'org.jetbrains.kotlin.android'
4+
}
5+
6+
android {
7+
compileSdk COMPILE_SDK_VERSION as int
8+
9+
defaultConfig {
10+
minSdk MIN_SDK_VERSION as int
11+
targetSdk TARGET_SDK_VERSION as int
12+
13+
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
14+
consumerProguardFiles "consumer-rules.pro"
15+
}
16+
17+
buildTypes {
18+
release {
19+
minifyEnabled false
20+
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
21+
}
22+
}
23+
compileOptions {
24+
sourceCompatibility JavaVersion.VERSION_1_7
25+
targetCompatibility JavaVersion.VERSION_1_7
26+
}
27+
kotlinOptions {
28+
jvmTarget = '1.8'
29+
}
30+
}
31+
32+
// Enable strict mode, but exclude tests.
33+
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
34+
if (!it.name.contains("Test")) {
35+
kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict"
36+
}
37+
}
38+
39+
dependencies {
40+
api project(":library")
41+
implementation "androidx.core:core-ktx:$ANDROID_X_CORE_KTX_VERSION"
42+
implementation "androidx.test:core-ktx:$ANDROID_X_TEST_CORE_KTX_VERSION"
43+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$JETBRAINS_KOTLINX_COROUTINES_VERSION"
44+
testImplementation "androidx.test.ext:junit-ktx:$ANDROID_X_TEST_JUNIT_KTX_VERSION"
45+
testImplementation "androidx.test.ext:junit:$ANDROID_X_TEST_JUNIT_VERSION"
46+
testImplementation "org.robolectric:robolectric:$ROBOLECTRIC_VERSION"
47+
testImplementation "androidx.test:runner:$ANDROID_X_TEST_RUNNER_VERSION"
48+
testImplementation "junit:junit:$JUNIT_VERSION"
49+
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$JETBRAINS_KOTLINX_COROUTINES_TEST_VERSION"
50+
testImplementation "com.google.truth:truth:$TRUTH_VERSION"
51+
androidTestImplementation "androidx.test.ext:junit:$ANDROID_X_TEST_JUNIT_VERSION"
52+
}
53+
54+
apply from: "${rootProject.projectDir}/scripts/upload.gradle"

‎integration/ktx/gradle.properties

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
POM_NAME=Glide Kotlin Extensions
2+
POM_ARTIFACT_ID=ktx
3+
POM_PACKAGING=aar
4+
POM_DESCRIPTION=An integration library to improve Kotlin interop with Glide
5+
6+
VERSION_MAJOR=1
7+
VERSION_MINOR=0
8+
VERSION_PATCH=0
9+
VERSION_NAME=1.0.0-alpha.0-SNAPSHOT
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest package="com.bumptech.glide.integration.ktx" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Functions that give us access to some of Glide's non-public internals to make the flows API a bit
3+
* better.
4+
*/
5+
package com.bumptech.glide
6+
7+
import com.bumptech.glide.request.RequestListener
8+
import com.bumptech.glide.request.target.Target
9+
10+
internal fun RequestBuilder<*>.requestManager() = this.requestManager
11+
12+
internal fun <ResourceT, TargetAndRequestListenerT> RequestBuilder<ResourceT>.intoDirect(
13+
targetAndRequestListener: TargetAndRequestListenerT,
14+
) where TargetAndRequestListenerT : Target<ResourceT>,
15+
TargetAndRequestListenerT : RequestListener<ResourceT> {
16+
this.into(targetAndRequestListener, targetAndRequestListener) {
17+
it.run()
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
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()

‎integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt

+472
Large diffs are not rendered by default.

‎library/src/main/java/com/bumptech/glide/RequestBuilder.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
import com.bumptech.glide.signature.AndroidResourceSignature;
3737
import com.bumptech.glide.util.Executors;
3838
import com.bumptech.glide.util.Preconditions;
39-
import com.bumptech.glide.util.Synthetic;
4039
import com.bumptech.glide.util.Util;
4140
import java.io.File;
4241
import java.net.URL;
@@ -102,6 +101,10 @@ protected RequestBuilder(
102101
apply(requestManager.getDefaultRequestOptions());
103102
}
104103

104+
RequestManager getRequestManager() {
105+
return requestManager;
106+
}
107+
105108
@SuppressLint("CheckResult")
106109
@SuppressWarnings("PMD.ConstructorCallsOverridableMethod")
107110
protected RequestBuilder(Class<TranscodeType> transcodeClass, RequestBuilder<?> other) {
@@ -772,7 +775,6 @@ public <Y extends Target<TranscodeType>> Y into(@NonNull Y target) {
772775
}
773776

774777
@NonNull
775-
@Synthetic
776778
<Y extends Target<TranscodeType>> Y into(
777779
@NonNull Y target,
778780
@Nullable RequestListener<TranscodeType> targetListener,

‎library/src/main/java/com/bumptech/glide/manager/RequestTracker.java

+2
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ public void pauseRequests() {
9595
public void pauseAllRequests() {
9696
isPaused = true;
9797
for (Request request : Util.getSnapshot(requests)) {
98+
// TODO(judds): Failed requests return false from isComplete(). They're still restarted in
99+
// resumeRequests, but they're not cleared here. We should probably clear all requests here?
98100
if (request.isRunning() || request.isComplete()) {
99101
request.clear();
100102
pendingRequests.add(request);

‎library/test/build.gradle

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ dependencies {
1313
testImplementation "com.squareup.okhttp3:mockwebserver:${MOCKWEBSERVER_VERSION}"
1414
testImplementation "androidx.test:core:${ANDROID_X_TEST_CORE_VERSION}"
1515
testImplementation "androidx.test.ext:junit:${ANDROID_X_TEST_JUNIT_VERSION}"
16-
testImplementation "androidx.test:runner:${ANDROID_X_TEST_RUNNER_VERSION}"}
16+
testImplementation "androidx.test:runner:${ANDROID_X_TEST_RUNNER_VERSION}"
17+
}
1718

1819
tasks.withType(JavaCompile) {
1920
options.fork = true

‎settings.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ include ':integration:avif'
2626
include ':integration:concurrent'
2727
include ':integration:cronet'
2828
include ':integration:gifencoder'
29+
include ':integration:ktx'
2930
include ':integration:okhttp'
3031
include ':integration:okhttp3'
3132
include ':integration:recyclerview'

0 commit comments

Comments
 (0)
Please sign in to comment.