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