一、 問題的背景和意義
在Android移動應用開發中,對Bitmap的不小心處理,很容易引起程序內存空間耗盡而導致的程序崩潰問題。比如我們常遇到的問題:
java.lang.OutofMemoryError: bitmap size exceeds VM budget.
導致該問題的出現,一般由以下幾方面原因導致:
- 引動設備一般存儲空間非常有限。當然不同設備分配給應用的內存空間是不同的。但相對不但提高的設備分辨率而言,內存的分配仍然是相對緊張的。
- Bitmap對象常常占用大量的內存空間,比如:對於2592*1936的設備,如果采用ARGB_8888的格式加載圖像,內存占用將達到19MB空間。
- 在Anroid App中經常用到ListView,ViewPager等控件,這些控件常會包含較大數量的圖片資源。
二、 問題及場景分析
1 高效地加載大圖片。
BitmapFactory類提供了一些加載圖片的方法:decodeByteArray(), decodeFile(), decodeResource(), 等等。
為了避免占用較大內存,經常使用BitmapFactory.Options 類,設置inJustDecodeBounds屬性為true。
// BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds =true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
為了避免java.lang.OutOfMemory 的異常,我們在真正decode圖片之前檢查它的尺寸,除非你確定這個數據源提供了准確無誤的圖片且不會導致占用過多的內存。
加載一個按比例縮小的版本到內存中。例如,如果把一個原圖是1024*768 pixel的圖片顯示到ImageView為128*96 pixel的縮略圖就沒有必要把整張圖片都加載到內存中。為了告訴解碼器去加載一個較小的圖片到內存,需要在你的BitmapFactory.Options 中設置 inSampleSize 為true 。例如, 一個分辨率為2048x1536 的圖片,如果設置inSampleSize 為4,那么會產出一個大概為512x384的圖片。加載這張小的圖片僅僅使用大概0.75MB,如果是加載全圖那么大概要花費12MB(假設bitmap的配置是ARGB_8888).
2 不要在主線程處理圖片。
眾所周知的問題,不再贅述。
注意兩點:1. 為了保證使用的資源能被回收,建議使用WeakReference, 以應用內存內存緊張時,回收部分資源,保證程序進程不被殺死。
2. 避免異步任務的長時間耗時操作,在任務執行結束后,及時釋放資源。
3 管理Bitmap內存。
在Android開發中,加載一個圖片到界面很容易,但如果一次加載大量圖片就復雜多了。在很多情況下(比如:ListView,GridView或ViewPager),能夠滾動的組件需要加載的圖片幾乎是無限多的。
有些組件的child view在不顯示時會回收,並循環使用,如果沒有任何對bitmap的持久引用的話,垃圾回收器會釋放你加載的bitmap。這沒什么問題,但當這些圖片再次顯示的時候,要想避免重復處理這些圖片,從而達到加載流暢的效果,就要使用內存緩存和本地緩存了,這些緩存可以讓你快速加載處理過的圖片。
3.1 內存緩存
內存緩存以犧牲內存的代價,帶來快速的圖片訪問。LruCache類(API Level 4之前可以使用Support Library)非常適合圖片緩存任務,在一個LinkedHashMap中保存着對Bitmap的強引用,當緩存數量超過容器容量時,刪除最近最少使用的成員(LRU)。
注意:在過去,非常流行用SoftReference或WeakReference來實現圖片的內存緩存,但現在不再推薦使用這個方法了。因為從Android 2.3 (API Level 9)之后,垃圾回收器會更積極的回收soft/weak的引用,這將導致使用soft/weak引用的緩存幾乎沒有緩存效果。順帶一提,在Android3.0(API Level 11)以前,bitmap是儲存在native 內存中的,所以系統以不可預見的方式來釋放bitmap,這可能會導致短時間超過內存限制從而造成崩潰。
為了給LruCache一個合適的容量,需要考慮很多因素,比如:
你其它的Activity 和/或 Application是怎樣使用內存的?
屏幕一次顯示多少圖片?需要多少圖片為顯示到屏幕做准備?
屏幕的大小(size)和密度(density)是多少?像Galaxy Nexus這樣高密度(xhdpi)的屏幕在緩存相同數量的圖片時,就需要比低密度屏幕Nexus S(hdpi)更大的內存。
每個圖片的尺寸多大,相關配置怎樣的,占用多大內存?
圖片的訪問頻率高不高?不同圖片的訪問頻率是否不一樣?如果是,你可能會把某些圖片一直緩存在內存中,或需要多種不同緩存策略的LruCache。
你能平衡圖片的質量和數量嗎?有時候,緩存多個質量低的圖片是很有用的,而質量高的圖片應該(像下載文件一樣)在后台任務中加載。
這里沒有適應所有應用的特定大小或公式,只能通過分析具體的使用方法,來得出合適的解決方案。緩存太小的話沒有實際用處,還會增加額外開銷;緩存太大的話,會再一次造成OutOfMemory異常,並給應用的其他部分留下很少的內存。
3.2 使用磁盤緩存
內存緩存能夠加快對最近顯示過的圖片的訪問速度,然而你不能認為緩存中的圖片全是有效的。像GridView這樣需要大量數據的組件是很容易填滿內存緩存的。你的應用可能會被別的任務打斷(比如一個來電),它可能會在后台被殺掉,其內存緩存當然也被銷毀了。當用戶恢復你的應用時,應用將重新處理之前緩存的每一張圖片。
在這個情形中,使用磁盤緩存可以持久的儲存處理過的圖片,並且縮短加載內存緩存中無效的圖片的時間。當然從磁盤加載圖片比從內存中加載圖片要慢的多,並且由於磁盤讀取的時間是不確定的,所以要在后台線程進行磁盤加載。
注意:如果以更高的頻率訪問圖片,比如圖片牆應用,使用ContentProvider可能更適合儲存圖片緩存。
下面這個例子除了之前的內存緩存,還添加了一個磁盤緩存,這個磁盤緩存實現自Android源碼中的DiskLruCache:
private DiskLruCache mDiskLruCache;
private final Object mDiskCacheLock = new Object();
private boolean mDiskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MBprivate static final String DISK_CACHE_SUBDIR = "thumbnails"; @Overrideprotected 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 (mDiskCacheLock) { File cacheDir = params[0]; mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE); mDiskCacheStarting = false; // Finished initialization mDiskCacheLock.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) { mMemoryCache.put(key, bitmap); } // Also add to disk cache synchronized (mDiskCacheLock) { if (mDiskLruCache != null && mDiskLruCache.get(key) == null) { mDiskLruCache.put(key, bitmap); } } } public Bitmap getBitmapFromDiskCache(String key) { synchronized (mDiskCacheLock) { // Wait while disk cache is started from background thread while (mDiskCacheStarting) { try { mDiskCacheLock.wait(); } catch (InterruptedException e) {} } if (mDiskLruCache != null) { return mDiskLruCache.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); }
檢查內存緩存可以在UI線程,檢查磁盤緩存最好在后台線程。永遠不要在UI線程做磁盤操作。當圖片處理完成,應該將其添加進內存緩存和磁盤緩存中,以備將來不時之需。
4. UI中展示圖片。
If you have a smaller number of images and are confident they all fit within the application memory limit, then using a regular PagerAdapter or FragmentPagerAdapter might be more appropriate.
public class ImageDetailActivity extends FragmentActivity { public static final String EXTRA_IMAGE = "extra_image"; private ImagePagerAdapter mAdapter; private ViewPager mPager; // A static dataset to back the ViewPager adapter public final static Integer[] imageResIds = new Integer[] { R.drawable.sample_image_1, R.drawable.sample_image_2, R.drawable.sample_image_3, R.drawable.sample_image_4, R.drawable.sample_image_5, R.drawable.sample_image_6, R.drawable.sample_image_7, R.drawable.sample_image_8, R.drawable.sample_image_9}; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.image_detail_pager); // Contains just a ViewPager mAdapter = new ImagePagerAdapter(getSupportFragmentManager(), imageResIds.length); mPager = (ViewPager) findViewById(R.id.pager); mPager.setAdapter(mAdapter); } public static class ImagePagerAdapter extends FragmentStatePagerAdapter { private final int mSize; public ImagePagerAdapter(FragmentManager fm, int size) { super(fm); mSize = size; } @Override public int getCount() { return mSize; } @Override public Fragment getItem(int position) { return ImageDetailFragment.newInstance(position); } } }
Here is an implementation of the details Fragment which holds the ImageView children. This might seem like a perfectly reasonable approach, but can you see the drawbacks of this implementation? How could it be improved?
public class ImageDetailFragment extends Fragment { private static final String IMAGE_DATA_EXTRA = "resId"; private int mImageNum; private ImageView mImageView; static ImageDetailFragment newInstance(int imageNum) { final ImageDetailFragment f = new ImageDetailFragment(); final Bundle args = new Bundle(); args.putInt(IMAGE_DATA_EXTRA, imageNum); f.setArguments(args); return f; } // Empty constructor, required as per Fragment docs public ImageDetailFragment() {} @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mImageNum = getArguments() != null ? getArguments().getInt(IMAGE_DATA_EXTRA) : -1; } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { // image_detail_fragment.xml contains just an ImageView final View v = inflater.inflate(R.layout.image_detail_fragment, container, false); mImageView = (ImageView) v.findViewById(R.id.imageView); return v; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); final int resId = ImageDetailActivity.imageResIds[mImageNum]; mImageView.setImageResource(resId); // Load image into ImageView } }