Android加載圖片佔用內存

圖片在內存中的存儲基於位圖模式(通常也稱作Bitmap),它把圖片的長寬定義成多個像素點,每個像素點的顏色值有幾個像素來保存,對那些尺寸比較大的圖片一張可能就佔據十兆左右的空間,應用只要多存儲一些大尺寸照片就會導致內存溢出。Android的應用運行在JVM虛擬機上,每個JVM虛擬機進程分配的內存有限,這樣才能保證多個應用同時運行時每個程序都有機會執行。系統究竟爲每個應用分配的內存值大小是多少,Java提供了Runtime運行時類它可以提供JVM的內存信息。

Log.e(TAG, "Total Memory = " + Runtime.getRuntime().totalMemory());
Log.e(TAG, "Free Memory = " + Runtime.getRuntime().freeMemory());
Log.e(TAG, "Max Memory = " + Runtime.getRuntime().maxMemory());
Total Memory = 2754792
Free Memory = 1267952
Max Memory = 268435456

Runtime三個方法提供的數據都是JVM進程的內存數,其中totalMemory代表當前JVM從操作系統已經申請到的內存大小,freeMemory的值等於totalMemory減去已經使用了的內存大小,maxMemory代表JVM進程可以向操作系統申請的最大內存值。 從測試代碼看當前申請totalMemory到的內存才2M多,而最大申請內存有268M那麼大,如果應用繼續執行需要更多的內存,totalMemory會不斷地增大,最大增長到maxMemory的大小,之後繼續申請內存就會導致內存溢出,JVM拋出OutOfMemory異常。

public void testMemory(View view) {
     byte[][] array = new byte[1024 * 1024][];
      for (int i = 0; i < 1024; i++) {
         array[i] = new byte[1024 * 1024 * 4];
     }
}

上面的內存申請代碼首先定義了一個1024 * 1024長度的二維數組,之後爲每個元素申請4 * 1024 * 1024也就是4M的內存空間,由於申請的內存始終都被array數組引用無法被回收,最終JVM拋出了OOM異常。從異常消息中可以看出系統限制了分配最大內存268M和前面獲取值是一致的。

java.lang.OutOfMemoryError: Failed to allocate a 4194320 byte allocation with 2395752 free bytes and 
2MB until OOM, max allowed footprint 268435456, growth limit 268435456

那麼有沒有方法增加最大分配內存大小呢,在AndroidManifest.xml文件的application節點有個屬性android:largeHeap,配置屬性值爲true系統就會爲應用提供更大的內存空間。

Total Memory = 2732016
Free Memory = 1202328
Max Memory = 536870912
java.lang.OutOfMemoryError: Failed to allocate a 4194320 byte allocation with 2133584 free bytes and 
2MB until OOM, max allowed footprint 536870912, growth limit 536870912

重新運行前面的測試代碼就會發現最大內存值變成了536M,比以前足足大了一倍,但largeHeap選項不建議一般的應用開啓,試想如果所有應用都申請內存變大最終還是會導致內存不足的情況。

圖片佔用內存

應用中使用到的圖片通常會放置在資源文件夾res/drawable-xxx、assets文件夾、本地緩存目錄、用戶相冊,還有一些圖片需要從網絡上加載。首先來看從res資源文件夾加載圖片實現,drawable文件夾通常看到的有drawable、drawable-hdpi、drawable-xxhdpi等多個文件夾,應用在不同尺寸的屏幕下會使用不同像素密度文件下的圖片。如果同一副圖片放在不同的像素密度資源文件夾下,將它加載到內存中它的大小是不是完全一樣的呢?

// 不同屏幕密度加載圖片大小測試
public void loadDrawable(View view) { // 加載drawable文件夾下的圖片
	Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.flower);
	imageView.setImageBitmap(bitmap);
	Log.e(TAG, "drawable bitmap width = " + bitmap.getWidth() + ", height = " 
+ bitmap.getHeight() + ", size = " + bitmap.getByteCount());
}

public void loadHDPI(View view) {  // 加載drawable-hdpi文件夾下的圖片
	Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.hdpi_flower);
	imageView.setImageBitmap(bitmap);
	Log.e(TAG, "hdpi bitmap width = " + bitmap.getWidth() + ", height = " + bitmap.getHeight() 
+ ", size = " + bitmap.getByteCount());
}

public void loadXXHDPI(View view) { // 加載drawable-xxhdpi文件夾下的圖片
	Bitmap bitmap = BitmapFactory.decodeResource(getResources(),
                                                      R.drawable.xxhdpi_flower);
	imageView.setImageBitmap(bitmap);
	Log.e(TAG, "xxhdpi bitmap width = " + bitmap.getWidth() + ", height = " 
+ bitmap.getHeight() + ", size = " + bitmap.getByteCount());
}
//~ 運行結果
drawable bitmap width = 2450, height = 2027, size = 19864600
hdpi bitmap width = 1633, height = 1351, size = 8824732
xxhdpi bitmap width = 817, height = 676, size = 2209168

