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