1package com.davemorrissey.labs.subscaleview.decoder; 2 3import android.app.ActivityManager; 4import android.content.ContentResolver; 5import android.content.Context; 6import android.content.pm.PackageManager; 7import android.content.res.AssetFileDescriptor; 8import android.content.res.AssetManager; 9import android.content.res.Resources; 10import android.graphics.Bitmap; 11import android.graphics.BitmapFactory; 12import android.graphics.BitmapRegionDecoder; 13import android.graphics.Point; 14import android.graphics.Rect; 15import android.net.Uri; 16import android.os.Build; 17import android.support.annotation.Keep; 18import android.text.TextUtils; 19import android.util.Log; 20 21import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView; 22 23import java.io.File; 24import java.io.FileFilter; 25import java.io.InputStream; 26import java.util.List; 27import java.util.Map; 28import java.util.concurrent.ConcurrentHashMap; 29import java.util.concurrent.Executor; 30import java.util.concurrent.Semaphore; 31import java.util.concurrent.atomic.AtomicBoolean; 32import java.util.concurrent.locks.ReadWriteLock; 33import java.util.concurrent.locks.ReentrantReadWriteLock; 34import java.util.regex.Pattern; 35 36import static android.content.Context.ACTIVITY_SERVICE; 37 38/** 39 * <p> 40 * An implementation of {@link ImageRegionDecoder} using a pool of {@link BitmapRegionDecoder}s, 41 * to provide true parallel loading of tiles. This is only effective if parallel loading has been 42 * enabled in the view by calling {@link com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView#setExecutor(Executor)} 43 * with a multi-threaded {@link Executor} instance. 44 * </p><p> 45 * One decoder is initialised when the class is initialised. This is enough to decode base layer tiles. 46 * Additional decoders are initialised when a subregion of the image is first requested, which indicates 47 * interaction with the view. Creation of additional encoders stops when {@link #allowAdditionalDecoder(int, long)} 48 * returns false. The default implementation takes into account the file size, number of CPU cores, 49 * low memory status and a hard limit of 4. Extend this class to customise this. 50 * </p><p> 51 * <b>WARNING:</b> This class is highly experimental and not proven to be stable on a wide range of 52 * devices. You are advised to test it thoroughly on all available devices, and code your app to use 53 * {@link SkiaImageRegionDecoder} on old or low powered devices you could not test. 54 * </p> 55 */ 56public class SkiaPooledImageRegionDecoder implements ImageRegionDecoder { 57 58 private static final String TAG = SkiaPooledImageRegionDecoder.class.getSimpleName(); 59 60 private static boolean debug = false; 61 62 private DecoderPool decoderPool = new DecoderPool(); 63 private final ReadWriteLock decoderLock = new ReentrantReadWriteLock(true); 64 65 private static final String FILE_PREFIX = "file://"; 66 private static final String ASSET_PREFIX = FILE_PREFIX + "/android_asset/"; 67 private static final String RESOURCE_PREFIX = ContentResolver.SCHEME_ANDROID_RESOURCE + "://"; 68 69 private final Bitmap.Config bitmapConfig; 70 71 private Context context; 72 private Uri uri; 73 74 private long fileLength = Long.MAX_VALUE; 75 private final Point imageDimensions = new Point(0, 0); 76 private final AtomicBoolean lazyInited = new AtomicBoolean(false); 77 78 @Keep 79 @SuppressWarnings("unused") 80 public SkiaPooledImageRegionDecoder() { 81 this(null); 82 } 83 84 @SuppressWarnings({"WeakerAccess", "SameParameterValue"}) 85 public SkiaPooledImageRegionDecoder(Bitmap.Config bitmapConfig) { 86 Bitmap.Config globalBitmapConfig = SubsamplingScaleImageView.getPreferredBitmapConfig(); 87 if (bitmapConfig != null) { 88 this.bitmapConfig = bitmapConfig; 89 } else if (globalBitmapConfig != null) { 90 this.bitmapConfig = globalBitmapConfig; 91 } else { 92 this.bitmapConfig = Bitmap.Config.RGB_565; 93 } 94 } 95 96 /** 97 * Controls logging of debug messages. All instances are affected. 98 * @param debug true to enable debug logging, false to disable. 99 */ 100 @Keep 101 @SuppressWarnings("unused") 102 public static void setDebug(boolean debug) { 103 SkiaPooledImageRegionDecoder.debug = debug; 104 } 105 106 /** 107 * Initialises the decoder pool. This method creates one decoder on the current thread and uses 108 * it to decode the bounds, then spawns an independent thread to populate the pool with an 109 * additional three decoders. The thread will abort if {@link #recycle()} is called. 110 */ 111 @Override 112 public Point init(final Context context, final Uri uri) throws Exception { 113 this.context = context; 114 this.uri = uri; 115 initialiseDecoder(); 116 return this.imageDimensions; 117 } 118 119 /** 120 * Initialises extra decoders for as long as {@link #allowAdditionalDecoder(int, long)} returns 121 * true and the pool has not been recycled. 122 */ 123 private void lazyInit() { 124 if (lazyInited.compareAndSet(false, true) && fileLength < Long.MAX_VALUE) { 125 debug("Starting lazy init of additional decoders"); 126 Thread thread = new Thread() { 127 @Override 128 public void run() { 129 while (decoderPool != null && allowAdditionalDecoder(decoderPool.size(), fileLength)) { 130 // New decoders can be created while reading tiles but this read lock prevents 131 // them being initialised while the pool is being recycled. 132 try { 133 if (decoderPool != null) { 134 long start = System.currentTimeMillis(); 135 debug("Starting decoder"); 136 initialiseDecoder(); 137 long end = System.currentTimeMillis(); 138 debug("Started decoder, took " + (end - start) + "ms"); 139 } 140 } catch (Exception e) { 141 // A decoder has already been successfully created so we can ignore this 142 debug("Failed to start decoder: " + e.getMessage()); 143 } 144 } 145 } 146 }; 147 thread.start(); 148 } 149 } 150 151 /** 152 * Initialises a new {@link BitmapRegionDecoder} and adds it to the pool, unless the pool has 153 * been recycled while it was created. 154 */ 155 private void initialiseDecoder() throws Exception { 156 String uriString = uri.toString(); 157 BitmapRegionDecoder decoder; 158 long fileLength = Long.MAX_VALUE; 159 if (uriString.startsWith(RESOURCE_PREFIX)) { 160 Resources res; 161 String packageName = uri.getAuthority(); 162 if (context.getPackageName().equals(packageName)) { 163 res = context.getResources(); 164 } else { 165 PackageManager pm = context.getPackageManager(); 166 res = pm.getResourcesForApplication(packageName); 167 } 168 169 int id = 0; 170 List<String> segments = uri.getPathSegments(); 171 int size = segments.size(); 172 if (size == 2 && segments.get(0).equals("drawable")) { 173 String resName = segments.get(1); 174 id = res.getIdentifier(resName, "drawable", packageName); 175 } else if (size == 1 && TextUtils.isDigitsOnly(segments.get(0))) { 176 try { 177 id = Integer.parseInt(segments.get(0)); 178 } catch (NumberFormatException ignored) { 179 } 180 } 181 try { 182 AssetFileDescriptor descriptor = context.getResources().openRawResourceFd(id); 183 fileLength = descriptor.getLength(); 184 } catch (Exception e) { 185 // Pooling disabled 186 } 187 decoder = BitmapRegionDecoder.newInstance(context.getResources().openRawResource(id), false); 188 } else if (uriString.startsWith(ASSET_PREFIX)) { 189 String assetName = uriString.substring(ASSET_PREFIX.length()); 190 try { 191 AssetFileDescriptor descriptor = context.getAssets().openFd(assetName); 192 fileLength = descriptor.getLength(); 193 } catch (Exception e) { 194 // Pooling disabled 195 } 196 decoder = BitmapRegionDecoder.newInstance(context.getAssets().open(assetName, AssetManager.ACCESS_RANDOM), false); 197 } else if (uriString.startsWith(FILE_PREFIX)) { 198 decoder = BitmapRegionDecoder.newInstance(uriString.substring(FILE_PREFIX.length()), false); 199 try { 200 File file = new File(uriString); 201 if (file.exists()) { 202 fileLength = file.length(); 203 } 204 } catch (Exception e) { 205 // Pooling disabled 206 } 207 } else { 208 InputStream inputStream = null; 209 try { 210 ContentResolver contentResolver = context.getContentResolver(); 211 inputStream = contentResolver.openInputStream(uri); 212 decoder = BitmapRegionDecoder.newInstance(inputStream, false); 213 try { 214 AssetFileDescriptor descriptor = contentResolver.openAssetFileDescriptor(uri, "r"); 215 if (descriptor != null) { 216 fileLength = descriptor.getLength(); 217 } 218 } catch (Exception e) { 219 // Stick with MAX_LENGTH 220 } 221 } finally { 222 if (inputStream != null) { 223 try { inputStream.close(); } catch (Exception e) { /* Ignore */ } 224 } 225 } 226 } 227 228 this.fileLength = fileLength; 229 this.imageDimensions.set(decoder.getWidth(), decoder.getHeight()); 230 decoderLock.writeLock().lock(); 231 try { 232 if (decoderPool != null) { 233 decoderPool.add(decoder); 234 } 235 } finally { 236 decoderLock.writeLock().unlock(); 237 } 238 } 239 240 /** 241 * Acquire a read lock to prevent decoding overlapping with recycling, then check the pool still 242 * exists and acquire a decoder to load the requested region. There is no check whether the pool 243 * currently has decoders, because it's guaranteed to have one decoder after {@link #init(Context, Uri)} 244 * is called and be null once {@link #recycle()} is called. In practice the view can't call this 245 * method until after {@link #init(Context, Uri)}, so there will be no blocking on an empty pool. 246 */ 247 @Override 248 public Bitmap decodeRegion(Rect sRect, int sampleSize) { 249 debug("Decode region " + sRect + " on thread " + Thread.currentThread().getName()); 250 if (sRect.width() < imageDimensions.x || sRect.height() < imageDimensions.y) { 251 lazyInit(); 252 } 253 decoderLock.readLock().lock(); 254 try { 255 if (decoderPool != null) { 256 BitmapRegionDecoder decoder = decoderPool.acquire(); 257 try { 258 // Decoder can't be null or recycled in practice 259 if (decoder != null && !decoder.isRecycled()) { 260 BitmapFactory.Options options = new BitmapFactory.Options(); 261 options.inSampleSize = sampleSize; 262 options.inPreferredConfig = bitmapConfig; 263 Bitmap bitmap = decoder.decodeRegion(sRect, options); 264 if (bitmap == null) { 265 throw new RuntimeException("Skia image decoder returned null bitmap - image format may not be supported"); 266 } 267 return bitmap; 268 } 269 } finally { 270 if (decoder != null) { 271 decoderPool.release(decoder); 272 } 273 } 274 } 275 throw new IllegalStateException("Cannot decode region after decoder has been recycled"); 276 } finally { 277 decoderLock.readLock().unlock(); 278 } 279 } 280 281 /** 282 * Holding a read lock to avoid returning true while the pool is being recycled, this returns 283 * true if the pool has at least one decoder available. 284 */ 285 @Override 286 public synchronized boolean isReady() { 287 return decoderPool != null && !decoderPool.isEmpty(); 288 } 289 290 /** 291 * Wait until all read locks held by {@link #decodeRegion(Rect, int)} are released, then recycle 292 * and destroy the pool. Elsewhere, when a read lock is acquired, we must check the pool is not null. 293 */ 294 @Override 295 public synchronized void recycle() { 296 decoderLock.writeLock().lock(); 297 try { 298 if (decoderPool != null) { 299 decoderPool.recycle(); 300 decoderPool = null; 301 context = null; 302 uri = null; 303 } 304 } finally { 305 decoderLock.writeLock().unlock(); 306 } 307 } 308 309 /** 310 * Called before creating a new decoder. Based on number of CPU cores, available memory, and the 311 * size of the image file, determines whether another decoder can be created. Subclasses can 312 * override and customise this. 313 * @param numberOfDecoders the number of decoders that have been created so far 314 * @param fileLength the size of the image file in bytes. Creating another decoder will use approximately this much native memory. 315 * @return true if another decoder can be created. 316 */ 317 @SuppressWarnings("WeakerAccess") 318 protected boolean allowAdditionalDecoder(int numberOfDecoders, long fileLength) { 319 if (numberOfDecoders >= 4) { 320 debug("No additional decoders allowed, reached hard limit (4)"); 321 return false; 322 } else if (numberOfDecoders * fileLength > 20 * 1024 * 1024) { 323 debug("No additional encoders allowed, reached hard memory limit (20Mb)"); 324 return false; 325 } else if (numberOfDecoders >= getNumberOfCores()) { 326 debug("No additional encoders allowed, limited by CPU cores (" + getNumberOfCores() + ")"); 327 return false; 328 } else if (isLowMemory()) { 329 debug("No additional encoders allowed, memory is low"); 330 return false; 331 } 332 debug("Additional decoder allowed, current count is " + numberOfDecoders + ", estimated native memory " + ((fileLength * numberOfDecoders)/(1024 * 1024)) + "Mb"); 333 return true; 334 } 335 336 337 /** 338 * A simple pool of {@link BitmapRegionDecoder} instances, all loading from the same source. 339 */ 340 private static class DecoderPool { 341 private final Semaphore available = new Semaphore(0, true); 342 private final Map<BitmapRegionDecoder, Boolean> decoders = new ConcurrentHashMap<>(); 343 344 /** 345 * Returns false if there is at least one decoder in the pool. 346 */ 347 private synchronized boolean isEmpty() { 348 return decoders.isEmpty(); 349 } 350 351 /** 352 * Returns number of encoders. 353 */ 354 private synchronized int size() { 355 return decoders.size(); 356 } 357 358 /** 359 * Acquire a decoder. Blocks until one is available. 360 */ 361 private BitmapRegionDecoder acquire() { 362 available.acquireUninterruptibly(); 363 return getNextAvailable(); 364 } 365 366 /** 367 * Release a decoder back to the pool. 368 */ 369 private void release(BitmapRegionDecoder decoder) { 370 if (markAsUnused(decoder)) { 371 available.release(); 372 } 373 } 374 375 /** 376 * Adds a newly created decoder to the pool, releasing an additional permit. 377 */ 378 private synchronized void add(BitmapRegionDecoder decoder) { 379 decoders.put(decoder, false); 380 available.release(); 381 } 382 383 /** 384 * While there are decoders in the map, wait until each is available before acquiring, 385 * recycling and removing it. After this is called, any call to {@link #acquire()} will 386 * block forever, so this call should happen within a write lock, and all calls to 387 * {@link #acquire()} should be made within a read lock so they cannot end up blocking on 388 * the semaphore when it has no permits. 389 */ 390 private synchronized void recycle() { 391 while (!decoders.isEmpty()) { 392 BitmapRegionDecoder decoder = acquire(); 393 decoder.recycle(); 394 decoders.remove(decoder); 395 } 396 } 397 398 private synchronized BitmapRegionDecoder getNextAvailable() { 399 for (Map.Entry<BitmapRegionDecoder, Boolean> entry : decoders.entrySet()) { 400 if (!entry.getValue()) { 401 entry.setValue(true); 402 return entry.getKey(); 403 } 404 } 405 return null; 406 } 407 408 private synchronized boolean markAsUnused(BitmapRegionDecoder decoder) { 409 for (Map.Entry<BitmapRegionDecoder, Boolean> entry : decoders.entrySet()) { 410 if (decoder == entry.getKey()) { 411 if (entry.getValue()) { 412 entry.setValue(false); 413 return true; 414 } else { 415 return false; 416 } 417 } 418 } 419 return false; 420 } 421 422 } 423 424 private int getNumberOfCores() { 425 if (Build.VERSION.SDK_INT >= 17) { 426 return Runtime.getRuntime().availableProcessors(); 427 } else { 428 return getNumCoresOldPhones(); 429 } 430 } 431 432 /** 433 * Gets the number of cores available in this device, across all processors. 434 * Requires: Ability to peruse the filesystem at "/sys/devices/system/cpu" 435 * @return The number of cores, or 1 if failed to get result 436 */ 437 private int getNumCoresOldPhones() { 438 class CpuFilter implements FileFilter { 439 @Override 440 public boolean accept(File pathname) { 441 return Pattern.matches("cpu[0-9]+", pathname.getName()); 442 } 443 } 444 try { 445 File dir = new File("/sys/devices/system/cpu/"); 446 File[] files = dir.listFiles(new CpuFilter()); 447 return files.length; 448 } catch(Exception e) { 449 return 1; 450 } 451 } 452 453 private boolean isLowMemory() { 454 ActivityManager activityManager = (ActivityManager)context.getSystemService(ACTIVITY_SERVICE); 455 if (activityManager != null) { 456 ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); 457 activityManager.getMemoryInfo(memoryInfo); 458 return memoryInfo.lowMemory; 459 } else { 460 return true; 461 } 462 } 463 464 private void debug(String message) { 465 if (debug) { 466 Log.d(TAG, message); 467 } 468 } 469 470} 471