blob: d2ad0eb1d6a1233a93f38663ce8324cf56f7db25 [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tv.util.images;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.media.tv.TvInputInfo;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
import android.support.annotation.WorkerThread;
import android.util.ArraySet;
import android.util.Log;
import com.android.tv.R;
import com.android.tv.common.concurrent.NamedThreadFactory;
import com.android.tv.util.images.BitmapUtils.ScaledBitmapInfo;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* This class wraps up completing some arbitrary long running work when loading a bitmap. It handles
* things like using a memory cache, running the work in a background thread.
*/
public final class ImageLoader {
private static final String TAG = "ImageLoader";
private static final boolean DEBUG = false;
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// We want at least 2 threads and at most 4 threads in the core pool,
// preferring to have 1 less than the CPU count to avoid saturating
// the CPU with background work
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
private static final int KEEP_ALIVE_SECONDS = 30;
private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader");
private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<>(128);
/**
* An private {@link Executor} that can be used to execute tasks in parallel.
*
* <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask} Since we do a
* lot of concurrent image loading we can exhaust a thread pool. ImageLoader catches the error,
* and just leaves the image blank. However other tasks will fail and crash the application.
*
* <p>Using a separate thread pool prevents image loading from causing other tasks to fail.
*/
private static final Executor IMAGE_THREAD_POOL_EXECUTOR;
static {
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAXIMUM_POOL_SIZE,
KEEP_ALIVE_SECONDS,
TimeUnit.SECONDS,
sPoolWorkQueue,
sThreadFactory);
threadPoolExecutor.allowCoreThreadTimeOut(true);
IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor;
}
private static Handler sMainHandler;
/**
* Handles when image loading is finished.
*
* <p>Use this to prevent leaking an Activity or other Context while image loading is still
* pending. When you extend this class you <strong>MUST NOT</strong> use a non static inner
* class, or the containing object will still be leaked.
*/
@UiThread
public abstract static class ImageLoaderCallback<T> {
private final WeakReference<T> mWeakReference;
/**
* Creates an callback keeping a weak reference to {@code referent}.
*
* <p>If the "referent" is no longer valid, it no longer makes sense to run the callback.
* The referent is the View, or Activity or whatever that actually needs to receive the
* Bitmap. If the referent has been GC, then no need to run the callback.
*/
public ImageLoaderCallback(T referent) {
mWeakReference = new WeakReference<>(referent);
}
/** Called when bitmap is loaded. */
private void onBitmapLoaded(@Nullable Bitmap bitmap) {
T referent = mWeakReference.get();
if (referent != null) {
onBitmapLoaded(referent, bitmap);
} else {
if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone");
}
}
/** Called when bitmap is loaded if the weak reference is still valid. */
public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap);
}
private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>();
/**
* Preload a bitmap image into the cache.
*
* <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading.
*
* <p>This method is thread safe.
*/
public static void prefetchBitmap(
Context context, final String uriString, final int maxWidth, final int maxHeight) {
if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString);
if (Looper.getMainLooper() == Looper.myLooper()) {
doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR);
} else {
final Context appContext = context.getApplicationContext();
getMainHandler()
.post(
() ->
doLoadBitmap(
appContext,
uriString,
maxWidth,
maxHeight,
null,
AsyncTask.SERIAL_EXECUTOR));
}
}
/**
* Load a bitmap image with the cache using a ContentResolver.
*
* <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in the
* cache.
*
* @return {@code true} if the load is complete and the callback is executed.
*/
@UiThread
public static boolean loadBitmap(
Context context, String uriString, ImageLoaderCallback callback) {
return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback);
}
/**
* Load a bitmap image with the cache and resize it with given params.
*
* <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in the
* cache.
*
* @return {@code true} if the load is complete and the callback is executed.
*/
@UiThread
public static boolean loadBitmap(
Context context,
String uriString,
int maxWidth,
int maxHeight,
ImageLoaderCallback callback) {
if (DEBUG) {
Log.d(TAG, "loadBitmap() " + uriString);
}
return doLoadBitmap(
context, uriString, maxWidth, maxHeight, callback, IMAGE_THREAD_POOL_EXECUTOR);
}
private static boolean doLoadBitmap(
Context context,
String uriString,
int maxWidth,
int maxHeight,
ImageLoaderCallback callback,
Executor executor) {
// Check the cache before creating a Task. The cache will be checked again in doLoadBitmap
// but checking a cache is much cheaper than creating an new task.
ImageCache imageCache = ImageCache.getInstance();
ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) {
if (callback != null) {
callback.onBitmapLoaded(bitmapInfo.bitmap);
}
return true;
}
return doLoadBitmap(
callback,
executor,
new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight));
}
/**
* Load a bitmap image with the cache and resize it with given params.
*
* <p>The LoadBitmapTask will be executed on a non ui thread.
*
* @return {@code true} if the load is complete and the callback is executed.
*/
@UiThread
public static boolean loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask) {
if (DEBUG) {
Log.d(TAG, "loadBitmap() " + loadBitmapTask);
}
return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask);
}
/** @return {@code true} if the load is complete and the callback is executed. */
@UiThread
private static boolean doLoadBitmap(
ImageLoaderCallback callback, Executor executor, LoadBitmapTask loadBitmapTask) {
ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache();
boolean needToReload = loadBitmapTask.isReloadNeeded();
if (bitmapInfo != null && !needToReload) {
if (callback != null) {
callback.onBitmapLoaded(bitmapInfo.bitmap);
}
return true;
}
LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey());
if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) {
// The image loading is already scheduled and is large enough.
if (callback != null) {
existingTask.mCallbacks.add(callback);
}
} else {
if (callback != null) {
loadBitmapTask.mCallbacks.add(callback);
}
sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask);
try {
loadBitmapTask.executeOnExecutor(executor);
} catch (RejectedExecutionException e) {
Log.e(TAG, "Failed to create new image loader", e);
sPendingListMap.remove(loadBitmapTask.getKey());
}
}
return false;
}
/**
* Loads and caches a a possibly scaled down version of a bitmap.
*
* <p>Implement {@link #doGetBitmapInBackground} to do the actual loading.
*/
public abstract static class LoadBitmapTask extends AsyncTask<Void, Void, ScaledBitmapInfo> {
protected final Context mAppContext;
protected final int mMaxWidth;
protected final int mMaxHeight;
private final Set<ImageLoaderCallback> mCallbacks = new ArraySet<>();
private final ImageCache mImageCache;
private final String mKey;
/**
* Returns true if a reload is needed compared to current results in the cache or false if
* there is not match in the cache.
*/
private boolean isReloadNeeded() {
ScaledBitmapInfo bitmapInfo = getFromCache();
boolean needToReload =
bitmapInfo != null && bitmapInfo.needToReload(mMaxWidth, mMaxHeight);
if (DEBUG) {
if (needToReload) {
Log.d(
TAG,
"Bitmap needs to be reloaded. {"
+ "originalWidth="
+ bitmapInfo.bitmap.getWidth()
+ ", originalHeight="
+ bitmapInfo.bitmap.getHeight()
+ ", reqWidth="
+ mMaxWidth
+ ", reqHeight="
+ mMaxHeight
+ "}");
}
}
return needToReload;
}
/** Checks if a reload would be needed if the results of other was available. */
private boolean isReloadNeeded(LoadBitmapTask other) {
return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2)
|| (other.mMaxWidth != Integer.MAX_VALUE && mMaxWidth >= other.mMaxWidth * 2);
}
@Nullable
public final ScaledBitmapInfo getFromCache() {
return mImageCache.get(mKey);
}
public LoadBitmapTask(
Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth) {
if (maxWidth == 0 || maxHeight == 0) {
throw new IllegalArgumentException(
"Image size should not be 0. {width="
+ maxWidth
+ ", height="
+ maxHeight
+ "}");
}
mAppContext = context.getApplicationContext();
mKey = key;
mImageCache = imageCache;
mMaxHeight = maxHeight;
mMaxWidth = maxWidth;
}
/** Loads the bitmap returning a possibly scaled down version. */
@Nullable
@WorkerThread
public abstract ScaledBitmapInfo doGetBitmapInBackground();
@Override
@Nullable
public final ScaledBitmapInfo doInBackground(Void... params) {
ScaledBitmapInfo bitmapInfo = getFromCache();
if (bitmapInfo != null && !isReloadNeeded()) {
return bitmapInfo;
}
bitmapInfo = doGetBitmapInBackground();
if (bitmapInfo != null) {
mImageCache.putIfNeeded(bitmapInfo);
}
return bitmapInfo;
}
@Override
public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) {
if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey);
for (ImageLoader.ImageLoaderCallback callback : mCallbacks) {
callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap);
}
ImageLoader.sPendingListMap.remove(mKey);
}
public final String getKey() {
return mKey;
}
@Override
public String toString() {
return this.getClass().getSimpleName()
+ "("
+ mKey
+ " "
+ mMaxWidth
+ "x"
+ mMaxHeight
+ ")";
}
}
private static final class LoadBitmapFromUriTask extends LoadBitmapTask {
private LoadBitmapFromUriTask(
Context context,
ImageCache imageCache,
String uriString,
int maxWidth,
int maxHeight) {
super(context, imageCache, uriString, maxHeight, maxWidth);
}
@Override
@Nullable
public final ScaledBitmapInfo doGetBitmapInBackground() {
return BitmapUtils.decodeSampledBitmapFromUriString(
mAppContext, getKey(), mMaxWidth, mMaxHeight);
}
}
/** Loads and caches the logo for a given {@link TvInputInfo} */
public static final class LoadTvInputLogoTask extends LoadBitmapTask {
private final TvInputInfo mInfo;
public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
super(
context,
cache,
getTvInputLogoKey(info.getId()),
context.getResources()
.getDimensionPixelSize(R.dimen.channel_banner_input_logo_size),
context.getResources()
.getDimensionPixelSize(R.dimen.channel_banner_input_logo_size));
mInfo = info;
}
@Nullable
@Override
public ScaledBitmapInfo doGetBitmapInBackground() {
Drawable drawable = mInfo.loadIcon(mAppContext);
Bitmap bm = drawable instanceof BitmapDrawable
? ((BitmapDrawable) drawable).getBitmap()
: BitmapUtils.drawableToBitmap(drawable);
return bm == null
? null
: BitmapUtils.createScaledBitmapInfo(getKey(), bm, mMaxWidth, mMaxHeight);
}
/** Returns key of TV input logo. */
public static String getTvInputLogoKey(String inputId) {
return inputId + "-logo";
}
}
private static synchronized Handler getMainHandler() {
if (sMainHandler == null) {
sMainHandler = new Handler(Looper.getMainLooper());
}
return sMainHandler;
}
private ImageLoader() {}
}