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<>(128);
     68 
     69     /**
     70      * An private {@link Executor} that can be used to execute tasks in parallel.
     71      *
     72      * <p>{@code IMAGE_THREAD_POOL_EXECUTOR} setting are copied from {@link AsyncTask}
     73      * Since we do a lot of concurrent image loading we can exhaust a thread pool.
     74      * ImageLoader catches the error, and just leaves the image blank.
     75      * However other tasks will fail and crash the application.
     76      *
     77      * <p>Using a separate thread pool prevents image loading from causing other tasks to fail.
     78      */
     79     private static final Executor IMAGE_THREAD_POOL_EXECUTOR;
     80 
     81     static {
     82         ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
     83                 MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, sPoolWorkQueue,
     84                 sThreadFactory);
     85         threadPoolExecutor.allowCoreThreadTimeOut(true);
     86         IMAGE_THREAD_POOL_EXECUTOR = threadPoolExecutor;
     87     }
     88 
     89     private static Handler sMainHandler;
     90 
     91     /**
     92      * Handles when image loading is finished.
     93      *
     94      * <p>Use this to prevent leaking an Activity or other Context while image loading is
     95      *  still pending. When you extend this class you <strong>MUST NOT</strong> use a non static
     96      *  inner class, or the containing object will still be leaked.
     97      */
     98     @UiThread
     99     public static abstract class ImageLoaderCallback<T> {
    100         private final WeakReference<T> mWeakReference;
    101 
    102         /**
    103          * Creates an callback keeping a weak reference to {@code referent}.
    104          *
    105          * <p> If the "referent" is no longer valid, it no longer makes sense to run the
    106          * callback. The referent is the View, or Activity or whatever that actually needs to
    107          * receive the Bitmap.  If the referent has been GC, then no need to run the callback.
    108          */
    109         public ImageLoaderCallback(T referent) {
    110             mWeakReference = new WeakReference<>(referent);
    111         }
    112 
    113         /**
    114          * Called when bitmap is loaded.
    115          */
    116         private void onBitmapLoaded(@Nullable Bitmap bitmap) {
    117             T referent = mWeakReference.get();
    118             if (referent != null) {
    119                 onBitmapLoaded(referent, bitmap);
    120             } else {
    121                 if (DEBUG) Log.d(TAG, "onBitmapLoaded not called because weak reference is gone");
    122             }
    123         }
    124 
    125         /**
    126          * Called when bitmap is loaded if the weak reference is still valid.
    127          */
    128         public abstract void onBitmapLoaded(T referent, @Nullable Bitmap bitmap);
    129     }
    130 
    131     private static final Map<String, LoadBitmapTask> sPendingListMap = new HashMap<>();
    132 
    133     /**
    134      * Preload a bitmap image into the cache.
    135      *
    136      * <p>Not to make heavy CPU load, AsyncTask.SERIAL_EXECUTOR is used for the image loading.
    137      * <p>This method is thread safe.
    138      */
    139     public static void prefetchBitmap(Context context, final String uriString, final int maxWidth,
    140             final int maxHeight) {
    141         if (DEBUG) Log.d(TAG, "prefetchBitmap() " + uriString);
    142         if (Looper.getMainLooper() == Looper.myLooper()) {
    143             doLoadBitmap(context, uriString, maxWidth, maxHeight, null, AsyncTask.SERIAL_EXECUTOR);
    144         } else {
    145             final Context appContext = context.getApplicationContext();
    146             getMainHandler().post(new Runnable() {
    147                 @Override
    148                 @MainThread
    149                 public void run() {
    150                     // Calling from the main thread prevents a ConcurrentModificationException
    151                     // in LoadBitmapTask.onPostExecute
    152                     doLoadBitmap(appContext, uriString, maxWidth, maxHeight, null,
    153                             AsyncTask.SERIAL_EXECUTOR);
    154                 }
    155             });
    156         }
    157     }
    158 
    159     /**
    160      * Load a bitmap image with the cache using a ContentResolver.
    161      *
    162      * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
    163      * the cache.
    164      *
    165      * @return {@code true} if the load is complete and the callback is executed.
    166      */
    167     @UiThread
    168     public static boolean loadBitmap(Context context, String uriString,
    169             ImageLoaderCallback callback) {
    170         return loadBitmap(context, uriString, Integer.MAX_VALUE, Integer.MAX_VALUE, callback);
    171     }
    172 
    173     /**
    174      * Load a bitmap image with the cache and resize it with given params.
    175      *
    176      * <p><b>Note</b> that the callback will be called synchronously if the bitmap already is in
    177      * the cache.
    178      *
    179      * @return {@code true} if the load is complete and the callback is executed.
    180      */
    181     @UiThread
    182     public static boolean loadBitmap(Context context, String uriString, int maxWidth, int maxHeight,
    183             ImageLoaderCallback callback) {
    184         if (DEBUG) {
    185             Log.d(TAG, "loadBitmap() " + uriString);
    186         }
    187         return doLoadBitmap(context, uriString, maxWidth, maxHeight, callback,
    188                 IMAGE_THREAD_POOL_EXECUTOR);
    189     }
    190 
    191     private static boolean doLoadBitmap(Context context, String uriString,
    192             int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor) {
    193         // Check the cache before creating a Task.  The cache will be checked again in doLoadBitmap
    194         // but checking a cache is much cheaper than creating an new task.
    195         ImageCache imageCache = ImageCache.getInstance();
    196         ScaledBitmapInfo bitmapInfo = imageCache.get(uriString);
    197         if (bitmapInfo != null && !bitmapInfo.needToReload(maxWidth, maxHeight)) {
    198             if (callback != null) {
    199                 callback.onBitmapLoaded(bitmapInfo.bitmap);
    200             }
    201             return true;
    202         }
    203         return doLoadBitmap(callback, executor,
    204                 new LoadBitmapFromUriTask(context, imageCache, uriString, maxWidth, maxHeight));
    205     }
    206 
    207     /**
    208      * Load a bitmap image with the cache and resize it with given params.
    209      *
    210      * <p>The LoadBitmapTask will be executed on a non ui thread.
    211      *
    212      * @return {@code true} if the load is complete and the callback is executed.
    213      */
    214     @UiThread
    215     public static boolean loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask) {
    216         if (DEBUG) {
    217             Log.d(TAG, "loadBitmap() " + loadBitmapTask);
    218         }
    219         return doLoadBitmap(callback, IMAGE_THREAD_POOL_EXECUTOR, loadBitmapTask);
    220     }
    221 
    222     /**
    223      * @return {@code true} if the load is complete and the callback is executed.
    224      */
    225     @UiThread
    226     private static boolean doLoadBitmap(ImageLoaderCallback callback, Executor executor,
    227             LoadBitmapTask loadBitmapTask) {
    228         ScaledBitmapInfo bitmapInfo = loadBitmapTask.getFromCache();
    229         boolean needToReload = loadBitmapTask.isReloadNeeded();
    230         if (bitmapInfo != null && !needToReload) {
    231             if (callback != null) {
    232                 callback.onBitmapLoaded(bitmapInfo.bitmap);
    233             }
    234             return true;
    235         }
    236         LoadBitmapTask existingTask = sPendingListMap.get(loadBitmapTask.getKey());
    237         if (existingTask != null && !loadBitmapTask.isReloadNeeded(existingTask)) {
    238             // The image loading is already scheduled and is large enough.
    239             if (callback != null) {
    240                 existingTask.mCallbacks.add(callback);
    241             }
    242         } else {
    243             if (callback != null) {
    244                 loadBitmapTask.mCallbacks.add(callback);
    245             }
    246             sPendingListMap.put(loadBitmapTask.getKey(), loadBitmapTask);
    247             try {
    248                 loadBitmapTask.executeOnExecutor(executor);
    249             } catch (RejectedExecutionException e) {
    250                 Log.e(TAG, "Failed to create new image loader", e);
    251                 sPendingListMap.remove(loadBitmapTask.getKey());
    252             }
    253         }
    254         return false;
    255     }
    256 
    257     /**
    258      * Loads and caches a a possibly scaled down version of a bitmap.
    259      *
    260      * <p>Implement {@link #doGetBitmapInBackground} to do the actual loading.
    261      */
    262     public static abstract class LoadBitmapTask extends AsyncTask<Void, Void, ScaledBitmapInfo> {
    263         protected final Context mAppContext;
    264         protected final int mMaxWidth;
    265         protected final int mMaxHeight;
    266         private final Set<ImageLoaderCallback> mCallbacks = new ArraySet<>();
    267         private final ImageCache mImageCache;
    268         private final String mKey;
    269 
    270         /**
    271          * Returns true if a reload is needed compared to current results in the cache or false if
    272          * there is not match in the cache.
    273          */
    274         private boolean isReloadNeeded() {
    275             ScaledBitmapInfo bitmapInfo = getFromCache();
    276             boolean needToReload = bitmapInfo != null && bitmapInfo
    277                     .needToReload(mMaxWidth, mMaxHeight);
    278             if (DEBUG) {
    279                 if (needToReload) {
    280                     Log.d(TAG, "Bitmap needs to be reloaded. {"
    281                             + "originalWidth=" + bitmapInfo.bitmap.getWidth()
    282                             + ", originalHeight=" + bitmapInfo.bitmap.getHeight()
    283                             + ", reqWidth=" + mMaxWidth
    284                             + ", reqHeight=" + mMaxHeight
    285                             + "}");
    286                 }
    287             }
    288             return needToReload;
    289         }
    290 
    291         /**
    292          * Checks if a reload would be needed if the results of other was available.
    293          */
    294         private boolean isReloadNeeded(LoadBitmapTask other) {
    295             return (other.mMaxHeight != Integer.MAX_VALUE && mMaxHeight >= other.mMaxHeight * 2)
    296                     || (other.mMaxWidth != Integer.MAX_VALUE && 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                     getTvInputLogoKey(info.getId()),
    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          * Returns key of TV input logo.
    408          */
    409         public static String getTvInputLogoKey(String inputId) {
    410             return inputId + "-logo";
    411         }
    412     }
    413 
    414     private static synchronized Handler getMainHandler() {
    415         if (sMainHandler == null) {
    416             sMainHandler = new Handler(Looper.getMainLooper());
    417         }
    418         return sMainHandler;
    419     }
    420 
    421     private ImageLoader() {
    422     }
    423 }
    424