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 */ ImageLoaderCallback(T referent)109 public ImageLoaderCallback(T referent) { 110 mWeakReference = new WeakReference<>(referent); 111 } 112 113 /** 114 * Called when bitmap is loaded. 115 */ 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 /** 126 * Called when bitmap is loaded if the weak reference is still valid. 127 */ onBitmapLoaded(T referent, @Nullable Bitmap bitmap)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 */ prefetchBitmap(Context context, final String uriString, final int maxWidth, final int maxHeight)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 loadBitmap(Context context, String uriString, ImageLoaderCallback callback)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 loadBitmap(Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback callback)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 doLoadBitmap(Context context, String uriString, int maxWidth, int maxHeight, ImageLoaderCallback callback, Executor executor)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 loadBitmap(ImageLoaderCallback callback, LoadBitmapTask loadBitmapTask)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 doLoadBitmap(ImageLoaderCallback callback, Executor executor, LoadBitmapTask loadBitmapTask)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 */ isReloadNeeded()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 */ isReloadNeeded(LoadBitmapTask other)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 getFromCache()300 public final ScaledBitmapInfo getFromCache() { 301 return mImageCache.get(mKey); 302 } 303 LoadBitmapTask(Context context, ImageCache imageCache, String key, int maxHeight, int maxWidth)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 doGetBitmapInBackground()323 public abstract ScaledBitmapInfo doGetBitmapInBackground(); 324 325 @Override 326 @Nullable doInBackground(Void... params)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 onPostExecute(ScaledBitmapInfo scaledBitmapInfo)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 getKey()349 public final String getKey() { 350 return mKey; 351 } 352 353 @Override toString()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 { LoadBitmapFromUriTask(Context context, ImageCache imageCache, String uriString, int maxWidth, int maxHeight)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 doGetBitmapInBackground()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 LoadTvInputLogoTask(Context context, ImageCache cache, TvInputInfo info)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 doGetBitmapInBackground()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 */ getTvInputLogoKey(String inputId)409 public static String getTvInputLogoKey(String inputId) { 410 return inputId + "-logo"; 411 } 412 } 413 getMainHandler()414 private static synchronized Handler getMainHandler() { 415 if (sMainHandler == null) { 416 sMainHandler = new Handler(Looper.getMainLooper()); 417 } 418 return sMainHandler; 419 } 420 ImageLoader()421 private ImageLoader() { 422 } 423 } 424