ذخیره کردن بیت‌مپ‌ها در حافظه نهان

توجه: برای اکثر موارد، توصیه می‌کنیم از کتابخانه Glide برای واکشی، رمزگشایی، و نمایش بیت‌مپ‌ها در برنامه‌تان استفاده کنید. Glide اکثر پیچیدگی‌های مربوط به مدیریت این موارد و سایر کارهای مربوط به کار با بیت‌مپ‌ها و تصاویر دیگر در Android را خلاصه می‌کند. برای کسب اطلاعات درباره استفاده و بارگیری Glide، به مخزن Glide در GitHub مراجعه کنید.

بار کردن یک نقشه بیتی در میانای کاربر (UI) ساده است، اما اگر لازم باشد مجموعه‌ای بزرگ‌تر از تصاویر را به‌طور هم‌زمان بار کنید، کار پیچیده‌تر می‌شود. در بسیاری از موارد (مانند مؤلفه‌هایی مثل ListView، GridView، یا ViewPager)، تعداد کل تصاویر روی صفحه به‌همراه تصاویری که ممکن است به‌زودی به صفحه پیمایش شوند، اساساً نامحدود است.

با مؤلفه‌هایی مثل این، استفاده از حافظه با بازیافت نماهای فرزند هنگام حرکت به خارج از صفحه کاهش می‌یابد. زباله‌روب همچنین نگاشت‌های بیتی بارشده را آزاد می‌کند، با این فرض که هیچ مرجع طولانی‌مدتی را نگه نمی‌دارید. این خوب و عالی است، اما برای حفظ یک واسط کاربری روان و سریع در بارگذاری، باید از پردازش مداوم این تصاویر هر بار که دوباره روی صفحه ظاهر می‌شوند، اجتناب کنید. حافظه نهان دیسک و حافظه اغلب می‌تواند در اینجا کمک کند و به مؤلفه‌ها اجازه دهد تصاویر پردازش‌شده را به‌سرعت بارگیری مجدد کنند.

این درس شما را با استفاده از حافظه پنهان بیت‌مپ دیسک و حافظه آشنا می‌کند تا پاسخ‌گویی و روان بودن واسط کاربری‌تان را هنگام بار کردن چند بیت‌مپ بهبود دهید.

استفاده از حافظه نهان

حافظه نهان دسترسی سریع به بیت‌مپ‌ها را با هزینه اشغال حافظه ارزشمند برنامه ارائه می‌دهد. کلاس LruCache (که در کتابخانه پشتیبانی نیز برای استفاده در سطح ۴ میانای برنامه‌سازی کاربردی دردسترس است) به‌طور خاص برای ذخیره بیت‌مپ‌ها در حافظه نهان مناسب است، اشیایی را که اخیراً به آن‌ها ارجاع داده شده است در LinkedHashMap با ارجاع قوی نگه می‌دارد و عضوی را که اخیراً کمتر استفاده شده است قبل‌از اینکه حافظه نهان از اندازه تعیین‌شده خود فراتر رود، حذف می‌کند.

توجه: در گذشته، یک پیاده‌سازی محبوب حافظه پنهان، حافظه پنهان نقشه بیتی SoftReference یا WeakReference بود، اما این روش توصیه نمی‌شود. از Android 2.3 (سطح API‏ ۹) به بعد، زباله‌روب در جمع‌آوری ارجاع‌های نرم/ضعیف تهاجمی‌تر عمل می‌کند که این امر باعث می‌شود این ارجاع‌ها تا حد زیادی بی‌اثر شوند. علاوه‌براین، پیش‌از Android 3.0 (سطح API‏ ۱۱)، داده‌های پشتیبان نقشه بیتی در حافظه بومی ذخیره می‌شد که به‌صورت قابل‌پیش‌بینی آزاد نمی‌شود و به‌طور بالقوه باعث می‌شود برنامه برای مدت کوتاهی از محدودیت‌های حافظه خود فراتر رود و ازکار بیفتد.

برای انتخاب اندازه مناسب برای LruCache، باید تعدادی از عوامل را درنظر گرفت، برای مثال:

  • بقیه فعالیت و/یا برنامه شما چقدر حافظه مصرف می‌کند؟
  • چند تصویر به‌طور هم‌زمان روی صفحه خواهد بود؟ چند مورد باید دردسترس باشد تا آماده آمدن روی صفحه باشد؟
  • اندازه و تراکم صفحه‌نمایش دستگاه چقدر است؟ دستگاهی با صفحه‌نمایش تراکم بسیار بالا (xhdpi) مانند Galaxy Nexus به حافظه نهان بزرگ‌تری نیاز دارد تا بتواند همان تعداد تصویر را در مقایسه با دستگاهی مانند Nexus S (hdpi) در حافظه نگه دارد.
  • نقشه‌های بیتی چه ابعاد و پیکربندی‌ای دارند و بنابراین هرکدام چقدر حافظه اشغال می‌کنند؟
  • تصاویر با چه دوره تناوبی دردسترس قرار می‌گیرد؟ آیا به برخی‌از آن‌ها بیشتر از بقیه دسترسی پیدا خواهد شد؟ اگر چنین است، شاید بخواهید موارد خاصی را همیشه در حافظه نگه دارید یا حتی چند شیء LruCache برای گروه‌های مختلف نگاشت بیتی داشته باشید.
  • آیا می‌توانید بین کیفیت و کمیت تعادل برقرار کنید؟ گاهی اوقات ذخیره کردن تعداد بیشتری از نگاشت‌های بیتی با کیفیت پایین‌تر می‌تواند مفیدتر باشد، و احتمالاً نسخه با کیفیت بالاتر در تکلیف پس‌زمینه دیگری بار می‌شود.

هیچ اندازه یا فرمول خاصی وجود ندارد که برای همه برنامه‌ها مناسب باشد، این به شما بستگی دارد که استفاده خود را تجزیه‌وتحلیل کنید و به یک راه‌حل مناسب برسید. حافظه پنهانی که خیلی کوچک باشد باعث سربار اضافی بدون هیچ مزیتی می‌شود، حافظه پنهانی که خیلی بزرگ باشد می‌تواند دوباره باعث استثناهای java.lang.OutOfMemory شود و حافظه کمی برای کار کردن بقیه برنامه‌تان باقی بگذارد.

در اینجا مثالی از راه‌اندازی LruCache برای نقشه‌های بیتی آورده شده است:

کاتلین

private lateinit var memoryCache: LruCache<String, Bitmap>

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // Get max available VM memory, exceeding this amount will throw an
    // OutOfMemory exception. Stored in kilobytes as LruCache takes an
    // int in its constructor.
    val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()

    // Use 1/8th of the available memory for this memory cache.
    val cacheSize = maxMemory / 8

    memoryCache = object : LruCache<String, Bitmap>(cacheSize) {

        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            // The cache size will be measured in kilobytes rather than
            // number of items.
            return bitmap.byteCount / 1024
        }
    }
    ...
}

جاوا

private LruCache<String, Bitmap> memoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Get max available VM memory, exceeding this amount will throw an
    // OutOfMemory exception. Stored in kilobytes as LruCache takes an
    // int in its constructor.
    final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

    // Use 1/8th of the available memory for this memory cache.
    final int cacheSize = maxMemory / 8;

    memoryCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            // The cache size will be measured in kilobytes rather than
            // number of items.
            return bitmap.getByteCount() / 1024;
        }
    };
    ...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap);
    }
}

public Bitmap getBitmapFromMemCache(String key) {
    return memoryCache.get(key);
}

توجه: در این مثال، یک هشتم حافظه برنامه به حافظه نهان ما اختصاص داده شده است. در دستگاه معمولی/hdpi، این مقدار حداقل حدود ۴ مگابایت (۳۲/۸) است. یک GridView تمام‌صفحه پر از تصویر در دستگاهی با وضوح ۸۰۰×۴۸۰ حدود ۱٫۵ مگابایت (۸۰۰*۴۸۰*۴ بایت) استفاده می‌کند، بنابراین این کار حداقل حدود ۲٫۵ صفحه تصویر را در حافظه پنهان می‌کند.

