深入理解Android Bitmap的各種操作
文章目錄
在 Android 開發中,經常和 Bitmap 打交道,不知道你是否真正理解 Bitmap?接下來讓我們一起走進 Bitmap 的世界。
一、Bitmap
Bitmap 代表一個位圖,BitmapDrawable 裡封裝的圖片就是一個 Bitmap 物件。開發者為了把一個 Bitmap 物件包裝成 BitmapDrawable
BitmapDrawable bitmapDrawable = new BitmapDrawable(bitmap);
如果需要獲取 BitmapDrawable 所包裝的 Bitmap 物件,則可以用 BitmapDrawable 的 getBitmap() 的方法:
Bitmap bitmap = bitmapDrawable.getBitmap();
1.1 Bitmap的建立
在 Bitmap 類中, Bitmap 構造方法預設許可權,而且這個類也是 final 的,所以我們無法 new 一個 Bitmap
這些方法大致可以分為三類(本文所用的原始碼是 android - 26):
1.1.1 根據已有的Bitmap來建立新Bitmap
/**
* 通過矩陣的方式,返回原始 Bitmap 中的一個不可變子集。新 Bitmap 可能返回的就是原始的 Bitmap,也可能還是複製出來的。
* 新 Bitmap 與原始 Bitmap 具有相同的密度(density)和顏色空間;
*
* @param source 原始 Bitmap
* @param x 在原始 Bitmap 中 x方向的其起始座標(你可能只需要原始 Bitmap x方向上的一部分)
* @param y 在原始 Bitmap 中 y方向的其起始座標(你可能只需要原始 Bitmap y方向上的一部分)
* @param width 需要返回 Bitmap 的寬度(px)(如果超過原始Bitmap寬度會報錯)
* @param height 需要返回 Bitmap 的高度(px)(如果超過原始Bitmap高度會報錯)
* @param m Matrix型別,表示需要做的變換操作
* @param filter 是否需要過濾,只有 matrix 變換不只有平移操作才有效
*/
public static Bitmap createBitmap(@NonNull Bitmap source, int x, int y, int width, int height,
@Nullable Matrix m, boolean filter)
1.1.2 通過畫素點陣列建立空的Bitmap
/**
*
* 返回具有指定寬度和高度的不可變點陣圖,每個畫素值設定為colors陣列中的對應值。
* 其初始密度由給定的確定DisplayMetrics。新建立的點陣圖位於sRGB 顏色空間中。
* @param display 顯示將顯示此點陣圖的顯示的度量標準
* @param colors 用於初始化畫素的sRGB陣列
* @param offset 顏色陣列中第一個顏色之前要跳過的值的數量
* @param stride 行之間陣列中的顏色數(必須> = width或<= -width)
* @param width 點陣圖的寬度
* @param height 點陣圖的高度
* @param config 要建立的點陣圖配置。如果配置不支援每畫素alpha(例如RGB_565),
* 那麼colors []中的alpha位元組將被忽略(假設為FF)
*/
public static Bitmap createBitmap(@NonNull DisplayMetrics display,
@NonNull @ColorInt int[] colors, int offset, int stride,
int width, int height, @NonNull Config config)
1.1.3 建立縮放的Bitmap
/**
* 對Bitmap進行縮放,縮放成寬 dstWidth、高 dstHeight 的新Bitmap
*/
public static Bitmap createScaledBitmap(@NonNull Bitmap src, int dstWidth, int dstHeight,boolean filter)
二、BitmapFactory
BitmapFactory 是一個工具類,它提供了大量的方法,這個方法可用於不同的資料來源來解析、建立 Bitmap 物件。大概有如下方法:
2.1 建立Bitmap的方法
- decodeByteArray(byte[] data, int offset, int length, Options opts):從指定位元組陣列的 offset 位置開始,將長度 length 的位元組資料解析成 Bitmap 物件。
- decodeFile(String pathName, Options opts):從 pathName 指定的檔案中解析、建立 Bitmap 物件。
- decodeFileDescriptor(FileDescriptor fd, Rect outPadding, Options opts):用於從 FileDescriptor 對應的檔案中解析、建立 Bitmap 物件。
- decodeResource(Resources res, int id, Options opts):用於給定的資源 ID 從指定資源中解析、建立 Bitmap 物件。
- decodeStream(InputStream is, Rect outPadding, Options opts):用於從指定輸入流中解析、建立 Bitmap 物件。
decodeFile 和 decodeResource 其實最終都會呼叫 decodeStream 方法來解析 Bitmap 。有一個特別有意思的事情是,在 decodeResource 呼叫 decodeStream 之前還會呼叫 decodeResourceStream 這個方法,接下來讓我們看看 decodeResourceStream 方法的原始碼:
public static Bitmap decodeResourceStream(Resources res, TypedValue value,
InputStream is, Rect pad, Options opts) {
validate(opts);
if (opts == null) {
opts = new Options();
}
if (opts.inDensity == 0 && value != null) {
final int density = value.density;
if (density == TypedValue.DENSITY_DEFAULT) {
opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
} else if (density != TypedValue.DENSITY_NONE) {
//這裡 density 的值如果對應資源目錄為 hdpi 的話,就是 240;如果是 xhdpi 則是 320;如果是 xxdpi 則是 480
opts.inDensity = density;
}
}
if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
return decodeStream(is, pad, opts);
}
這個方法主要對 Options (詳解的屬性值在下面)進行處理,在得到 opts.inDensity 的屬性前提下,如果沒有對該屬性的設定值,那麼 opts.inDensity = DisplayMetrics.DENSITY_DEFAULT; 這個值預設為標準dpi的基值:160。如果沒有設定 opts.inTargetDensity 的值時,opts.inTargetDensity = res.getDisplayMetrics().densityDpi; 該值為當前裝置的 densityDpi,這個值是根據你放置在 drawable 下的檔案不同而不同的(具體參考 Android 螢幕各種引數的介紹和學習)。
所以說 decodeResourceStream 這個方法主要對 opts.inDensity 和 opts.inTargetDensity進行賦值。那什麼時候使用這個 opts 屬性呢?在將引數傳入 decodeStream方法,該方法在呼叫 native 方法進行解析 Bitmap 後會呼叫 setDensityFromOptions 這個方法:
/**
* Set the newly decoded bitmap's density based on the Options.
*/
private static void setDensityFromOptions(Bitmap outputBitmap, Options opts) {
if (outputBitmap == null || opts == null) return;
//opts.inDensity 這個值會因為你放置在 drawable 下不同解析度的資料夾下而不同
final int density = opts.inDensity;
if (density != 0) {
outputBitmap.setDensity(density);
final int targetDensity = opts.inTargetDensity;
if (targetDensity == 0 || density == targetDensity || density == opts.inScreenDensity) {
return;
}
byte[] np = outputBitmap.getNinePatchChunk();
final boolean isNinePatch = np != null && NinePatch.isNinePatchChunk(np);
if (opts.inScaled || isNinePatch) {
outputBitmap.setDensity(targetDensity);
}
} else if (opts.inBitmap != null) {
// bitmap was reused, ensure density is reset
outputBitmap.setDensity(Bitmap.getDefaultDensity());
}
}
這個方法就是把剛剛賦值過的兩個屬性 inDensity 和 inTargetDensity 給 Bitmap 進行賦值,不過並不是直接賦值給 Bitmap 而是判斷 inDensity 的值與 inTargetDensity 或 該裝置螢幕 Density 不相等 ,而且 opts.inScaled = true 時,條件才成立。具體的計算見: 3.2 手動計算
從上面的原始碼可以得出一個重要的結論:
- decodeResource 在解析時會對 Bitmap 根據當前裝置螢幕密度 densityDpi 的值進行縮放適配操作,使得解析出來的 Bitmap 與當前裝置解析度匹配,並且一般來說,這時 Bitmap 的大小將比原始的 Bitmap 大。
- decodeFile、decodeStream 在解析時不會對 Bitmap 進行一系列的螢幕適配,解析出來的將是原始大小的圖。
2.2 BitmapFactory.Options的屬性解析
在使用 BitmapFactory 時 Options 這個靜態內部類經常用到,裡面有很多經常使用的屬性,讓我們來看一些比較重要的:
- inJustDecodeBounds:如果這個值為 true ,那麼在解碼的時候將不會返回 Bitmap ,只會返回這個 Bitmap 的尺寸。這個屬性的目的是,如果你只想知道一個 Bitmap 的尺寸,但又不想將其載入到記憶體中時,是一個非常好用的屬性。
- outWidth和outHeight:表示這個 Bitmap 的寬和高,一般和 inJustDecodeBounds 一起使用來獲得 Bitmap的寬高,但是不載入到記憶體。
- inSampleSize:壓縮圖片時取樣率的值,如果這個值大於1,那麼就會按照比例(1 / inSampleSize)來縮小 Bitmap 的寬和高。如果這個值為 2,那麼 Bitmap 的寬為原來的1/2,高為原來的1/2,那麼這個 Bitmap 是所佔記憶體畫素值會縮小為原來的 1/4。
- inDensity:表示這個 Bitmap 的畫素密度,對應的是 DisplayMetrics 中的 densityDpi,不是 density。(如果不明白它倆之間的異同,可以看我的 Android 螢幕各種引數的介紹和學習 )
- inTargetDensity:表示要被新 Bitmap 的目標畫素密度,對應的是 DisplayMetrics 中的 densityDpi。
- inScreenDensity:表示實際裝置的畫素密度,對應的是 DisplayMetrics 中的 densityDpi。
- inPreferredConfig:這個值是設定色彩模式,預設值是 ARGB_8888,這個模式下,一個畫素點佔用 4Byte 。RGB_565 佔用 2Byte,ARGB_4444 佔用 4Byte(以廢棄)。
- inPremultiplied:這個值和透明度通道有關,預設值是 true,如果設定為 true,則返回的 Bitmap 的顏色通道上會預先附加上透明度通道。
- inDither:這個值和抖動解碼有關,預設值為 false,表示不採用抖動解碼。
- inScaled:設定這個Bitmap 是否可以被縮放,預設值是 true,表示可以被縮放。
- inPreferQualityOverSpeed:這個值表示是否在解碼時圖片有更高的品質,僅用於 JPEG 格式。如果設定為 true,則圖片會有更高的品質,但是會解碼速度會很慢。
- inBitmap:這個引數用來實現 Bitmap 記憶體的複用,但複用存在一些限制,具體體現在:在 Android 4.4 之前只能重用相同大小的 Bitmap 的記憶體,而 Android 4.4 及以後版本則只要後來的 Bitmap 比之前的小即可。使用 inBitmap 引數前,每建立一個 Bitmap 物件都會分配一塊記憶體供其使用,而使用了 inBitmap 引數後,多個 Bitmap 可以複用一塊記憶體,這樣可以提高效能。
三、計算Bitmap的大小
3.1 Android API 的方法
在使用 Bitmap 時經常會出現 OOM 的現象(這是多麼讓人心痛的錯誤啊),那麼一張圖片究竟是有多大呢?在 Bitmap 中提供了一個供我們檢視 Bitmap 大小的 getByteCount() 方法:
public final int getByteCount() {
if (mRecycled) {
Log.w(TAG, "Called getByteCount() on a recycle()'d bitmap! "
+ "This is undefined behavior!");
return 0;
}
// int result permits bitmaps up to 46,340 x 46,340
return getRowBytes() * getHeight();
}
這個是 Android API所給的方法,當我們想進一步看看它是怎麼實現的時候卻發現 getRowBytes 呼叫的是 native 方法。那麼我們能不能給一張在某個手機上的圖片,你就知道 Bitmap 所在記憶體的大小呢?為了探究 Bitmap 的奧祕,我們去手動計算一張 Bitmap 的大小。
3.2 手動計算
下載了 Android framework 原始碼,Bitmap 相關的原始碼在檔案下:frameworks\base\core\jni\android\graphics,找到 Bitmap.cpp, getRowBytes() 對應的函式為:
static jint Bitmap_rowBytes(JNIEnv* env, jobject, jlong bitmapHandle) {
LocalScopedBitmap bitmap(bitmapHandle);
return static_cast<jint>(bitmap->rowBytes());
}
SkBitmap.cpp:
size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);
}
SkImageInfo.h:
static int SkColorTypeBytesPerPixel(SkColorType ct) {
static const uint8_t gSize[] = {
0, // Unknown
1, // Alpha_8
2, // RGB_565
2, // ARGB_4444
4, // RGBA_8888
4, // BGRA_8888
1, // kIndex_8
};
SK_COMPILE_ASSERT(SK_ARRAY_COUNT(gSize) == (size_t)(kLastEnum_SkColorType + 1),
size_mismatch_with_SkColorType_enum);
SkASSERT((size_t)ct < SK_ARRAY_COUNT(gSize));
return gSize[ct];
}
static inline size_t SkColorTypeMinRowBytes(SkColorType ct, int width) {
return width * SkColorTypeBytesPerPixel(ct);
}
順便提一下,Bitmap 本質上是一個 SKBitmap ,而這個 SKBitmap 也是大有來頭,它來自 Skia 。這個是 Android 2D 影象引擎,而且也是 flutter 的影象引擎。我們發現 ARGB_8888(也就是我們最常用的 Bitmap 的格式)的一個畫素佔用 4byte,那麼 rowBytes 實際上就是 4*width bytes。那麼一行圖片所佔的記憶體計算公式:
圖片的佔用記憶體 = 圖片的長度(畫素單位) * 圖片的寬度(畫素單位) * 單位畫素所佔位元組數
但需要注意的是, 在使用decodeResource 獲得的 Bitmap 的時候,上面的計算公式並不準確。讓我們來看看原因。decodeStream 會呼叫 native 方法 nativeDecodeStream 最終會呼叫BitmapFactory.cpp 的 doDecode函式:
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {
// .....省略
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
//對應不同 dpi 的值不同,這個值跟這張圖片的放置的目錄有關,如 hdpi 是240;xdpi 是320;xxdpi 是480。
const int density = env->GetIntField(options, gOptions_densityFieldID);
//特定手機的螢幕畫素密度不同,如華為p20 pro targetDensity是480
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
//縮放比例(這個是重點)
scale = (float) targetDensity / density;
}
}
}
//這裡這個deodingBitmap就是解碼出來的bitmap,大小是圖片原始的大小
int scaledWidth = size.width();
int scaledHeight = size.height();
bool willScale = false;
// Apply a fine scaling step if necessary.
if (needsFineScale(codec->getInfo().dimensions(), size, sampleSize)) {
willScale = true;
scaledWidth = codec->getInfo().width() / sampleSize;
scaledHeight = codec->getInfo().height() / sampleSize;
}
// Scale is necessary due to density differences.
//進行縮放後的高度和寬度
if (scale != 1.0f) {
willScale = true;
scaledWidth = static_cast<int>(scaledWidth * scale + 0.5f);//①
scaledHeight = static_cast<int>(scaledHeight * scale + 0.5f);
}
//.......省略
if (willScale) {
// This is weird so let me explain: we could use the scale parameter
// directly, but for historical reasons this is how the corresponding
// Dalvik code has always behaved. We simply recreate the behavior here.
// The result is slightly different from simply using scale because of
// the 0.5f rounding bias applied when computing the target image size
//sx 和 sy 實際上約等於 scale ,因為在①出可以看出scaledWidth 和 scaledHeight 是由 width 和 height 乘以 scale 得到的。
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
// Set the allocator for the outputBitmap.
SkBitmap::Allocator* outputAllocator;
if (javaBitmap != nullptr) {
outputAllocator = &recyclingAllocator;
} else {
outputAllocator = &defaultAllocator;
}
SkColorType scaledColorType = colorTypeForScaledOutput(decodingBitmap.colorType());
// FIXME: If the alphaType is kUnpremul and the image has alpha, the
// colors may not be correct, since Skia does not yet support drawing
// to/from unpremultiplied bitmaps.
outputBitmap.setInfo(
bitmapInfo.makeWH(scaledWidth, scaledHeight).makeColorType(scaledColorType));
if (!outputBitmap.tryAllocPixels(outputAllocator, NULL)) {
// This should only fail on OOM. The recyclingAllocator should have
// enough memory since we check this before decoding using the
// scaleCheckingAllocator.
return nullObjectReturn("allocation failed for scaled bitmap");
}
SkPaint paint;
// kSrc_Mode instructs us to overwrite the uninitialized pixels in
// outputBitmap. Otherwise we would blend by default, which is not
// what we want.
paint.setBlendMode(SkBlendMode::kSrc);
paint.setFilterQuality(kLow_SkFilterQuality); // bilinear filtering
SkCanvas canvas(outputBitmap, SkCanvas::ColorBehavior::kLegacy);
canvas.scale(sx, sy);//canvas進行縮放
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
} else {
outputBitmap.swap(decodingBitmap);
}
//.......省略
}
所以,sx 和 sy 約等於 scale 的值,而 scale = (float) targetDensity / density;,那麼我們來計算,在一張4000 * 3000 的jpg 圖片,我們把它放在 drawable - xhdpi 目錄下,在華為 P20 Pro 上載入,這個手機上densityDpi為 480,用 getByteCount 算出佔用記憶體為 108000000。
我們來手動計算:
sx = 480/320,
sy = 480/320,
4000 * sx *3000 * sy * 4 = 108000000
如果你自己算你手機上的可能和 getByteCount 不一致,那是因為精度不一樣,大家可以看到上面原始碼 ①處,scaledWidth = static_cast(scaledWidth * scale + 0.5f); 它是這樣算的,所以我們可以用:
scaledWidth = int (480/320 *4000 + 0.5)
scaledHeight = int (480/320 *3000 + 0.5)
Bitmap所佔記憶體空間為:scaledWidth * scaledHeight * 4
所以這時Bitmap所佔記憶體空間的方式為:
圖片的佔用記憶體 = 縮放後圖片的長度(畫素單位) * 縮放後圖片的寬度(畫素單位) * 單位畫素所佔位元組數
總結:這個方法主要是讓我們知道為什麼同一張圖片放在不同解析度的檔案下,Bitmap 所佔記憶體空間的不同。而且這個計算方式是用 decodeResource 來得到Bitmap 的大小時,才有效。而用 decodeFile、decodeStream直接計算就行,沒有縮放寬和高的整個過程。
四、Bitmap的縮放
4.1 質量壓縮
質量壓縮不會改變圖片的畫素點,即我們使用完質量壓縮後,在轉換 Bitmap 時佔用記憶體依舊不會減小。但是可以減少我們儲存在本地檔案的大小,即放到 disk上的大小。如果減少 Bitmap 載入到記憶體的大小,可以用採用壓縮。下面是質量壓縮的程式碼:
/**
* 質量壓縮方法,並不能減小載入到記憶體時所佔用記憶體的空間,應該是減小的所佔用磁碟的空間
* @param image
* @param compressFormat
* @return
*/
public static Bitmap compressbyQuality(Bitmap image, Bitmap.CompressFormat compressFormat) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
image.compress(compressFormat, 100, baos);//質量壓縮方法,這裡100表示不壓縮,把壓縮後的資料存放到baos中
int quality = 100;
while ( baos.toByteArray().length / 1024 > 100) { //迴圈判斷如果壓縮後圖片是否大於100kb,大於繼續壓縮
baos.reset();//重置baos即清空baos
if(quality > 10){
quality -= 20;//每次都減少20
}else {
break;
}
image.compress(Bitmap.CompressFormat.JPEG, quality, baos);//這裡壓縮options%,把壓縮後的資料存放到baos中
}
ByteArrayInputStream isBm = new ByteArrayInputStream(baos.toByteArray());//把壓縮後的資料baos存放到ByteArrayInputStream中
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap bmp = BitmapFactory.decodeStream(isBm, null, options);//把ByteArrayInputStream資料生成圖片
return bmp;
}
4.2 取樣壓縮
這個方法主要用在圖片資源本身較大,或者適當地取樣並不會影響視覺效果的條件下,這時候我們輸出的目標可能相對的較小,對圖片的大小和解析度都減小。取樣壓縮最典型的程式碼如下:
BitmapFactory.Options options = new Options();
options.inSampleSize = 2;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), resId, options);
4.3 使用矩陣
前面我們採用了取樣壓縮,Bitmap 所佔用的記憶體是小了,可是圖的尺寸也小了。當我們需要尺寸較大時該怎麼辦?我們要用用 Canvas 繪製怎麼辦?當然可以用矩陣(Matrix):
/**
* 矩陣縮放圖片
* @param sourceBitmap
* @param width 要縮放到的寬度
* @param height 要縮放到的長度
* @return
*/
private Bitmap getScaleBitmap(Bitmap sourceBitmap,float width,float height){
Bitmap scaleBitmap;
//定義矩陣物件
Matrix matrix = new Matrix();
float scale_x = width/sourceBitmap.getWidth();
float scale_y = height/sourceBitmap.getHeight();
matrix.postScale(scale_x,scale_y);
try {
scaleBitmap = Bitmap.createBitmap(sourceBitmap,0,0,sourceBitmap.getWidth(),sourceBitmap.getHeight(),matrix,true);
}catch (OutOfMemoryError e){
scaleBitmap = null;
System.gc();
}
return scaleBitmap;