שמירת מפות סיביות במטמון

הערה: ברוב המקרים, אנחנו ממליצים שמשתמשים בהחלקה כדי לאחזר, לפענח ולהציג מפות סיביות באפליקציה שלך. בהחלקה מופשטים רוב התוכן את המורכבות בטיפול משימות אחרות שקשורות לעבודה עם מפות סיביות ותמונות אחרות ב-Android. לקבלת מידע על השימוש בהחלקה וההורדה שלה, אפשר להיכנס אל מאגר גלישה ב-GitHub.

הטעינה של מפת סיביות יחידה לממשק המשתמש (UI) היא פשוטה, אבל הפעולות נעשים יותר מורכבת אם צריך לטעון קבוצה גדולה יותר של תמונות בבת אחת. במקרים רבים (למשל עם רכיבים כמו ListView,‏ GridView או ViewPager), אין הגבלה על המספר הכולל של התמונות במסך בשילוב עם תמונות שעשויות לגלול למסך בקרוב.

השימוש בזיכרון נשמר ברכיבים כמו זה על ידי מיחזור של תצוגות הצאצא בזמן שהן זזות אל מחוץ למסך. אספן האשפה גם משחרר את מפות הביטים הטעונות, בהנחה שלא שמרת הפניות לטווח ארוך. כל זה טוב, אבל כדי לשמור על ממשק משתמש יציב שנטען במהירות כדאי להימנע מעיבוד מתמשך של התמונות האלה בכל פעם שהן מוצגות במסך. במקרים כאלה, מטמון בזיכרון ובדיסק יכול לעזור, ולאפשר לרכיבים לטעון מחדש במהירות תמונות שעברו עיבוד.

בשיעור הזה תלמדו איך להשתמש במטמון של מפת סיביות (bitmap) בזיכרון ובדיסק כדי לשפר את הרספונסיביות והגמישות של ממשק המשתמש כשטוענים מספר מפות סיביות.

שימוש במטמון זיכרון

מטמון זיכרון מאפשר גישה מהירה למפות ביטים בתשלום של שימוש באפליקציות חשובות זיכרון. הכיתה LruCache (שזמינה גם ב-Support Library לשימוש עד לרמה 4 של API) מתאימה במיוחד למשימות של שמירת מטמון של בימפטים, שמירה של אובייקטים שהופנו אליהם לאחרונה ב-LinkedHashMap עם הפניה חזקה והוצאה של המשתנה שלא נעשה בו שימוש לאחרונה לפני שהמטמון חורג מהגודל הייעודי שלו.

הערה: בעבר, יישום פופולרי של מטמון זיכרון היה עם זאת, מטמון מפת סיביות של SoftReference או WeakReference מומלץ לא לעשות זאת. החל מ-Android 2.3 (רמת API 9), איסוף האשפה יעיל יותר אגרסיבית באיסוף הפניות רכות/חלשות, ולכן הן לא יעילות למדי. In addition, לפני Android 3.0 (API ברמה 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, הגודל המינימלי הוא כ-4MB (32/8). A מלא מסך GridView מלא בתמונות במכשיר ברזולוציה של 800x480 היא בגודל 1.5MB (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);
}

הערה: גם לאינטליקציה של מטמון הדיסק נדרשות פעולות דיסק, ולכן אסור לבצע אותה ב-thread הראשי. עם זאת, המשמעות היא שיש סיכוי שתתבצע גישה למטמון לפני האתחול. כדי לטפל בבעיה, בהטמעה שצוינה למעלה, אובייקט מבטיח שהאפליקציה לא תקרא ממטמון הדיסק עד שהמטמון אותחל.

כשמטמון הזיכרון נבדק ב-thread של ממשק המשתמש, מטמון הדיסק נבדק ברקע של שרשור. אסור לבצע פעולות בדיסק בשרשור של ממשק המשתמש. כאשר עיבוד התמונה הוא מפת הביטים הסופית מתווספת גם לזיכרון וגם למטמון הדיסק לשימוש עתידי.

טיפול בשינויים בהגדרות

שינויים בתצורה של זמן הריצה, כגון שינוי בכיוון המסך, גורמים ל-Android להרוס מפעילים מחדש את פעילות הריצה עם ההגדרה החדשה (למידע נוסף על ההתנהגות הזו, כדאי לעיין במאמר טיפול בשינויים בזמן ריצה. אתם לא רוצים שתצטרכו לעבד שוב את כל התמונות, כדי שהמשתמשים ייהנו מחוויית שימוש חלקה ומהירה כשמתרחש שינוי בהגדרות.

למזלכם, יש לכם מטמון זיכרון נחמד של קובצי bitmap שיצרתם בקטע שימוש במטמון זיכרון. ניתן להעביר את המטמון החדש מופע פעילות באמצעות 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 וגם בלי להשאיר אותו. אמור להיות עיכוב קטן או אפסי, כי התמונות מאכלסות את הפעילות כמעט באופן מיידי מהזיכרון, כששומרים את המטמון. אם יש תמונות שלא נמצאו במטמון הזיכרון הם זמינים במטמון הדיסק. אם לא, הם מעובדים כרגיל.