هنگام بار کردن نقشه بیتی در ImageView، ابتدا LruCache بررسی می‌شود. اگر ورودی‌ای پیدا شود، بلافاصله برای به‌روزرسانی ImageView استفاده می‌شود، درغیراین‌صورت رشته‌ای در پس‌زمینه برای پردازش تصویر ایجاد می‌شود:

کاتلین

fun loadBitmap(resId: Int, imageView: ImageView) {
    val imageKey: String = resId.toString()

    val bitmap: Bitmap? = getBitmapFromMemCache(imageKey)?.also {
        mImageView.setImageBitmap(it)
    } ?: run {
        mImageView.setImageResource(R.drawable.image_placeholder)
        val task = BitmapWorkerTask()
        task.execute(resId)
        null
    }
}

جاوا

public void loadBitmap(int resId, ImageView imageView) {
    final String imageKey = String.valueOf(resId);

    final Bitmap bitmap = getBitmapFromMemCache(imageKey);
    if (bitmap != null) {
        mImageView.setImageBitmap(bitmap);
    } else {
        mImageView.setImageResource(R.drawable.image_placeholder);
        BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
        task.execute(resId);
    }
}

BitmapWorkerTask نیز باید به‌روز شود تا ورودی‌ها به حافظه نهان اضافه شود:

کاتلین

private inner class BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
    ...
    // Decode image in background.
    override fun doInBackground(vararg params: Int?): Bitmap? {
        return params[0]?.let { imageId ->
            decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
                addBitmapToMemoryCache(imageId.toString(), bitmap)
            }
        }
    }
    ...
}

جاوا

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final Bitmap bitmap = decodeSampledBitmapFromResource(
                getResources(), params[0], 100, 100));
        addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
        return bitmap;
    }
    ...
}

استفاده از حافظه نهان دیسک

حافظه نهان برای افزایش سرعت دسترسی به بیت‌مپ‌هایی که اخیراً مشاهده شده‌اند مفید است، اما نمی‌توانید به دردسترس بودن تصاویر در این حافظه نهان اتکا کنید. مؤلفه‌هایی مثل GridView با مجموعه داده‌های بزرگ‌تر می‌توانند به‌راحتی حافظه نهان را پر کنند. ممکن است برنامه شما توسط کار دیگری مثل تماس تلفنی قطع شود و درحالی‌که در پس‌زمینه است، ممکن است ازبین برود و حافظه پنهان آن نابود شود. وقتی کاربر ازسر می‌گیرد، برنامه شما باید هر تصویر را دوباره پردازش کند.

در این موارد، می‌توان از حافظه نهان دیسک برای حفظ نگاشت‌های بیتی پردازش‌شده و کمک به کاهش زمان بارگیری در شرایطی که تصاویر دیگر در حافظه نهان موجود نیستند استفاده کرد. البته واکشی تصاویر از دیسک کندتر از بار کردن از حافظه است و باید در رشته پس‌زمینه انجام شود، زیرا زمان خواندن دیسک می‌تواند غیرقابل‌پیش‌بینی باشد.

توجه: اگر تصاویر ذخیره‌شده در حافظه نهان به‌طور مکرر مورد دسترسی قرار می‌گیرند، مثلاً در برنامه گالری تصاویر، ContentProvider ممکن است مکان مناسب‌تری برای ذخیره آن‌ها باشد.

کد نمونه این کلاس از پیاده‌سازی DiskLruCache استفاده می‌کند که از منبع Android گرفته شده است. در اینجا کد نمونه به‌روزشده‌ای وجود دارد که علاوه‌بر حافظه نهان موجود، حافظه نهان دیسک را نیز اضافه می‌کند:

کاتلین

private const val DISK_CACHE_SIZE = 1024 * 1024 * 10 // 10MB
private const val DISK_CACHE_SUBDIR = "thumbnails"
...
private var diskLruCache: DiskLruCache? = null
private val diskCacheLock = ReentrantLock()
private val diskCacheLockCondition: Condition = diskCacheLock.newCondition()
private var diskCacheStarting = true

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    val cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR)
    InitDiskCacheTask().execute(cacheDir)
    ...
}

