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
Feature Request: Hooks to know when image loading started & finished for all requests #1440
Comments
Heh, I just mentioned this to Sam yesterday. I settled on reflective access to |
This comment was marked as outdated.
This comment was marked as outdated.
Hi there, Thanks |
This comment was marked as outdated.
This comment was marked as outdated.
You're right for the implementation of ReflectionTools. I'm not used to use reflection but I made them easily. Thanks for letting us know. I'll looking forward for a more correct implementation. Thanks! |
Hi @dral3x, here's the updated version. It's crazy complex, I hope it works for you as well. Watch out for the package declarations, due to visibility of Glide internals it was easier to put some classes there, then reflecting on everything. I'm using my own implementation of SLF4J bindings similar to https://www.slf4j.org/android/, but that should work too; or rewrite the logs to match your own logging framework. We need to register it before any Glide loads are launched, otherwise it may not pick up on some loads. I should probably distribute it as a gradle dep, if I have time in the future. public class MyActivityRule<T extends Activity> extends ActivityTestRule<T> {
private final GlideIdlingResource glideIdler = new GlideIdlingResource();
// super ctors omitted
@Override protected void beforeActivityLaunched() {
Espresso.registerIdlingResources(glideIdler);
super.beforeActivityLaunched();
}
@Override protected void afterActivityFinished() {
super.afterActivityFinished();
Espresso.unregisterIdlingResources(glideIdler);
}
} package net.twisterrob.android.test.espresso.idle;
import java.lang.reflect.Field;
import org.slf4j.*;
import android.support.annotation.Nullable;
import android.support.test.InstrumentationRegistry;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.*;
public class GlideIdlingResource extends AsyncIdlingResource {
private static final Logger LOG = LoggerFactory.getLogger(GlideIdlingResource.class);
private static final Field mEngine = getEngineField();
private final Runnable callTransitionToIdle = new Runnable() {
@Override public void run() {
transitionToIdle();
}
};
private EngineIdleWatcher watcher;
private Engine currentEngine;
@Override public String getName() {
return "Glide";
}
@Override protected boolean isIdle() {
// Glide is a singleton, hence Engine should be too; just lazily initialize when needed.
// In case Glide is replaced, this will still work.
Engine engine = getEngine();
if (currentEngine != engine) {
if (watcher != null) {
watcher.unsubscribe(callTransitionToIdle);
}
EngineIdleWatcher oldWatcher = watcher;
watcher = new EngineIdleWatcher(engine);
watcher.setLogEvents(isVerbose());
if (currentEngine != null) {
LOG.warn("Engine changed from {}({}) to {}({})", currentEngine, oldWatcher, engine, watcher);
}
currentEngine = engine;
}
return isIdleCore();
}
private boolean isIdleCore() {
return watcher.isIdle();
}
@Override protected void waitForIdleAsync() {
watcher.subscribe(callTransitionToIdle);
}
@Override protected void transitionToIdle() {
watcher.unsubscribe(callTransitionToIdle);
super.transitionToIdle();
}
private @Nullable Engine getEngine() {
try {
Glide glide = Glide.get(InstrumentationRegistry.getTargetContext());
return (Engine)mEngine.get(glide);
} catch (IllegalAccessException ex) {
throw new IllegalStateException("Glide Engine cannot be found", ex);
}
}
private static Field getEngineField() {
try {
Field field = Glide.class.getDeclaredField("engine");
field.setAccessible(true);
return field;
} catch (Exception ex) {
throw new IllegalStateException("Glide Engine cannot be found", ex);
}
}
} package com.bumptech.glide.load.engine;
import java.util.*;
import org.slf4j.*;
import com.bumptech.glide.request.ResourceCallback;
import static net.twisterrob.java.utils.ReflectionTools.*;
/**
* This class is the bridge between package private stuff and the world, don't try to inline it.
*/
public class EngineIdleWatcher implements EngineExternalLifecycle.PhaseCallbacks {
private static final Logger LOG = LoggerFactory.getLogger("EngineIdleWatcher");
private final Set<Runnable> idleCallbacks = new HashSet<>();
private final EngineExternalLifecycle lifecycle;
private boolean logEvents = false;
public EngineIdleWatcher(Engine engine) {
lifecycle = new EngineExternalLifecycle(engine, this);
}
public void setLogEvents(boolean logEvents) {
this.logEvents = logEvents;
}
public void subscribe(Runnable callback) {
idleCallbacks.add(callback);
}
public void unsubscribe(Runnable callback) {
idleCallbacks.remove(callback);
}
public boolean isIdle() {
Collection<EngineJob> jobs = lifecycle.getJobs();
Collection<? extends ResourceCallback> active = lifecycle.getActive();
//LOG.trace("{}/{}: active={}", this, lifecycle, active.size());
return jobs.isEmpty() && active.isEmpty();
}
private void tryToCallBack() {
if (isIdle()) {
for (Runnable callback : idleCallbacks) {
callback.run();
}
}
}
@Override public void starting(Engine engine, EngineKey key, EngineJob job) {
if (logEvents) {
LOG.trace("{}.starting {}: {}", this, job, id(key));
}
}
@Override public void finishing(Engine engine, EngineKey key, EngineJob job) {
if (logEvents) {
LOG.trace("{}.finishing {}: {}", this, job, id(key));
}
}
@Override public void cancelled(Engine engine, EngineKey key, EngineJob job) {
if (logEvents) {
LOG.trace("{}.cancelled {}: {}", this, job, id(key));
}
tryToCallBack();
}
@Override public void loadSuccess(Engine engine, EngineKey key, EngineJob job) {
if (logEvents) {
LOG.trace("{}.loadSuccess {}: {}", this, job, id(key));
}
tryToCallBack();
}
@Override public void loadFailure(Engine engine, EngineKey key, EngineJob job) {
if (logEvents) {
LOG.trace("{}.loadFailure {}: {}", this, job, id(key));
}
tryToCallBack();
}
private Object id(EngineKey key) {
return get(key, "id") + "[" + get(key, "width") + "x" + get(key, "height") + "]";
}
} package com.bumptech.glide.load.engine;
import java.lang.reflect.Field;
import java.util.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import android.support.annotation.*;
import com.bumptech.glide.load.Key;
import com.bumptech.glide.request.ResourceCallback;
import com.bumptech.glide.util.Util;
import static net.twisterrob.java.utils.ReflectionTools.*;
public class EngineExternalLifecycle {
private static final Field mJobs = getJobsField();
private static final Field mCbs = getCallbacksField();
private final PhaseCallbacks callback;
private final Engine engine;
private final EngineJobsReplacement replacementJobs = new EngineJobsReplacement();
private final Collection<LoadEndListener> endListeners = new HashSet<>();
public EngineExternalLifecycle(@NonNull Engine engine, PhaseCallbacks callback) {
this.callback = callback;
this.engine = engine;
associate();
}
public Collection<EngineJob> getJobs() {
return Collections.unmodifiableCollection(replacementJobs.values());
}
public Collection<ResourceCallback> getActive() {
return Collections.<ResourceCallback>unmodifiableCollection(endListeners);
}
private void starting(EngineKey key, EngineJob job) {
LoadEndListener endListener = setSignUp(key, job);
assertTrue(endListeners.add(endListener));
assertThat(replacementJobs, hasEntry((Key)key, job));
callback.starting(engine, key, job);
//noinspection ConstantConditions it's a primitive type, they won't be null
if (job.isCancelled()
|| (Boolean)get(job, "hasResource")
|| (Boolean)get(job, "hasException")) {
// catch up in case they're already done when we're created
finishing(key, job);
// call the corresponding callback method
job.addCallback(endListener);
}
}
private void finishing(EngineKey key, EngineJob job) {
assertThat(replacementJobs, not(hasKey((Key)key)));
assertThat(replacementJobs, not(hasValue(job)));
assertThat(endListeners, hasItem(getSignUp(job)));
if (job.isCancelled()) {
assertTrue(endListeners.remove(getSignUp(job)));
callback.cancelled(engine, key, job);
} else {
callback.finishing(engine, key, job);
}
}
private void loadSuccess(LoadEndListener signup) {
assertTrue(endListeners.remove(signup));
callback.loadSuccess(engine, signup.key, signup.job);
}
private void loadFailure(LoadEndListener signup) {
assertTrue(endListeners.remove(signup));
callback.loadFailure(engine, signup.key, signup.job);
}
/** {@code Engine.jobs = new EngineJobsReplacement(Engine.jobs)} */
private void associate() {
try {
@SuppressWarnings("unchecked")
Map<Key, EngineJob> original = (Map<Key, EngineJob>)mJobs.get(engine);
if (original instanceof EngineJobsReplacement) {
EngineJobsReplacement replacement = (EngineJobsReplacement)original;
throw new IllegalStateException(
engine + " already has an external lifecycle: " + replacement.getAssociation());
}
assertThat(replacementJobs, is(anEmptyMap()));
replacementJobs.putAll(original);
mJobs.set(engine, replacementJobs);
} catch (Exception ex) {
throw new IllegalStateException("Cannot hack Engine.jobs", ex);
}
}
/** {@code job.cbs += new LoadEndListener()} */
private LoadEndListener setSignUp(EngineKey key, EngineJob job) {
Util.assertMainThread();
try {
@SuppressWarnings("unchecked")
List<ResourceCallback> cbs = (List<ResourceCallback>)mCbs.get(job);
if (cbs instanceof ExtraItemList) {
throw new IllegalStateException(job + " already being listened to by " + cbs);
}
LoadEndListener extra = new LoadEndListener(key, job);
cbs = new ExtraItemList(cbs, extra);
mCbs.set(job, cbs);
return extra;
} catch (IllegalAccessException ex) {
throw new IllegalStateException("Cannot hack EngineJob.cbs", ex);
}
}
/** {@code job.cbs.iterator().last()} */
private LoadEndListener getSignUp(EngineJob job) {
Util.assertMainThread();
try {
@SuppressWarnings("unchecked")
List<ResourceCallback> cbs = (List<ResourceCallback>)mCbs.get(job);
if (cbs instanceof ExtraItemList) {
return (LoadEndListener)((ExtraItemList)cbs).extra;
} else {
throw new IllegalStateException(job + " doesn't have an end listener");
}
} catch (IllegalAccessException ex) {
throw new IllegalStateException("Cannot hack EngineJob.cbs", ex);
}
}
private static Field getJobsField() {
try {
Field field;
field = Engine.class.getDeclaredField("jobs");
field.setAccessible(true);
return field;
} catch (Exception ex) {
throw new IllegalStateException("Glide Engine jobs cannot be found", ex);
}
}
private static Field getCallbacksField() {
try {
Field field;
field = EngineJob.class.getDeclaredField("cbs");
field.setAccessible(true);
return field;
} catch (Exception ex) {
throw new IllegalStateException("Glide EngineJobs callbacks cannot be found", ex);
}
}
@Override public String toString() {
return String.format(Locale.ROOT, "%s jobs=%d, listeners=%d, callback=%s",
engine, replacementJobs.size(), endListeners.size(), callback);
}
/**
* Appends and extra item to the end of the list, but only when iterating. Size and other queries won't report it.
* This is only useful with {@link EngineJob}s, because we know that only {@link #add}, {@link #remove},
* {@link #isEmpty} and {@link #iterator} are going to be called.
*/
private class ExtraItemList extends ArrayList<ResourceCallback> {
private final ResourceCallback extra;
public ExtraItemList(Collection<ResourceCallback> callbacks, ResourceCallback extra) {
super(callbacks);
this.extra = extra;
}
@NonNull @Override public Iterator<ResourceCallback> iterator() {
Iterator<ResourceCallback> extraIt =
android.support.test.espresso.core.deps.guava.collect.Iterators.singletonIterator(extra);
return android.support.test.espresso.core.deps.guava.collect.Iterators.concat(super.iterator(), extraIt);
}
}
/**
* Callbacks whenever jobs are added or removed. This helps to "modify" the code of Engine externally.
*/
private class EngineJobsReplacement extends HashMap<Key, EngineJob> {
@Override public EngineJob put(Key key, EngineJob value) {
assertNull(super.put(key, value));
starting((EngineKey)key, value);
return null;
}
@Override public EngineJob remove(Object key) {
EngineJob removed = super.remove(key);
finishing((EngineKey)key, removed);
return removed;
}
public EngineExternalLifecycle getAssociation() {
return EngineExternalLifecycle.this;
}
}
/**
* Called back at the end of a job when all other resources are notified.
*/
private class LoadEndListener implements ResourceCallback {
private final EngineKey key;
private final EngineJob job;
public LoadEndListener(EngineKey key, EngineJob job) {
this.key = key;
this.job = job;
}
@Override public void onResourceReady(Resource<?> resource) {
// this "target" won't ever be cleared, so let's clean up real quick after ourselves
((EngineResource<?>)resource).release();
loadSuccess(this);
}
@Override public void onException(Exception e) {
loadFailure(this);
}
@Override public String toString() {
return job + ": " + get(key, "id")
+ "[" + get(key, "width") + "x" + get(key, "height") + "]";
}
}
@UiThread
public interface PhaseCallbacks {
/**
* Job created, but no callbacks are added yet, and the job will be started right after this.
* @see Engine#load
*/
//EngineJob engineJob = engineJobFactory.build(key, isMemoryCacheable);
//jobs.put(key, engineJob); // starting
//engineJob.addCallback(cb);
//engineJob.start(runnable);
void starting(Engine engine, EngineKey key, EngineJob job);
/**
* Job is finishing, it already has a resource or an exception,
* but the result is not broadcast yet to the callbacks.
* Either {@link #loadSuccess} or {@link #loadFailure} will be called right after this.
* @see Engine#onEngineJobComplete
* @see EngineJob#handleResultOnMainThread()
* @see EngineJob#handleExceptionOnMainThread()
*/
//hasResource = true; or hasException = true;
//listener.onEngineJobComplete(key, ?); -> jobs.remove(key);
void finishing(Engine engine, EngineKey key, EngineJob job);
/**
* Job is cancelled, it has no resource nor exception.
* No more interaction are expected with this job after this.
* @see EngineJob#cancel()
* @see Engine#onEngineJobCancelled
*/
//isCancelled = true;
//listener.onEngineJobCancelled(this, key); -> jobs.remove(key);
void cancelled(Engine engine, EngineKey key, EngineJob job);
/**
* All the callbacks have been notified for {@link ResourceCallback#onResourceReady}.
* Load is considered fully finished, resources are delivered to targets.
* No further interaction will, not even when be clearing the target,
* because the job already has resource or exception and removeCallback won't call cancel.
* @see EngineJob#handleResultOnMainThread()
*/
//hasResource = true;
//engineResource.acquire();
//listener.onEngineJobComplete(key, engineResource); -> jobs.remove(key);
//for (ResourceCallback cb : cbs) {
// engineResource.acquire(); // this is released in LoadEndListener
// cb.onResourceReady(engineResource);
//}
//engineResource.release();
void loadSuccess(Engine engine, EngineKey key, EngineJob job);
/**
* All the callbacks have been notified for {@link ResourceCallback#onException}.
* Load is considered fully finished, exception is delivered to targets.
* No further interaction will, not even when be clearing the target,
* because the job already has resource or exception and removeCallback won't call cancel.
* @see EngineJob#handleExceptionOnMainThread()
*/
//hasException = true;
//listener.onEngineJobComplete(key, null); -> jobs.remove(key);
//for (ResourceCallback cb : cbs) cb.onException(exception);
void loadFailure(Engine engine, EngineKey key, EngineJob job);
}
} package net.twisterrob.android.test.espresso.idle;
import org.slf4j.*;
import android.support.annotation.MainThread;
import android.support.test.espresso.IdlingResource;
public abstract class AsyncIdlingResource implements IdlingResource {
private final Logger LOG = LoggerFactory.getLogger(getClass());
private ResourceCallback resourceCallback;
private boolean verbose = false;
protected AsyncIdlingResource() {
//beVerbose();
}
public AsyncIdlingResource beVerbose() {
verbose = true;
return this;
}
protected boolean isVerbose() {
return verbose;
}
@MainThread
@Override public final boolean isIdleNow() {
boolean idle = isIdle();
if (verbose || !idle) {
LOG.trace("{}.isIdleNow: {}", getName(), idle);
}
if (idle) {
transitionToIdle(false);
} else {
waitForIdleAsync();
}
return idle;
}
//@AnyThread, don't enable yet, there are still annoying warnings
protected void transitionToIdle() {
transitionToIdle(true);
}
private void transitionToIdle(boolean log) {
if (log) {
LOG.trace("{}.onTransitionToIdle with {}", getName(), resourceCallback);
}
if (resourceCallback != null) {
resourceCallback.onTransitionToIdle();
}
}
@MainThread
protected abstract boolean isIdle();
@MainThread
protected abstract void waitForIdleAsync();
public ResourceCallback getIdleTransitionCallback() {
return resourceCallback;
}
@MainThread
@Override public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
this.resourceCallback = resourceCallback;
}
} package net.twisterrob.java.utils;
import java.lang.reflect.*;
import javax.annotation.*;
import org.slf4j.*;
public class ReflectionTools {
private static final Logger LOG = LoggerFactory.getLogger(ReflectionTools.class);
@SuppressWarnings("unchecked")
public static <T> T get(@Nonnull Object object, @Nonnull String fieldName) {
try {
Field field = findDeclaredField(object.getClass(), fieldName);
field.setAccessible(true);
return (T)field.get(object);
} catch (Exception ex) {
//noinspection ConstantConditions prevent NPE when object is null, even though it was declared not null
Class<?> clazz = object != null? object.getClass() : null;
LOG.warn("Cannot read field {} of ({}){}", fieldName, clazz, object, ex);
}
return null;
}
} |
I am having the same issue here seems a very complex hack, but is there a way to just set all of the |
@poldz123 hmm, that may also work. You can use Note that my first shared impl here is hacking those services, but it didn't work, because those become idle, before the actual Targets receive the resources; so I couldn't check if an image was actually loaded. But please do try, and let me know if it works. |
@TWiStErRob Yes this does work by specifying the |
Does Espresso do automatic idle detection on all executor services? |
Yes it does wait on all of the executor as long you have different instances of it. Having a wrapper for the ExecutorService will let you have control on it. Which you can then add a CountingIdlingResource to the wrapper to let espresso wait until glide has finishes/idle. |
Ah, so it's not automatic, that sounds like my first solution, but without reflection and manual queue counting. Thanks for sharing it, it's a valuable idea! |
As of Espresso 3.0.0, IdlingThreadPoolExecutor is available. By using it, the following code makes Espresso wait until loading images has finished! // must belong to this package because GlideExecutor(ExecutorService) has package private scope...
package com.bumptech.glide.load.engine.executor;
@GlideModule
public class IdlingGlideModule extends AppGlideModule {
@SuppressLint("VisibleForTests")
@Override
public void applyOptions(Context context, GlideBuilder builder) {
builder.setDiskCacheExecutor(new GlideExecutor(
new IdlingThreadPoolExecutor("GlideDiskCacheExecutor",
1,
1,
0,
TimeUnit.MICROSECONDS,
new PriorityBlockingQueue<>(),
// GlideExecutor.DefaultThreadFactory is private and can't access.
Executors.defaultThreadFactory())
));
builder.setSourceExecutor(new GlideExecutor(
new IdlingThreadPoolExecutor("GlideSourceExecutor",
GlideExecutor.calculateBestThreadCount(),
GlideExecutor.calculateBestThreadCount(),
0,
TimeUnit.MICROSECONDS,
new PriorityBlockingQueue<>(),
// GlideExecutor.DefaultThreadFactory is private and can't access.
Executors.defaultThreadFactory())
));
}
} @TWiStErRob |
See glide/library/test/src/test/java/com/bumptech/glide/load/engine/executor/MockGlideExecutor.java Line 17 in b7c2b13
I believe we haven't released this publicly, but if there's demand we could work toward that. Or if you're interested in sending a pull request to add the build configuration and target for a mocks/fakes library for Glide I'd appreciate that. |
@sjudd Thanks for your reply. |
Yeah you'd add a new subproject that exposes a set of Fakes for testing Glide. You could probably put it at the top level and call it "Fakes". |
@sumio NB: when I wrote my |
@sumio are you working on the PR, this feature will sure be useful for espresso tests. |
@AnwarShahriar @sjudd @TWiStErRob |
Sorry for hijacking the thread but is there a way to get a callback in non-reflection way, when all images are loaded? |
I'd like to share my solution that looks clean and seems to be working well:
val GLIDE_REQUEST_COUNTER = CountingIdlingResource("glide-requests", true)
IdlingRegistry.getInstance().register(GLIDE_REQUEST_COUNTER) You might consider having one idling resource per Activity/Fragment/RequestManager so that your tests wouldn't have to wait for screens in background but that's gonna be trickier to manage.
class IdlingResourceTarget<T>(
private val delegate: Target<T>,
idlingResource: CountingIdlingResource
) : Target<T> {
private var finished = false
private val idlingResourceWrapper = IdlingResourceWrapper(idlingResource)
private val sizeCallback = object : SizeReadyCallback {
override fun onSizeReady(width: Int, height: Int) {
// Ignore if onResourceReady, onLoadFailed or onLoadCleared has been called already
if (!finished) {
idlingResourceWrapper.isLoading = true
}
delegate.removeCallback(this)
}
}
override fun onLoadStarted(placeholder: Drawable?) {
delegate.onLoadStarted(placeholder)
finished = false
// onLoadStarted can be called when the corresponding view is not visible on the screen
// so we wait for the view to be laid out.
delegate.getSize(sizeCallback)
}
override fun onLoadFailed(errorDrawable: Drawable?) {
delegate.onLoadFailed(errorDrawable)
finished = true
idlingResourceWrapper.isLoading = false
}
override fun onResourceReady(resource: T, transition: Transition<in T>?) {
delegate.onResourceReady(resource, transition)
finished = true
idlingResourceWrapper.isLoading = false
}
override fun onLoadCleared(placeholder: Drawable?) {
delegate.onLoadCleared(placeholder)
finished = true
idlingResourceWrapper.isLoading = false
}
override fun getRequest(): Request? {
return delegate.request
}
override fun setRequest(request: Request?) {
delegate.request = request
}
override fun getSize(cb: SizeReadyCallback) {
delegate.getSize(cb)
}
override fun removeCallback(cb: SizeReadyCallback) {
delegate.removeCallback(cb)
}
override fun onStart() {
delegate.onStart()
}
override fun onStop() {
delegate.onStop()
}
override fun onDestroy() {
delegate.onDestroy()
}
private class IdlingResourceWrapper(private val idlingResource: CountingIdlingResource) {
var isLoading: Boolean = false
set(value) {
if (field != value) {
field = value
if (value) {
idlingResource.increment()
} else {
idlingResource.decrement()
}
}
}
}
}
request.into(IdlingResourceTarget(DrawableImageViewTarget(view), GLIDE_REQUEST_COUNTER))
Hope this helps. |
Updated the code in #1440 (comment) with a bugfix for some corner cases. |
glide 3.7.0
Not sure if we already have way to know when image loading started and finished for all the request, even when it loads from cache.
I would like to use using idling resource for android testing which make sure images are loaded and app is idle now.
The text was updated successfully, but these errors were encountered: