تخزين الصور النقطية مؤقتًا

ملاحظة: في معظم الحالات، ننصح باستخدام مكتبة Glide لجلب الصور النقطية وفك ترميزها وعرضها في التطبيق. تساعد هذه الميزة في التخلص من معظم التعقيدات التي تواجهها في التعامل مع هذه الصور وغيرها من المهام ذات الصلة بالعمل مع الصور النقطية والصور الأخرى على نظام التشغيل Android. للحصول على معلومات عن استخدام Glide وتنزيله، يُرجى الانتقال إلى مستودع Glide على GitHub.

يعد تحميل صورة نقطية واحدة في واجهة المستخدم (UI) أمرًا بسيطًا، ولكن الأمور تزداد تعقيدًا إذا كنت بحاجة إلى تحميل مجموعة أكبر من الصور في وقت واحد. في كثير من الحالات (كما هو الحال مع مكونات مثل ListView أو GridView أو ViewPager)، يكون إجمالي عدد الصور التي تظهر على الشاشة مع الصور التي قد يتم تمريرها قريبًا على الشاشة غير محدود بشكل أساسي.

ويتم الاحتفاظ باستخدام الذاكرة مع بعض المكونات مثل هذه عن طريق إعادة تدوير طرق العرض الثانوية أثناء تحركها خارج الشاشة. تعمل أداة تجميع البيانات المهملة أيضًا على تفريغ الصور النقطية التي تم تحميلها، بافتراض أنك لا تحتفظ بأي مراجع قديمة. كل هذا جيد وجيد، ولكن من أجل الحفاظ على واجهة مستخدم سلسة وسريعة التحميل، يجب أن تتجنب معالجة هذه الصور باستمرار في كل مرة تظهر فيها على الشاشة. غالبًا ما تكون الذاكرة وذاكرة التخزين المؤقت على القرص مفيدة هنا، مما يسمح للمكونات بإعادة تحميل الصور التي تمت معالجتها بسرعة.

يرشدك هذا الدرس إلى طريقة استخدام ذاكرة التخزين المؤقت للصورة النقطية للذاكرة والقرص لتحسين سرعة استجابة واجهة المستخدم وسلاستها عند تحميل صور نقطية متعددة.

استخدام ذاكرة تخزين مؤقت

توفر ذاكرة التخزين المؤقت وصولاً سريعًا إلى الصور النقطية مقابل استهلاك ذاكرة تطبيقات قيّمة. إنّ فئة LruCache (المتوفّرة أيضًا في مكتبة الدعم للرجوع إليها إلى المستوى 4 من واجهة برمجة التطبيقات) مناسبة بشكل خاص لمهمة التخزين المؤقت للصور النقطية، والاحتفاظ بالكائنات التي تمت الإشارة إليها مؤخرًا في LinkedHashMap مُشار إليها مؤخرًا واستبعاد العناصر الأقل استخدامًا قبل أن تتجاوز ذاكرة التخزين المؤقت الحجم المحدَّد.

ملاحظة: في السابق، كان استخدام ذاكرة التخزين المؤقت الشائعة استخدام ذاكرة التخزين المؤقت للصورة النقطية SoftReference أو WeakReference، ولكن لا يُنصح بتنفيذ هذا الإجراء. بدءًا من نظام التشغيل Android 2.3 (مستوى واجهة برمجة التطبيقات 9)، أصبحت أداة تجميع البيانات المهملة أكثر صرامة فيما يتعلق بجمع المراجع الضعيفة أو الضعيفة، ما يجعلها غير فعّالة إلى حد ما. علاوة على ذلك، بالنسبة إلى الإصدار Android 3.0 (المستوى 11 لواجهة برمجة التطبيقات)، كان يتم تخزين بيانات النسخ الاحتياطي للصور النقطية في ذاكرة أصلية لا يتم إصدارها بشكل يمكن توقعه، وهو ما قد يتسبب في تجاوز التطبيق لحدود الذاكرة وتعطله لفترة قصيرة.

