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 mMaxHeight >= other.mMaxHeight * 2 || mMaxWidth >= other.mMaxWidth * 2;
    296         }
    297 
    298         @Nullable
    299         public final ScaledBitmapInfo getFromCache() {
    300             return mImageCache.get(mKey);
    301         }
    302 
    303         public LoadBitmapTask(Context context, ImageCache imageCache, String key, int maxHeight,
    304                 int maxWidth) {
    305             if (maxWidth == 0 || maxHeight == 0) {
    306                 throw new IllegalArgumentException(
    307                         "Image size should not be 0. {width=" + maxWidth + ", height=" + maxHeight
    308                                 + "}");
    309             }
    310             mAppContext = context.getApplicationContext();
    311             mKey = key;
    312             mImageCache = imageCache;
    313             mMaxHeight = maxHeight;
    314             mMaxWidth = maxWidth;
    315         }
    316 
    317         /**
    318          * Loads the bitmap returning a possibly scaled down version.
    319          */
    320         @Nullable
    321         @WorkerThread
    322         public abstract ScaledBitmapInfo doGetBitmapInBackground();
    323 
    324         @Override
    325         @Nullable
    326         public final ScaledBitmapInfo doInBackground(Void... params) {
    327             ScaledBitmapInfo bitmapInfo = getFromCache();
    328             if (bitmapInfo != null && !isReloadNeeded()) {
    329                 return bitmapInfo;
    330             }
    331             bitmapInfo = doGetBitmapInBackground();
    332             if (bitmapInfo != null) {
    333                 mImageCache.putIfNeeded(bitmapInfo);
    334             }
    335             return bitmapInfo;
    336         }
    337 
    338         @Override
    339         public final void onPostExecute(ScaledBitmapInfo scaledBitmapInfo) {
    340             if (DEBUG) Log.d(ImageLoader.TAG, "Bitmap is loaded " + mKey);
    341 
    342             for (ImageLoader.ImageLoaderCallback callback : mCallbacks) {
    343                 callback.onBitmapLoaded(scaledBitmapInfo == null ? null : scaledBitmapInfo.bitmap);
    344             }
    345             ImageLoader.sPendingListMap.remove(mKey);
    346         }
    347 
    348         public final String getKey() {
    349             return mKey;
    350         }
    351 
    352         @Override
    353         public String toString() {
    354             return this.getClass().getSimpleName() + "(" + mKey + " " + mMaxWidth + "x" + mMaxHeight
    355                     + ")";
    356         }
    357     }
    358 
    359     private static final class LoadBitmapFromUriTask extends LoadBitmapTask {
    360         private LoadBitmapFromUriTask(Context context, ImageCache imageCache, String uriString,
    361                 int maxWidth, int maxHeight) {
    362             super(context, imageCache, uriString, maxHeight, maxWidth);
    363         }
    364 
    365         @Override
    366         @Nullable
    367         public final ScaledBitmapInfo doGetBitmapInBackground() {
    368             return BitmapUtils
    369                     .decodeSampledBitmapFromUriString(mAppContext, getKey(), mMaxWidth, mMaxHeight);
    370         }
    371     }
    372 
    373     /**
    374      * Loads and caches the logo for a given {@link TvInputInfo}
    375      */
    376     public static final class LoadTvInputLogoTask extends LoadBitmapTask {
    377         private final TvInputInfo mInfo;
    378 
    379         public LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info) {
    380             super(context,
    381                     cache,
    382                     getTvInputLogoKey(info.getId()),
    383                     context.getResources()
    384                             .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size),
    385                     context.getResources()
    386                             .getDimensionPixelSize(R.dimen.channel_banner_input_logo_size)
    387             );
    388             mInfo = info;
    389         }
    390 
    391         @Nullable
    392         @Override
    393         public ScaledBitmapInfo doGetBitmapInBackground() {
    394             Drawable drawable = mInfo.loadIcon(mAppContext);
    395             if (!(drawable instanceof BitmapDrawable)) {
    396                 return null;
    397             }
    398             Bitmap original = ((BitmapDrawable) drawable).getBitmap();
    399             if (original == null) {
    400                 return null;
    401             }
    402             return BitmapUtils.createScaledBitmapInfo(getKey(), original, mMaxWidth, mMaxHeight);
    403         }
    404 
    405         /**
    406          * Returns key of TV input logo.
    407          */
    408         public static String getTvInputLogoKey(String inputId) {
    409             return inputId + "-logo";
    410         }
    411     }
    412 
    413     private static synchronized Handler getMainHandler() {
    414         if (sMainHandler == null) {
    415             sMainHandler = new Handler(Looper.getMainLooper());
    416         }
    417         return sMainHandler;
    418     }
    419 
    420     private ImageLoader() {
    421     }
    422 }
    423