internal inner class InitDiskCacheTask : AsyncTask<File, Void, Void>() {
    override fun doInBackground(vararg params: File): Void? {
        diskCacheLock.withLock {
            val cacheDir = params[0]
            diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE)
            diskCacheStarting = false // Finished initialization
            diskCacheLockCondition.signalAll() // Wake any waiting threads
        }
        return null
    }
}

internal inner class  BitmapWorkerTask : AsyncTask<Int, Unit, Bitmap>() {
    ...

    // Decode image in background.
    override fun doInBackground(vararg params: Int?): Bitmap? {
        val imageKey = params[0].toString()

        // Check disk cache in background thread
        return getBitmapFromDiskCache(imageKey) ?:
                // Not found in disk cache
                decodeSampledBitmapFromResource(resources, params[0], 100, 100)
                        ?.also {
                            // Add final bitmap to caches
                            addBitmapToCache(imageKey, it)
                        }
    }
}

fun addBitmapToCache(key: String, bitmap: Bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap)
    }

    // Also add to disk cache
    synchronized(diskCacheLock) {
        diskLruCache?.apply {
            if (!containsKey(key)) {
                put(key, bitmap)
            }
        }
    }
}

fun getBitmapFromDiskCache(key: String): Bitmap? =
        diskCacheLock.withLock {
            // Wait while disk cache is started from background thread
            while (diskCacheStarting) {
                try {
                    diskCacheLockCondition.await()
                } catch (e: InterruptedException) {
                }

            }
            return diskLruCache?.get(key)
        }

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
fun getDiskCacheDir(context: Context, uniqueName: String): File {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    val cachePath =
            if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
                    || !isExternalStorageRemovable()) {
                context.externalCacheDir.path
            } else {
                context.cacheDir.path
            }

    return File(cachePath + File.separator + uniqueName)
}

جاوا

private DiskLruCache diskLruCache;
private final Object diskCacheLock = new Object();
private boolean diskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
private static final String DISK_CACHE_SUBDIR = "thumbnails";

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
    new InitDiskCacheTask().execute(cacheDir);
    ...
}

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
    @Override
    protected Void doInBackground(File... params) {
        synchronized (diskCacheLock) {
            File cacheDir = params[0];
            diskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            diskCacheStarting = false; // Finished initialization
            diskCacheLock.notifyAll(); // Wake any waiting threads
        }
        return null;
    }
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final String imageKey = String.valueOf(params[0]);

        // Check disk cache in background thread
        Bitmap bitmap = getBitmapFromDiskCache(imageKey);

        if (bitmap == null) { // Not found in disk cache
            // Process as normal
            final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources(), params[0], 100, 100));
        }

        // Add final bitmap to caches
        addBitmapToCache(imageKey, bitmap);

        return bitmap;
    }
    ...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        memoryCache.put(key, bitmap);
    }

    // Also add to disk cache
    synchronized (diskCacheLock) {
        if (diskLruCache != null && diskLruCache.get(key) == null) {
            diskLruCache.put(key, bitmap);
        }
    }
}

public Bitmap getBitmapFromDiskCache(String key) {
    synchronized (diskCacheLock) {
        // Wait while disk cache is started from background thread
        while (diskCacheStarting) {
            try {
                diskCacheLock.wait();
            } catch (InterruptedException e) {}
        }
        if (diskLruCache != null) {
            return diskLruCache.get(key);
        }
    }
    return null;
}

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    final String cachePath =
            Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                    !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context.getCacheDir().getPath();

    return new File(cachePath + File.separator + uniqueName);
}

توجه: حتی مقداردهی اولیه حافظه نهان دیسک به عملیات دیسک نیاز دارد و بنابراین نباید در رشته اصلی انجام شود. بااین‌حال، این یعنی احتمال دارد قبل‌از مقداردهی اولیه به حافظه پنهان دسترسی پیدا شود. برای رفع این مشکل، در پیاده‌سازی بالا، یک شیء قفل تضمین می‌کند که برنامه تا زمانی که حافظه نهان مقداردهی اولیه نشده باشد، از حافظه نهان دیسک نمی‌خواند.