لاختيار مقاس مناسب لـ LruCache، يجب مراعاة عدد من العوامل، على سبيل المثال:

  • ما مدى كثافة الذاكرة التي تستهلكها بقية أنشطتك و/أو تطبيقك؟
  • كم عدد الصور التي ستظهر على الشاشة في آنٍ واحد؟ كم عدد الأجهزة التي يجب أن تكون جاهزة للعرض على الشاشة؟
  • ما هو حجم شاشة الجهاز وكثافته؟ سيحتاج جهاز ذو شاشة عالية الكثافة (xhdpi) مثل Galaxy Nexus إلى ذاكرة تخزين مؤقت أكبر للاحتفاظ بالعدد نفسه من الصور في الذاكرة مقارنةً بجهاز مثل Nexus S (hdpi).
  • ما هي أبعاد الصور النقطية وإعداداتها، وبالتالي ما هو حجم الذاكرة التي يستهلكها كلّ منها؟
  • ما هو معدّل الوصول إلى الصور؟ وهل سيتم الوصول إلى بعضها بشكل متكرر أكثر من غيرها؟ في هذه الحالة، ننصحك بإبقاء عناصر محددة في الذاكرة دائمًا أو استخدام عدة كائنات LruCache لمجموعات مختلفة من الصور النقطية.
  • هل يمكنك تحقيق التوازن بين الجودة والكمية؟ في بعض الأحيان، قد يكون من المفيد تخزين عدد أكبر من الصور النقطية ذات الجودة المنخفضة، ما قد يؤدي إلى تحميل نسخة ذات جودة أعلى في مهمة أخرى في الخلفية.

لا يوجد حجم أو صيغة محددة تناسب جميع التطبيقات، فالأمر متروك لك لتحليل الاستخدام والتوصل إلى حل مناسب. تتسبّب ذاكرة التخزين المؤقت الصغيرة جدًا في تحمّل أعباء إضافية بدون أي فائدة، بينما تؤدي ذاكرة التخزين المؤقت الكبيرة جدًا إلى استثناءات java.lang.OutOfMemory وترك باقي المساحة في التطبيق صغيرة الحجم للعمل من جديد.

في ما يلي مثال على إعداد LruCache للصور النقطية:

Kotlin

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
        }
    }
    ...
}

Java

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، يبلغ الحد الأدنى لحجم الملف حوالي 4 ميغابايت (32/8). إنّ GridView بملء الشاشة المملوءة بالصور على جهاز بدقة 800x480 سيستخدم حوالى 1.5 ميغابايت (800*480*4 بايت)، لذلك سيؤدّي هذا إلى تخزين ما لا يقل عن 2.5 صفحة من الصور في الذاكرة.

عند تحميل صورة نقطية في ImageView، يتم وضع علامة في LruCache أولاً. إذا تم العثور على إدخال، يتم استخدامه فورًا لتعديل ImageView، وإلا يتم إنشاء سلسلة محادثات في الخلفية لمعالجة الصورة:

Kotlin

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
    }
}

Java

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 أيضًا لإضافة إدخالات إلى ذاكرة التخزين المؤقت للذاكرة:

Kotlin

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)
            }
        }
    }
    ...
}

Java

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. إليك مثال محدّث للرمز الذي يضيف ذاكرة تخزين مؤقت للقرص بالإضافة إلى ذاكرة التخزين المؤقت الحالية:

Kotlin

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)
}

Java

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:

Kotlin

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
    }
}

Java

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 أو بدونها. من المفترض أن تلاحظ تأخّرًا طفيفًا أو منعدمًا لأنّ الصور تملأ النشاط على الفور تقريبًا من الذاكرة عند الاحتفاظ بذاكرة التخزين المؤقت. نأمل أن تتوفر أي صور لم يتم العثور عليها في ذاكرة التخزين المؤقت في ذاكرة التخزين المؤقت على القرص، وفي حال عدم العثور عليها، ستتم معالجتها كالمعتاد.