從運行結果可以看出加載的圖片不但長寬尺寸變了,而且圖片佔用的字節數也跟着變化。直接在Windows系統文件夾裏查看原始圖片的寬高爲700*579,在Android加載默認使用的是ARGB_8888也即一個像素使用4個字節保存,Alpha透明度使用1字節保存,Red紅色使用1字節,Green綠色使用1字節保存,Blue藍色同樣使用1字節保存,ARGB_8888裏的8代表8個bit,如果沒有任何的縮放操作原始圖片加載到內存中佔據的字節數應爲700*579*4(1621200),實際上上面輸出的結果沒有一個符合該計算結果,這就牽扯到Android系統屏幕像素密度的問題。

Android手機的屏幕各個廠商都有所不同,不同屏幕的分辨率各不相同,比如常見的1080*1920(橫向1080個顯示點,豎向1920個顯示點),它代表手機在橫豎方向上實際的物理顯示點數量。屏幕像素密度指的是屏幕物理一英寸裏有多少個物理顯示點,它的單位是dpi(dot per inch,每英寸顯示點數)。在Android中通常使用160dpi代表正常的像素密度,也就是中密度,常見的屏幕密度與後綴對應如下表所示。

項目 低分辨率 中分辨率 高分辨率 超高分辨率 超超高分辨率
密度值 120 160 240 320 480
後綴 ldpi mdpi hdpi xhdpi xxhdpi

瞭解像素密度的劃分後再來查看BitmapFactory.decodeResource()方法的底層實現代碼,代碼包含BitmapFactory.java的Java源代碼,也包含通過JNI調用的BitmapFactory.cpp源碼,由於是C++實現只貼出主要計算步驟。

// BitmapFactory加載圖片實現源代碼
// BitmapFactory.java實現源代碼
public static Bitmap decodeResourceStream(Resources res, TypedValue value,InputStream is, 
	Rect pad, Options opts) {
	if (opts.inDensity == 0 && value != null) {
		final int density = value.density;
		if (density == TypedValue.DENSITY_DEFAULT) {
			//inDensity默認爲圖片所在文件夾對應的密度
			opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
		} else if (density != TypedValue.DENSITY_NONE) {
			opts.inDensity = density;
		}
	}
	if (opts.inTargetDensity == 0 && res != null) {
		// inTargetDensity設置當前系統密度,通常和屏幕系統密度一致
		opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
	}
	return decodeStream(is, pad, opts);
}
// BitmapFactory.cpp
// 計算縮放係數
int density = env->GetIntField(options, gOptions_densityFieldID); // 圖片所在文件夾密度
int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID); // 當前系統密度
int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID); // 屏幕係數密度
if (density != 0 && targetDensity != 0 && density != screenDensity) {
	//縮放係數是當前係數密度/圖片所在文件夾對應的密度;
	scale = (float) targetDensity / density;
}
int scaledWidth = decodingBitmap.width(); // 圖片原始寬度
int scaledHeight = decodingBitmap.height(); // 圖片原始高度
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
	scaledWidth = int(scaledWidth * scale + 0.5f); // 圖片縮放後寬度
	scaledHeight = int(scaledHeight * scale + 0.5f); //  圖片縮放後高度
}    

BitmapFactory在加載資源文件中的圖片時會根據資源所在文件夾的密度值和系統當前密度值計算縮放比例scale,得到縮放比例後通過原始高度計算縮放後新高度,最後返回縮放完成的Bitmap對象。現在通過DisplayMetrics接口獲取測試手機的系統密度數據。

// 獲取Android系統的密度係數
public void showScreenInfo(View view) {
	DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
	Log.e(TAG, "width = " + displayMetrics.widthPixels + 
", height = " + displayMetrics.heightPixels);
	Log.e(TAG, "density = " + displayMetrics.density +
 ", densityDpi = " + displayMetrics.densityDpi);
}
//~ 運行結果
width = 1440, height = 2392
density = 3.5, densityDpi = 560

從上面代碼運行結果可以看到測試手機的dpi爲560,測試手機在一英寸長度上有560個物理顯示點,使用560/160結果正好是3.5,也就是說測試手機的使用3.5個顯示點展示一個圖片像素。此時計算處於drawable目錄下其實就是drawable-mdpi的圖片加載到內存的大小,文件夾像素密度值density=160,目標像素密度值targetDensity=560,scale縮放因子的值就是3.5;圖片的原始寬度爲700,縮放後寬度爲int(700 * 3.5 + 0.5)=2050;原始高度值爲579,縮放後的高度值爲int(579 * 3.5 + 0.5)=2027;默認情況下一個像素使用4字節保存,最終加載的圖片大小爲2050*2027*4=19864600,和之前打印的結果值完全一致。其他像素密度文件夾下的圖片加載大小也可以通過上面的方法計算出來,現在來簡單的計算一下它們的結果值。

HDPI:
density=240, targetDensity=560,scale=2.33333333,
width=700, scaleWidth = int(width * scale + 0.5)= int(700 * 2.33333333 + 0.5) = 1633
height=579,scaleHeight=int(height *scale + 0.5)=int(579 * 2.33333333 + 0.5) = 1351
byteCount=1633*1351*4=8824732
XXHDPI:
density=480, targetDensity=560,scale=1.1666666,
width=700, scaleWidth = int(width * scale + 0.5)= int(700 * 1.1666666 + 0.5) = 817
height=579,scaleHeight=int(height *scale + 0.5)=int(579 * 1.1666666 + 0.5) = 676
byteCount=817*676*4=2209168