درحالی‌که حافظه نهان در رشته میانای کاربر بررسی می‌شود، حافظه نهان دیسک در رشته پس‌زمینه بررسی می‌شود. عملیات دیسک هرگز نباید در رشته میانای کاربر انجام شود. وقتی پردازش تصویر کامل شد، بیت‌مپ نهایی برای استفاده در آینده به حافظه نهان دیسک و حافظه اضافه می‌شود.

رسیدگی به تغییرات پیکربندی

تغییرات پیکربندی زمان اجرا، مانند تغییر جهت صفحه، باعث می‌شود Android فعالیت درحال اجرا را با پیکربندی جدید ازبین ببرد و دوباره راه‌اندازی کند (برای اطلاعات بیشتر درباره این رفتار، مدیریت تغییرات زمان اجرا را ببینید). می‌خواهید از پردازش مجدد همه تصاویرتان اجتناب کنید تا کاربر هنگام تغییر پیکربندی، تجربه‌ای روان و سریع داشته باشد.

خوشبختانه، حافظه نهان خوبی از بیت‌مپ‌ها دارید که در بخش استفاده از حافظه نهان ساخته‌اید. این حافظه نهان را می‌توان بااستفاده از Fragment که با فراخوانی setRetainInstance(true) حفظ می‌شود به نمونه فعالیت جدید منتقل کرد. پس‌از بازسازی فعالیت، این Fragment حفظ‌شده دوباره متصل می‌شود و به شیء حافظه پنهان موجود دسترسی پیدا می‌کنید، که امکان واکشی سریع تصاویر و بازجمع‌آوری آن‌ها در اشیاء ImageView را فراهم می‌کند.

در اینجا مثالی از حفظ شیء LruCache در تغییرات پیکربندی بااستفاده از Fragment آورده شده است:

کاتلین

private const val TAG = "RetainFragment"
...
private lateinit var mMemoryCache: LruCache<String, Bitmap>

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    val retainFragment = RetainFragment.findOrCreateRetainFragment(supportFragmentManager)
    mMemoryCache = retainFragment.retainedCache ?: run {
        LruCache<String, Bitmap>(cacheSize).also { memoryCache ->
            ... // Initialize cache here as usual
            retainFragment.retainedCache = memoryCache
        }
    }
    ...
}

class RetainFragment : Fragment() {
    var retainedCache: LruCache<String, Bitmap>? = null

    companion object {
        fun findOrCreateRetainFragment(fm: FragmentManager): RetainFragment {
            return (fm.findFragmentByTag(TAG) as? RetainFragment) ?: run {
                RetainFragment().also {
                    fm.beginTransaction().add(it, TAG).commit()
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        retainInstance = true
    }
}

جاوا

private LruCache<String, Bitmap> memoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    RetainFragment retainFragment =
            RetainFragment.findOrCreateRetainFragment(getFragmentManager());
    memoryCache = retainFragment.retainedCache;
    if (memoryCache == null) {
        memoryCache = new LruCache<String, Bitmap>(cacheSize) {
            ... // Initialize cache here as usual
        }
        retainFragment.retainedCache = memoryCache;
    }
    ...
}

class RetainFragment extends Fragment {
    private static final String TAG = "RetainFragment";
    public LruCache<String, Bitmap> retainedCache;

    public RetainFragment() {}

    public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) {
        RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG);
        if (fragment == null) {
            fragment = new RetainFragment();
            fm.beginTransaction().add(fragment, TAG).commit();
        }
        return fragment;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setRetainInstance(true);
    }
}

برای آزمایش این موضوع، دستگاه را هم با حفظ Fragment و هم بدون حفظ آن بچرخانید. وقتی حافظه پنهان را حفظ می‌کنید، باید متوجه شوید که تصاویر تقریباً بلافاصله از حافظه در فعالیت قرار می‌گیرند و تأخیر بسیار کمی وجود دارد یا اصلاً تأخیری وجود ندارد. هر تصویری که در حافظه نهان پیدا نشود، امیدواریم در حافظه نهان دیسک دردسترس باشد، درغیراین‌صورت، طبق معمول پردازش می‌شود.