Home | History | Annotate | Download | only in util
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.tv.util;
     18 
     19 import android.content.Context;
     20 import android.graphics.Bitmap;
     21 import android.graphics.drawable.BitmapDrawable;
     22 import android.graphics.drawable.Drawable;
     23 import android.media.tv.TvInputInfo;
     24 import android.os.AsyncTask;
     25 import android.os.Handler;
     26 import android.os.Looper;
     27 import android.support.annotation.MainThread;
     28 import android.support.annotation.Nullable;
     29 import android.support.annotation.UiThread;
     30 import android.support.annotation.WorkerThread;
     31 import android.util.ArraySet;
     32 import android.util.Log;
     33 
     34 import com.android.tv.R;
     35 import com.android.tv.util.BitmapUtils.ScaledBitmapInfo;
     36 
     37 import java.lang.ref.WeakReference;
     38 import java.util.HashMap;
     39 import java.util.Map;
     40 import java.util.Set;
     41 import java.util.concurrent.BlockingQueue;
     42 import java.util.concurrent.Executor;
     43 import java.util.concurrent.LinkedBlockingQueue;
     44 import java.util.concurrent.RejectedExecutionException;
     45 import java.util.concurrent.ThreadFactory;
     46 import java.util.concurrent.ThreadPoolExecutor;
     47 import java.util.concurrent.TimeUnit;
     48 
     49 /**
     50  * This class wraps up completing some arbitrary long running work when loading a bitmap. It
     51  * handles things like using a memory cache, running the work in a background thread.
     52  */
     53 public final class ImageLoader {
     54     private static final String TAG = "ImageLoader";
     55     private static final boolean DEBUG = false;
     56 
     57     private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
     58     // We want at least 2 threads and at most 4 threads in the core pool,
     59     // preferring to have 1 less than the CPU count to avoid saturating
     60     // the CPU with background work
     61     private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
     62     private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
     63     private static final int KEEP_ALIVE_SECONDS = 30;
     64 
     65     private static final ThreadFactory sThreadFactory = new NamedThreadFactory("ImageLoader");
     66 
     67     private static final BlockingQueue<Runnable> sPoolWorkQueue = new LinkedBlockingQueue<Runnable>(
     68             128);
     69 
     70     /**
     71      * An private {@link Executor} that can be used to execute tasks in parallel.
     72      *
     73      * <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask}
     74      * Since we do a lot of concurrent image loading we can exhaust a thread pool.
     75      * ImageLoader catches the error, and just leaves the image blank.
     76      * However other tasks will fail and crash the application.
     77      *
     78      * <p>Using a separate thread pool prevents image loading from causing other tasks to fail.
     79      */
     80     private static final Executor IMAGE_THREAD_POOL_EXECUTOR;
     81 
     82     static {
     83         ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
     84                 MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, sPoolWorkQueue,
     85                 sThreadFactory);
     86         threadPoolExecutor.allowCoreThreadTimeOut(true);
     87         IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor;
     88     }
     89 
     90     private static Handler sMainHandler;
     91 
     92     /**
     93      * Handles when image loading is finished.
     94      *
     95      * <p>Use this to prevent leaking an Activity or other Context while image loading is
     96      *  still pending. When you extend this class you <strong>MUST NOT</strong> use a non static
     97      *  inner class, or the containing object will still be leaked.
     98      */
     99     @UiThread
    100     public static abstract class ImageLoaderCallback<T> {
    101         private final WeakReference<T> mWeakReference;
    102 
    103         /**
    104          * Creates an callback keeping a weak reference to {@code referent}.
    105          *
    106          * <p> If the "referent" is no longer valid, it no longer makes sense to run the
    107          * callback. The referent is the View, or Activity or whatever that actually needs to
    108          * receive the Bitmap.  If the referent has been GC, then no need to run the callback.
    109          */
    110         public ImageLoaderCallback(T referent) {
    111             mWeakReference = new WeakReference<>(referent);
    112         }
    113 
    114         /**
    115          * Called when bitmap is loaded.
    116          */
    117         private void onBitmapLoaded(@Nullable Bitmap bitmap) {
    118             T referent = mWeakReference.get();
    119             if (referent != null) {
    120                 onBitmapLoaded(referent, bitmap);
    121             } else {
    122                 if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone");
    123             }
    124         }
    125 
    126         /**
    127          * Called when bitmap is loaded if the weak reference is still valid.
    128          */
    129         public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap);
    130     }
    131 
    132     private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>();
    133 
    134     /**
    135      * Preload a bitmap image into the cache.
    136      *
    137      * <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading.
    138      * <p>This method is thread safe.
    139      */
    140     public static void prefetchBitmap(Context context, final String uriString, final int maxWidth,
    141             final int maxHeight) {
    142         if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString);
    143         if (Looper.getMainLooper() == Looper.myLooper()) {
    144             doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR);
    145         } else {
    146             final Context appContext = context.getApplicationContext();
    147             getMainHandler().post(new Runnable() {
    148                 @Override
    149                 @MainThread
    150                 public void run() {
    151                     // Calling from the main thread prevents a ConcurrentModificationException
    152                     // in LoadBitmapTask.onPostExecute
    153                     doLoadBitmap(appContext, uriString, maxWidth, maxHeight, null,
    154                             AsyncTask.SERIAL_EXECUTOR);
    155                 }
    156             });
    157         }
    158     }
    159 
    160     /**
    161      * Load a bitmap image with the cache using a ContentResolver.
    162      *
    163      * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
    164      * the cache.
    165      *
    166      * @return {@code true} if the load is complete and the callback is executed.
    167      */
    168     @UiThread
    169     public static boolean loadBitmap(Context context, String uriString,
    170             ImageLoaderCallback callback) {
    171         return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback);
    172     }
    173 
    174     /**
    175      * Load a bitmap image with the cache and resize it with given params.
    176      *
    177      * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
    178      * the cache.
    179      *
    180      * @return {@code true} if the load is complete and the callback is executed.
    181      */
    182     @UiThread
    183     public static boolean loadBitmap(Context context, String uriString, int maxWidth, int maxHeight,
    184             ImageLoaderCallback callback) {
    185         if (DEBUG) {
    186             Log.d(TAG, "loadBitmap() " + uriString);
    187         }
    188         return doLoadBitmap(context, uriString, maxWidth, maxHeight, callback,
    189                 IMAGE_THREAD_POOL_EXECUTOR);
    190     }
    191 
    192     private static boolean doLoadBitmap(Context context, String uriString,
    193             int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor) {
    194         // Check the cache before creating a Task.  The cache will be checked again in doLoadBitmap
    195         // but checking a cache is much cheaper than creating an new task.
    196         ImageCache imageCache = ImageCache.getInstance();
    197         ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
    198         if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) {
    199             if (callback != null) {
    200                 callback.onBitmapLoaded(bitmapInfo.bitmap);
    201             }
    202             return true;
    203         }
    204         return doLoadBitmap(callback, executor,
    205                 new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight));
    206     }
    207 
    208     /**
    209      * Load a bitmap image with the cache and resize it with given params.
    210      *
    211      * <p>The LoadBitmapTask will be executed on a non ui thread.
    212      *
    213      * @return {@code true} if the load is complete and the callback is executed.
    214      */
    215     @UiThread
    216     public static boolean loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask) {
    217         if (DEBUG) {
    218             Log.d(TAG, "loadBitmap() " + loadBitmapTask);
    219         }
    220         return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask);
    221     }
    222 
    223     /**
    224      * @return {@code true} if the load is complete and the callback is executed.
    225      */
    226     @UiThread
    227     private static boolean doLoadBitmap(ImageLoaderCallback callback, Executor executor,
    228             LoadBitmapTask loadBitmapTask) {
    229         ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache();
    230         boolean needToReload = loadBitmapTask.isReloadNeeded();
    231         if (bitmapInfo != null && !needToReload) {
    232             if (callback != null) {
    233                 callback.onBitmapLoaded(bitmapInfo.bitmap);
    234             }
    235             return true;
    236         }
    237         LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey());
    238         if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) {
    239             // The image loading is already scheduled and is large enough.
    240             if (callback != null) {
    241                 existingTask.mCallbacks.add(callback);
    242             }
    243         } else {
    244             if (callback != null) {
    245                 loadBitmapTask.mCallbacks.add(callback);
    246             }
    247             sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask);
    248             try {
    249                 loadBitmapTask.executeOnExecutor(executor);
    250             } catch (RejectedExecutionException e) {
    251                 Log.e(TAG, "Failed to create new image loader", e);
    252                 sPendingListMap.remove(loadBitmapTask.getKey());
    253             }
    254         }
    255         return false;
    256     }
    257 
    258     /**
    259      * Loads and caches a a possibly scaled down version of a bitmap.
    260      *
    261      * <p>Implement {@link #doGetBitmapInBackground} to do the actual loading.
    262      */
    263     public static abstract class LoadBitmapTask extends AsyncTask<Void, Void, ScaledBitmapInfo> {
    264         protected final Context mAppContext;
    265         protected final int mMaxWidth;
    266         protected final int mMaxHeight;
    267         private final Set<ImageLoaderCallback> mCallbacks = new ArraySet<>();
    268         private final ImageCache mImageCache;
    269         private final String mKey;
    270 
    271         /**
    272          * Returns true if a reload is needed compared to current results in the cache or false if
    273          * there is not match in the cache.
    274          */
    275         private boolean isReloadNeeded() {
    276             ScaledBitmapInfo bitmapInfo = getFromCache();
    277             boolean needToReload = bitmapInfo != null && bitmapInfo
    278                     .needToReload(mMaxWidth, mMaxHeight);
    279             if (DEBUG) {
    280                 if (needToReload) {
    281                     Log.d(TAG, "Bitmap needs to be reloaded. {"
    282                             + "originalWidth=" + bitmapInfo.bitmap.getWidth()
    283                             + ", originalHeight=" + bitmapInfo.bitmap.getHeight()
    284                             + ", reqWidth=" + mMaxWidth
    285                             + ", reqHeight=" + mMaxHeight
    286                             + "}");
    287                 }
    288             }
    289             return needToReload;
    290         }
    291 
    292         /**
    293          * Checks if a reload would be needed if the results of other was available.
    294          */
    295         private boolean isReloadNeeded(LoadBitmapTask other) {
    296             return mMaxHeight >= other.mMaxHeight * 2 || mMaxWidth >= other.mMaxWidth * 2;
    297         }
    298 
    299         @Nullable
    300         public final ScaledBitmapInfo getFromCache() {
    301             return mImageCache.get(mKey);
    302         }
    303 
    304         public LoadBitmapTask(Context context, ImageCache imageCache, String key, int maxHeight,
    305                 int maxWidth) {
    306             if (maxWidth == 0 || maxHeight == 0) {
    307                 throw new IllegalArgumentException(
    308                         "Image size should not be 0. {width=" + maxWidth + ", height=" + maxHeight
    309                                 + "}");
    310             }
    311             mAppContext = context.getApplicationContext();
    312             mKey = key;
    313             mImageCache = imageCache;
    314             mMaxHeight = maxHeight;
    315             mMaxWidth = maxWidth;
    316         }
    317 
    318         /**
    319          * Loads the bitmap returning a possibly scaled down version.
    320          */
    321         @Nullable
    322         @WorkerThread
    323         public abstract ScaledBitmapInfo doGetBitmapInBackground();
    324 
    325         @Override
    326         @Nullable
    327         public final ScaledBitmapInfo doInBackground(Void... params) {
    328             ScaledBitmapInfo bitmapInfo = getFromCache();
    329             if (bitmapInfo != null && !isReloadNeeded()) {
    330                 return bitmapInfo;
    331             }
    332             bitmapInfo = doGetBitmapInBackground();
    333             if (bitmapInfo != null) {
    334                 mImageCache.putIfNeeded(bitmapInfo);
    335             }
    336             return bitmapInfo;
    337         }
    338 
    339         @Override
    340         public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) {
    341             if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey);
    342 
    343             for (ImageLoader.ImageLoaderCallback callback : mCallbacks) {
    344                 callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap);
    345             }
    346             ImageLoader.sPendingListMap.remove(mKey);
    347         }
    348 
    349         public final String getKey() {
    350             return mKey;
    351         }
    352 
    353         @Override
    354         public String toString() {
    355             return this.getClass().getSimpleName() + "(" + mKey + " " + mMaxWidth + "x" + mMaxHeight
    356                     + ")";
    357         }
    358     }
    359 
    360     private static final class LoadBitmapFromUriTask extends LoadBitmapTask {
    361         private LoadBitmapFromUriTask(Context context, ImageCache imageCache, String uriString,
    362                 int maxWidth, int maxHeight) {
    363             super(context, imageCache, uriString, maxHeight, maxWidth);
    364         }
    365 
    366         @Override
    367         @Nullable
    368         public final ScaledBitmapInfo doGetBitmapInBackground() {
    369             return BitmapUtils
    370                     .decodeSampledBitmapFromUriString(mAppContext, getKey(), mMaxWidth, mMaxHeight);
    371         }
    372     }
    373 
    374     /**
    375      * Loads and caches the logo for a given {@link TvInputInfo}
    376      */
    377     public static final class LoadTvInputLogoTask extends LoadBitmapTask {
    378         private final TvInputInfo mInfo;
    379 
    380         public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
    381             super(context,
    382                     cache,
    383                     info.getId() + "-logo",
    384                     context.getResources()
    385                             .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size),
    386                     context.getResources()
    387                             .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size)
    388             );
    389             mInfo = info;
    390         }
    391 
    392         @Nullable
    393         @Override
    394         public ScaledBitmapInfo doGetBitmapInBackground() {
    395             Drawable drawable = mInfo.loadIcon(mAppContext);
    396             if (!(drawable instanceof BitmapDrawable)) {
    397                 return null;
    398             }
    399             Bitmap original = ((BitmapDrawable) drawable).getBitmap();
    400             if (original == null) {
    401                 return null;
    402             }
    403             return BitmapUtils.createScaledBitmapInfo(getKey(), original, mMaxWidth, mMaxHeight);
    404         }
    405     }
    406 
    407     private static synchronized Handler getMainHandler() {
    408         if (sMainHandler == null) {
    409             sMainHandler = new Handler(Looper.getMainLooper());
    410         }
    411         return sMainHandler;
    412     }
    413 
    414     private ImageLoader() {
    415     }
    416 }
    417