以上就是資源文件夾加載圖片的內存佔用計算過程,接着再看一下從assets目錄中加載圖片大小是多少,AssetManager對象專門用來管理加載assets目錄下的資源文件。

// 加載assets目錄下圖片
public void loadAsset(View view) {
InputStream inputStream = null;
	inputStream = getAssets().open("flower.png");
	Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
	imageView.setImageBitmap(bitmap);
	Log.e(TAG, "asset bitmap width = " + bitmap.getWidth() + ", height = "
 + bitmap.getHeight() + ", size = " + bitmap.getByteCount());
}
//~ 運行結果: asset bitmap width = 700, height = 579, size = 1621200

從代碼的運行結果可以看出放在assets文件夾下的圖片不會有任何縮放過程,加載圖片就是原始大小,與使用原始尺寸計算出來的佔用大小是一致的。繼續測試其他從本地緩存和網絡加載的圖片它們的大小和assets文件夾一樣都是原始尺寸,不會有圖片縮放的步驟。

圖片內存優化

從前面的圖片內存佔用節可以看出加載圖片佔用的內存大小和圖片本身的分辨率有很大關係,分辨率越大的圖片佔用內存越大,應用中大圖片太多容易將內存消耗殆盡引起OOM異常;分辨率大同樣會導致數據加載到內存過程佔用時間較長,加載這段時間內用戶無法看到真實圖片內容,影響用戶體驗,從網絡加載還會消耗用戶流量,給用戶帶來一定的經濟損失。因此圖片內存佔用不得不做優化處理,應用開發中使用的圖片通常都只是展示在某個部分,大多數的圖片不會把它所有的內容都展示出來,也就是說大部分大圖片其實沒必要按照它原始的大小做加載,只需要加載展示大小即可。

BitmapFactory在加載圖片的時候會提供BitmapFactory.Options選項參數,選項中提供了一個inJustDecodeBounds屬性,如果被設置爲true它不會加載圖片數據而是僅僅將圖片的分辨率大小加載進來,開發者得到圖片的原始尺寸大小和展示圖片視圖的大小計算出採樣率,就可以得到低分辨率的圖片減小內存佔用大小。

// 使用採樣率減小圖片加載大小
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true; // 僅僅獲取圖片原始分辨率
BitmapFactory.decodeResource(getResources(), R.drawable.snow_scienory, options);
Log.e(TAG, "bitmap width = " + options.outWidth + ", height = " + options.outHeight);
// 獲取展示ImageView的大小
int vwidth = imageView.getWidth(), vheight = imageView.getHeight();
int dwidth = options.outWidth, dheight = options.outHeight;
// 計算加載圖片時候的採樣率
int sampleSize = calcSampleSize(vwidth, vheight, dwidth, dheight);
// 計算採樣率很簡單就是不斷對圖片大小做橫豎方向減小一半操作,當圖片在
// 橫豎方法的大小還是比展示視圖還大繼續做減半操作直到有一個方向比展示
// 視圖小就停止減半操作。
private int calcSampleSize(int vwidth, int vheight, int dwidth, int dheight) {
	int sampleSize = 1;
	if (dheight > vheight || dwidth > vwidth) {
		int halfHeight = dheight / 2;
		int halfWidth = dwidth / 2;
		while ((halfHeight / sampleSize) >= vheight && (halfWidth / sampleSize) >= vwidth) {
			sampleSize *= 2;
		}
	}
	return sampleSize;
}
// 計算出採樣率後BitmapFactory.Options選項參數的inJustDecodeBounds需要
// 設置爲false,同時設置inSampleSize爲計算出來的採樣率,再通過BitmapFactory
// 加載圖片就是縮小的圖片大小。
options.inJustDecodeBounds = false;
options.inSampleSize = sampleSize;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), 
R.drawable.snow_scienory, options);
imageView.setImageBitmap(bitmap);
Log.e(TAG, "drawable bitmap width = " + bitmap.getWidth() + ", height = "
 + bitmap.getHeight() + ", size = " + bitmap.getByteCount());

//~ 運行結果
// bitmap width = 5120, height = 2880  // size = 58982400
// vwidth = 350, vheight = 320  dwidth = 5120, dheight = 2880
// sampleSize = 8
// drawable bitmap width = 747, height = 420, size = 1254960

運行結果展示使用採樣率爲8加載出來的圖片佔用內存空間從原始的58M左右減小到新的1M左右,可見優化的比例非常大。除了上面的圖片尺寸優化,每個像素佔有的字節數其實也是可以變的,除了前面介紹的ARGB_8888使用4字節保存一個像素點信息,還能用RGB565每個像素使用兩個字節保存沒有透明度的圖片,ARGB_4444每個像素2個字節保存,透明度和三色圖每個都只用4個bit保存。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章