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