Android中Bitmap的深入探討總結
由於最近公司對影象這一塊做文章比較多,而自己對於Bitmap的認識確實也比較淺顯,因此花些功夫研究一下Bitmap的使用以及原理,寫下該篇文章記錄一下學習過程。
關於系統Graphics的研究需要擱置一段時間了,原因是看了老羅的文章,發現自己的表達能力真是相差甚大,為了不誤人子弟,打算熟讀老羅的分析後在進行歸納總結。
文章主要圍繞著如下幾個問題展開分析探討:
- Bitmap是什麼?它跟JPG,PNG,WEBP等有什麼區別?
- Andorid中的Bitmap使用方式?
- Android中Bitmap的記憶體佔用?
- Android中Bitmap為什麼出現OOM的問題?Bitmap的記憶體管理?
- Android中Bitmap的尺寸壓縮與質量壓縮?
Bitmap的概念以及跟JPG,PNG,WEBP的區別
Bitmap是由畫素(Pixel)組成的,畫素是點陣圖最小的資訊單元,儲存在影象柵格中。 每個畫素都具有特定的位置和顏色值。按從左到右、從上到下的順序來記錄影象中每一個畫素的資訊,如:畫素在螢幕上的位置、畫素的顏色等。點陣圖影象質量是由單位長度內畫素的多少來決定的。單位長度內畫素越多,解析度越高,影象的效果越好。點陣圖也稱為“點陣圖影象”“點陣影象”“資料影象”“數碼影象”。一個畫素點可以由1,4,16,24,32bit來表示,畫素點的色彩越豐富,自然影象的效果就越好了。
上面的介紹引用自百度百科,點陣圖檔案(注意是點陣圖檔案)的字尾一般是**.bmp或者.dib**。點陣圖概念來自於Windows,是Windows的標準圖形檔案,我們在Windows中看到的預設背景圖其實就是一張點陣圖檔案,有興趣的朋友可以看看自家Windows電腦的背景圖。一個位圖儲存檔案的結構如下所示:
具體的結構解析就不深入了,畢竟術業有專攻,我們只要知道概念即可,詳細的可以查閱該篇文章。
點陣圖檔案不等於點陣圖(Bitmap)
接下來介紹兩個概念:位深以及色深
- 色深:表示一個畫素點可以有多少種色彩來描述,它的單位是bit,拿點陣圖而言,其支援RGB各8bit,所以說點陣圖的色深為24bit。
- 位深:位深主要表示儲存每個畫素所用的位數,主要用於實際影象檔案的儲存。
下面貼個網上的例子理解一下這兩個概念:
100畫素x100畫素的圖片, 使用ARGB_8888,所以色深32位,儲存時選擇位深為24位,則在記憶體中所佔大小為:100 x100 x (32 / 8)Byte,而在檔案所佔大小為** 100 x100 x( 24/ 8 ) x 壓縮效率 Byte**。
我們可以寫個程式碼驗證看看是否是這樣的,直接載入一張圖片出來試下看看:
private void testCompress() {
try {
File file = new File(getInnerSDCardPath() + File.separator + "001LQK0Czy74OrXDiVLdd&690.jpeg");
Log.e("compress", "檔案大小=" + file.length());
Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
最後載入後的效果如下:
可以看到載入到記憶體後確實變成了32bit的影象,而載入之前就是24bit。
ok,接下來說說Bitmap和JPG,PNG,WEBP區別。其實Bitmap通俗意義上講就是一張圖片在記憶體中表現的完整形式,裡面包含的都是畫素點,而bmp,jpg,png,webp則是Bitmap在硬碟儲存的格式,可以理解成一個壓縮包的概念,所以儲存下來的檔案相比於記憶體展現的會小很多。
- JPG:JPG全名是JPEG,是圖片的一種格式。JPEG圖片以24位顏色儲存單個位圖。JPEG是與平臺無關的格式,支援最高級別的壓縮,不過,這種壓縮是有損耗的。這裡注意JPG不支援透明通道,所以是24位而不是32位。
- PNG:行動式網路圖形是一種無失真壓縮的點陣圖片形格式,其設計目的是試圖替代GIF和TIFF檔案格式,同時增加一些GIF檔案格式所不具備的特性。PNG使用從LZ77派生的無損資料壓縮演算法,一般應用於JAVA程式、網頁或S60程式中,原因是它壓縮比高,生成檔案體積小。PNG以32位顏色儲存單個位圖。
- WEBP:WebP格式,谷歌(google)開發的一種旨在加快圖片載入速度的圖片格式。圖片壓縮體積大約只有JPEG的2/3,並能節省大量的伺服器寬頻資源和資料空間。Facebook Ebay等知名網站已經開始測試並使用WebP格式。WebP既支援有失真壓縮也支援無失真壓縮。相較編碼JPEG檔案,編碼同樣質量的WebP檔案需要佔用更多的計算資源。詳細資料可以看下騰訊Bugly團隊寫的文章:WebP原理和Android支援現狀介紹。
上面介紹中提到了有損以及無損,這兩個的概念如下:
- 有失真壓縮。指在壓縮檔案大小的過程中,損失了一部分圖片的資訊,也即降低了圖片的質量,並且這種損失是不可逆的,我們不可能從有一個有失真壓縮過的圖片中恢復出全來的圖片。常見的有失真壓縮手段,是按照一定的演算法將臨近的畫素點進行合併。
- 無失真壓縮。只在壓縮檔案大小的過程中,圖片的質量沒有任何損耗。我們任何時候都可以從無失真壓縮過的圖片中恢復出原來的資訊。
Android中的Bitmap
在Android中解析獲取Bitmap的方式存在於BitmapFactory.java
工廠類當中,該類中提供瞭解析檔案,解析流,解析Resource以及解析Asset中圖片檔案的方式,具體的使用方法如下:
這裡對Options引數進行一個說明,Options物件能夠支援對圖片進行一些預處理的操作,其內部變數如下所示:
public static class Options {
public Options() {
inDither = false;
inScaled = true; //預設允許縮放影象
inPremultiplied = true;
}
public Bitmap inBitmap; //涉及重用Bitmap相關知識
//返回的Bitmap是否可變(可操作)
public boolean inMutable;
//只獲取圖片相關引數(如寬高)不載入圖片
public boolean inJustDecodeBounds;
//設定取樣率
public int inSampleSize;
//Bitmap.Config的四種列舉型別,預設使用Bitmap.Config.ARGB_8888
public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;
//如果被設定為true(預設值),在圖片被顯示出來之前各個顏色通道會被事先乘以它的alpha值,如果圖片是由系統直接繪製或者是由Canvas繪製,這個值不應該被設定為false,否則會發生RuntimeException
public boolean inPremultiplied;
//處理圖片抖動,如果設定為true,則如果影象存在抖動,就處理抖動,設定為false則不管抖動問題
public boolean inDither;
//原影象的畫素密度,跟縮放inScale有關
public int inDensity;
//目標圖片畫素密度,跟縮放inScale有關
public int inTargetDensity;
//螢幕畫素密度
public int inScreenDensity;
//是否允許縮放影象
public boolean inScaled;
// 5.0以上的版本標記過時了
public boolean inPurgeable;
//// 4.4.4以上版本忽略
public boolean inInputShareable;
//是否支援Android本身處理優化圖片,從而載入更高質量的圖片
public boolean inPreferQualityOverSpeed;
//圖片寬度
public int outWidth;
//圖片高度
public int outHeight;
//返回圖片mimetype,可能為null
public String outMimeType;
//圖片解碼的臨時儲存空間,預設值為16K
public byte[] inTempStorage;
..
}
Bitmap.Config
這裡先需要介紹的是Bitmap.Config,有6個值:
ARGB指的是一種色彩模式,裡面A代表Alpha,R表示red,G表示green,B表示blue
- ALPHA_8:代表8位Alpha點陣圖(沒有儲存任何的色彩資訊,每一個畫素只需要1byte儲存);
- ARGB_4444:代表16位ARGB點陣圖,質量太差,Android不建議使用,建議使用ARGB_8888;
- ARGB_8888:代表32位ARGB點陣圖,並且可以提供最好質量的圖片顯示,A,R,G.B各佔8bit,。
- RGB_565:代表16位RGB點陣圖,不儲存Alpha值,只用2bytes儲存RGB資訊,其中R為5bit,G為6bit,而B為5bit。
- HARDWARE:該模式表示硬體點陣圖,該模式的作用可以檢視Glide對他的解釋,這裡不過多討論。
- RGBA_F16:該模式不太特別清楚,有待研究。
上面可以看出RGB_565相比於ARGB_8888來說,記憶體佔用會減少一半,但是其捨棄了透明度,同時三色值也有部分損失,雖然圖片失真度很小。而ALPHA_8使用情景有限,ARGB_4444官方不推薦使用,所以本文研究的著重點就在ARGB_8888以及RGB_565上,當時具體使用策略按需而定,如圖片庫Glide就是使用RGB_565來減少Bitmap的記憶體佔用。下面我們從程式碼的角度驗證一下正確性:
原圖大小為:寬x高=690x975
File file = new File(getInnerSDCardPath() + File.separator + "001LQK0Czy74OrXDiVLdd&690.jpeg");
Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap bitmap2 = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
Log.e("compress", "bitmap1記憶體佔用大小=" + bitmap1.getByteCount()+" bitmap2記憶體佔用大小="+bitmap2.getByteCount());
列印Log的結果如下:
bitmap1記憶體佔用大小=2691000 bitmap2記憶體佔用大小=1345500
可以看到,對於同一張圖片而言,RGB_565確實圖片記憶體佔用減少了一半,因此在對圖片質量要求不是特別高的情況下,如資訊流的小圖,其實使用該模式是非常不錯的。
inBitmap
接下來再看下inBitmap引數,在Android3.0版本後,該引數就在原始碼中加上了,該引數的意義在於複用當前Bitmap所申請的記憶體空間,以優化釋放舊Bitmap記憶體以及重新申請Bitmap記憶體導致的效能損耗。這裡討論的版本為Android4.4.4以後,在該版本以後,使用該引數需要滿足如下條件:
- Bitmap本身可可變的(mutable)
- 新的Bitmap的記憶體需要小於等於舊的Bitmap的記憶體
- 新申請的bitmap與舊的bitmap必須有相同的解碼格式,如:使用了ARGB_8888就不能再使用RGB_565的解碼模式了。
滿足了上面兩個條件,就可以重新複用記憶體,而不需要額外申請了,具體的使用教程移步Andorid官方教程: Managing Bitmap Memory,這裡就不深入了。
decodeFile(...)的記憶體佔用情況
關於decodeFile(...)方式加載出來的Bitmap本質上是呼叫decodeStream(...)
進行的,上面程式碼再貼下:
File file = new File(getInnerSDCardPath() + File.separator + "001LQK0Czy74OrXDiVLdd&690.jpeg");
Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
解碼模式用ARGB_8888,最後佔用的記憶體大小是2691000,解析後獲得的寬x高=690x975,即2691000=690x975x4,發現加載出來的圖片確實是原圖大小,那麼如果加上Options引數的設定呢,上面分析Options物件的構成,我們可以發現可能影響記憶體大小的引數會有inScaled,inScreenDensity,inDensity等等,那麼怎麼去驗證呢?最簡單的方法就是看Native原始碼,所以這裡跟蹤一下原始碼,然後在用程式碼確認一遍。decodeFile(...)
最終會呼叫到如下方法:
private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
byte [] tempStorage = null;
if (opts != null) tempStorage = opts.inTempStorage;//使用解碼臨時快取區,預設為16K
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}
Native呼叫在BitmapFactory.cpp中:
static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,
jobject padding, jobject options) {
return nativeDecodeStreamScaled(env, clazz, is, storage, padding, options, false, 1.0f);
}
static jobject nativeDecodeStreamScaled(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,
jobject padding, jobject options, jboolean applyScale, jfloat scale) {
jobject bitmap = NULL;
//建立SkStream流
SkStream* stream = CreateJavaInputStreamAdaptor(env, is, storage, 0);
if (stream) {
// for now we don't allow purgeable with java inputstreams
bitmap = doDecode(env, stream, padding, options, false, false, applyScale, scale);
stream->unref();
}
return bitmap;
}
到這可以看見Skia的影子了,Skia 是 Google 一個底層的圖形、影象、動畫、 SVG 、文字等多方面的圖形庫,是 Android 中圖形系統的引擎,主要支援Android的2D影象操作,3D自然就是Opengl es了。關於Skia本身我也瞭解的不是很多,但是這裡並不需要用到相關知識,邏輯還是能夠理清,因此我們繼續跟蹤doDecode(...)
:
//4.4w版本程式碼
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding,
jobject options, bool allowPurgeable, bool forcePurgeable = false) {
int sampleSize = 1;
//1.解碼的模式,主要有兩個,一個是kDecodeBounds_Mode,該模式下只返回Bitmap的寬高以及一些Config引數;
//另外一個是kDecodePixels_Mode,返回完整的圖片以及相關資訊
SkImageDecoder::Mode mode = SkImageDecoder::kDecodePixels_Mode;
//Java層Config對應native層的Config,可以看到預設是使用ARGB_8888來處理圖片
SkBitmap::Config prefConfig = SkBitmap::kARGB_8888_Config;
bool doDither = true;
bool isMutable = false;
float scale = 1.0f;
////isPurgeable=true
bool isPurgeable = forcePurgeable || (allowPurgeable && optionsPurgeable(env, options));
bool preferQualityOverSpeed = false;
bool requireUnpremultiplied = false;
jobject javaBitmap = NULL;
if (options != NULL) {
sampleSize = env->GetIntField(options, gOptions_sampleSizeFieldID);
//可以看到這步,如果Java層設定了inJustDecodeBounds,那麼使用kDecodeBounds_Mode模式,只獲取寬高以及一些資訊,而不是去載入圖片
if (optionsJustBounds(env, options)) {
mode = SkImageDecoder::kDecodeBounds_Mode;
}
// initialize these, in case we fail later on
env->SetIntField(options, gOptions_widthFieldID, -1);
env->SetIntField(options, gOptions_heightFieldID, -1);
env->SetObjectField(options, gOptions_mimeFieldID, 0);
jobject jconfig = env->GetObjectField(options, gOptions_configFieldID);
prefConfig = GraphicsJNI::getNativeBitmapConfig(env, jconfig);
isMutable = env->GetBooleanField(options, gOptions_mutableFieldID);
doDither = env->GetBooleanField(options, gOptions_ditherFieldID);
preferQualityOverSpeed = env->GetBooleanField(options,
gOptions_preferQualityOverSpeedFieldID);
requireUnpremultiplied = !env->GetBooleanField(options, gOptions_premultipliedFieldID);
//獲取可重用的Bitmap,即當前Bitmap設定的inBitmap引數不為空情況下用到
javaBitmap = env->GetObjectField(options, gOptions_bitmapFieldID);
//可以設定scale
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);
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;
}
}
}
//這裡情境下為false
const bool willScale = scale != 1.0f;
//這裡重新設定了isPurgeable引數,如果不在縮放情況下,那麼isPurgeable恆等於false,當前情況下=false
isPurgeable &= !willScale;
...
SkBitmap* outputBitmap = NULL;
unsigned int existingBufferSize = 0;
if (javaBitmap != NULL) {
outputBitmap = (SkBitmap*) env->GetIntField(javaBitmap, gBitmap_nativeBitmapFieldID);
if (outputBitmap->isImmutable()) {
ALOGW("Unable to reuse an immutable bitmap as an image decoder target.");
javaBitmap = NULL;
outputBitmap = NULL;
} else {
existingBufferSize = GraphicsJNI::getBitmapAllocationByteCount(env, javaBitmap);
}
}
SkAutoTDelete<SkBitmap> adb(outputBitmap == NULL ? new SkBitmap : NULL);
if (outputBitmap == NULL) outputBitmap = adb.get();
NinePatchPeeker peeker(decoder);
decoder->setPeeker(&peeker);
SkImageDecoder::Mode decodeMode = isPurgeable ? SkImageDecoder::kDecodeBounds_Mode : mode;
JavaPixelAllocator javaAllocator(env);
RecyclingPixelAllocator recyclingAllocator(outputBitmap->pixelRef(), existingBufferSize);
ScaleCheckingAllocator scaleCheckingAllocator(scale, existingBufferSize);
SkBitmap::Allocator* outputAllocator = (javaBitmap != NULL) ?
(SkBitmap::Allocator*)&recyclingAllocator : (SkBitmap::Allocator*)&javaAllocator;
if (decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
if (!willScale) {
// If the java allocator is being used to allocate the pixel memory, the decoder
// need not write zeroes, since the memory is initialized to 0.
decoder->setSkipWritingZeroes(outputAllocator == &javaAllocator);
decoder->setAllocator(outputAllocator);
} else if (javaBitmap != NULL) {
// check for eventual scaled bounds at allocation time, so we don't decode the bitmap
// only to find the scaled result too large to fit in the allocation
decoder->setAllocator(&scaleCheckingAllocator);
}
}
...
if (options != NULL && env->GetBooleanField(options, gOptions_mCancelID)) {
return nullObjectReturn("gOptions_mCancelID");
}
SkBitmap decodingBitmap;
if (!decoder->decode(stream, &decodingBitmap, prefConfig, decodeMode)) {
return nullObjectReturn("decoder->decode returned false");
}
//獲取寬高
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
if (willScale && mode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
// update options (if any)
if (options != NULL) {
env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
env->SetObjectField(options, gOptions_mimeFieldID,
getMimeTypeString(env, decoder->getFormat()));
}
//inJustDecodeBounds=true則直接返回null,不對圖片進行解析載入
if (mode == SkImageDecoder::kDecodeBounds_Mode) {
return NULL;
}
...
if (willScale) {
//通過畫布的方式縮放Bimap
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
SkBitmap::Config config = configForScaledOutput(decodingBitmap.config());
outputBitmap->setConfig(config, scaledWidth, scaledHeight, 0,
decodingBitmap.alphaType());
if (!outputBitmap->allocPixels(outputAllocator, NULL)) {
return nullObjectReturn("allocation failed for scaled bitmap");
}
// If outputBitmap's pixels are newly allocated by Java, there is no need
// to erase to 0, since the pixels were initialized to 0.
if (outputAllocator != &javaAllocator) {
outputBitmap->eraseColor(0);
}
SkPaint paint;
paint.setFilterLevel(SkPaint::kLow_FilterLevel);
SkCanvas canvas(*outputBitmap);
canvas.scale(sx, sy);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
} else {
outputBitmap->swap(decodingBitmap);
}
...
SkPixelRef* pr;
if (isPurgeable) {
pr = installPixelRef(outputBitmap, stream, sampleSize, doDither);
} else {
// if we get here, we're in kDecodePixels_Mode and will therefore
// already have a pixelref installed.
pr = outputBitmap->pixelRef();
}
if (pr == NULL) {
return nullObjectReturn("Got null SkPixelRef");
}
if (!isMutable && javaBitmap == NULL) {
// promise we will never change our pixels (great for sharing and pictures)
pr->setImmutable();
}
// detach bitmap from its autodeleter, since we want to own it now
adb.detach();
//如果有重用的Bitmap,則返回
if (javaBitmap != NULL) {
bool isPremultiplied = !requireUnpremultiplied;
GraphicsJNI::reinitBitmap(env, javaBitmap, outputBitmap, isPremultiplied);
outputBitmap->notifyPixelsChanged();
// If a java bitmap was passed in for reuse, pass it back
return javaBitmap;
}
int bitmapCreateFlags = 0x0;
if (isMutable) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Mutable;
if (!requireUnpremultiplied) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Premultiplied;
// 建立新Bitmap
return GraphicsJNI::createBitmap(env, outputBitmap, javaAllocator.getStorageObj(),
bitmapCreateFlags, ninePatchChunk, layoutBounds, -1);
}
上面程式碼中有兩個非常重要的引數:willScale,isPurgeable,這兩個引數直接或間接影響圖片的記憶體佔用以及管理,willScale表示圖片是否需要縮放操作,而isPurgeable則代表圖片的記憶體管理方式,不設定對應inDensity,inTargetDensity,inScreenDensity,willScale都是false,不涉及到縮放,所以加載出來的圖片就是原圖片大小,記憶體自然也是無變化。而isPurgeable的值在當前條件下則為false,如果為True的話那麼會走到installPixelRef(...)
方法中:
static SkPixelRef* installPixelRef(SkBitmap* bitmap, SkStream* stream,
int sampleSize, bool ditherImage) {
SkImageRef* pr;
// only use ashmem for large images, since mmaps come at a price
if (bitmap->getSize() >= 32 * 1024) {
pr = new SkImageRef_ashmem(stream, bitmap->config(), sampleSize);
} else {
pr = new SkImageRef_GlobalPool(stream, bitmap->config(), sampleSize);
}
pr->setDitherImage(ditherImage);
bitmap->setPixelRef(pr)->unref();
pr->isOpaque(bitmap);
return pr;
}
通過查閱資料可知如果圖片大小(佔用記憶體)大於32×1024=32K,那麼就使用Ashmem,否則就就放入一個引用池中。如果圖片不大,直接放到native層記憶體中,讀取方便且迅速。如果圖片過大,放到native層記憶體也就不合理了,不然圖片一多,native層記憶體很難管理。但是如果使用Ashmem匿名共享記憶體方式,寫入到裝置檔案中,需要時再讀取就能避免很大的記憶體消耗了。
不過這是針對於5.0以下的版本使用,在5.0及以上的版本被標記為Deprecated,即使inPurgeable=true,也不會再使用Ashmem記憶體存放圖片,而是直接放到了Java Heap中,簡而言之就是inPurgeable屬性被忽略了(下面在分析decodeResource(...)時候使用6.0版本來分析,可以看到isPurgeable引數已經消失了)。
在查閱相關資料發現Andorid O版本好像針對Bitmap的分配策略又不同了,詳細的可以參考這篇文章,這裡我並沒有檢視原始碼驗證,因此僅供參考吧
因為Android系統從5.0開始對Java Heap記憶體管理做了大幅的優化。和以往不同的是,物件不再統一管理和回收,而是在Java Heap中單獨開闢了一塊區域用來存放大型物件,比如Bitmap這種,同時這塊記憶體區域的垃圾回收機制也是和其它區域完全分開的,這樣就使得OOM的概率大幅降低,而且讀取效率更高。所以,用Ashmem來儲存圖片就完全沒有必要了,何況Ashmem還會導致效能問題。這裡我們到時候看下再處理decodeResource(...)
時候的邏輯。
對於通過decodeFile(...)
載入Bitmap的流程分析完畢了,總結一下在使用decodeFile(...)
的時候,不設定對應inDensity,inTargetDensity,inScreenDensity,系統是不會對Bitmap進行縮放操作,載入的是原圖。如果設定了inDensity,inTargetDensity,inScreenDensity,並且滿足縮放條件,則走的流程跟decodeResource(...)
一致。
這裡記錄一下工作期間遇到的一個問題,通過decodeFile(...)
加載出Bitmap後,再把Bitmap重新儲存發現舊圖片和新圖片大小是不一樣的:
try {
File file = new File(Environment.getExternalStorageDirectory() + File.separator + "11.jpeg");
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPurgeable = true;
Bitmap bitmap1 = BitmapFactory.decodeFile(file.getAbsolutePath());
File comFile = new File(Environment.getExternalStorageDirectory() + File.separator + "11_new.jpeg");
if (!comFile.exists()) {
comFile.createNewFile();
}
OutputStream stream = new FileOutputStream(comFile);
bitmap1.compress(Bitmap.CompressFormat.JPEG, 100
, stream);
stream.flush();
stream.close();
} catch (Exception e) {
}
可以發現新的圖片莫名其妙增加了100多kb的大小,百思不得其解,這裡猜測是否是Android將Bitmap轉換成JPG的演算法,所以再嘗試通過11_new.jpg檔案再重新生成一張圖片,得到的結果如下:
發現圖片大小又變大了??這是為啥,查閱了一下谷歌發現沒有對應的答案,這裡只能猜測是Android生成圖片的演算法的原因吧,暫且做個筆記,日後弄明白了再做回答吧。如果有哪位同行知道的,希望指點一下迷津蛤。
decodeResource(...)的記憶體佔用情況
上面介紹了通過檔案載入圖片的情況,在Android中也可以直接載入drawable或者mipmap資料夾下的圖片,而通過這種方式載入的圖片大小可能不一致,最直觀的就是放在drawable-hdpi和drawable-mdpi資料夾下的相同圖片,加載出來是兩張大小不一樣的圖片。關於各個資料夾的含義這裡就不解釋了,如果對這些個概念比較模糊,可以檢視一下這篇文章,這裡就盜用一張圖簡單看下對應各個drawable資料夾所代表的螢幕密度:
而各個mipmap資料夾中官方意見是存放應用icon(進行記憶體優化),其他的圖片資源仍然存放在drawable資料夾當中,所以在這裡就不探討mipmap檔案夾了。
首先我們可以通過如下程式碼獲得手機螢幕的寬高密度:
float densityDpi = getResources().getDisplayMetrics().densityDpi;
得到的結果是螢幕密度=480,也就是說正常不進行縮放的圖片應該放在xxhdpi資料夾下。下面測試一下同一張圖片(72x72)放在ldpi,mdpi,hdpi,xhdpi,xxhdpi資料夾下面的記憶體佔用情況:
ldpi中的圖片 寬x高=288x288 記憶體大小=324.0kb
mdpi中的圖片 寬x高=216x216 記憶體大小=182.25kb
hdpi中的圖片 寬x高=144x144 記憶體大小=81.0kb
xhdpi中的圖片 寬x高=108x108 記憶體大小=45.5625kb
xxhdpi中的圖片 寬x高=72x72 記憶體大小=20.25kb
可以看到同一張圖片放在不同的drawable在程式設計Bitmap後寬高跟記憶體都變了,只有在xxhdpi中才顯示原圖,為什麼會這樣呢?Android對於不同drawable載入的邏輯是這樣的:
首先先尋找手機密度匹配的drawable資料夾,這裡我的手機匹配的是xxhdpi資料夾,如果沒有則先向高密度的資料夾尋找,即xxxdpi,一直尋找到最高密度資料夾,如果依然沒有則到drawable-nodpi資料夾找這張圖,發現也沒有,那麼就會去更低密度 的資料夾下面找,依次是drawable-xhdpi -> drawable-hdpi -> drawable-mdpi -> drawable-ldpi的順序。
如果在比當前螢幕密度高的資料夾中找到了,Android認為這是一張相對於螢幕密度所屬檔案更大的圖,所以要進行縮小,同理如果在相比之下低密度的資料夾中找到了,則需要進行放大操作,縮放因子等於當前螢幕密度所在資料夾的密度除以圖片所在資料夾的密度,拿xhdpi舉例,縮放比例等於480/320=1.5,所以xhdpi中加載出來的寬高等於108x108。
如果覺得講述不清可以看下郭霖大神這篇部落格,講的很好。
上面是結論,那麼自然要在原始碼中尋找一下立據才符合程式設計師的個性,這裡以圖片放在xhdpi資料夾下為前提,在呼叫decodeResource(...)
在Java層會最終呼叫到如下方法中:
public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
@Nullable InputStream is, @Nullable Rect pad, @Nullable 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) {
opts.inDensity = density;
}
}
if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
return decodeStream(is, pad, opts);
}
public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding,
@Nullable Options opts) {
...
bm = nativeDecodeAsset(asset, outPadding, opts);
...
return bm;
}
這裡會首先確定影象位於資料夾的密度,即設定opts.inDensity等於xhdpi的密度值,也就是說等於320,opts.inTargetDensity則為螢幕密度480。接著走到native方法中:
static jobject nativeDecodeAsset(JNIEnv* env, jobject clazz, jint native_asset,
jobject padding, jobject options) {
return nativeDecodeAssetScaled(env, clazz, native_asset, padding, options, false, 1.0f);
}
static jobject nativeDecodeAssetScaled(JNIEnv* env, jobject clazz, jint native_asset,
jobject padding, jobject options, jboolean applyScale, jfloat scale) {
SkStream* stream;
Asset* asset = reinterpret_cast<Asset*>(native_asset);
//false
bool forcePurgeable = optionsPurgeable(env, options);
...
SkAutoUnref aur(stream);
//applyScale=false,scale=1.0f,forcePurgeable=false
return doDecode(env, stream, padding, options, true, forcePurgeable, applyScale, scale);
}
這裡還是呼叫到了doDecode(...)
方法中,這裡我們貼出6.0版本程式碼來檢視吧,否則跟不上時代了(雖然已經跟不上了):
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {
int sampleSize = 1;
//1.解碼的模式,主要有兩個,一個是kDecodeBounds_Mode,該模式下只返回Bitmap的寬高以及一些Config引數;
//另外一個是kDecodePixels_Mode,返回完整的圖片以及相關資訊
SkImageDecoder::Mode decodeMode = SkImageDecoder::kDecodePixels_Mode;
SkColorType prefColorType = kN32_SkColorType;
bool doDither = true;
bool isMutable = false;
float scale = 1.0f;
bool preferQualityOverSpeed = false;
bool requireUnpremultiplied = false;
jobject javaBitmap = NULL;
if (options != NULL) {
//獲取取樣率
sampleSize = env->GetIntField(options, gOptions_sampleSizeFieldID);
//可以看到這步,如果Java層設定了inJustDecodeBounds,那麼使用kDecodeBounds_Mode模式,只獲取寬高以及一些資訊,而不是去載入圖片
if (optionsJustBounds(env, options)) {
decodeMode = SkImageDecoder::kDecodeBounds_Mode;
}
// initialize these, in case we fail later on
env->SetIntField(options, gOptions_widthFieldID, -1);
env->SetIntField(options, gOptions_heightFieldID, -1);
env->SetObjectField(options, gOptions_mimeFieldID, 0);
jobject jconfig = env->GetObjectField(options, gOptions_configFieldID);
prefColorType = GraphicsJNI::getNativeBitmapColorType(env, jconfig);
isMutable = env->GetBooleanField(options, gOptions_mutableFieldID);
doDither = env->GetBooleanField(options, gOptions_ditherFieldID);
preferQualityOverSpeed = env->GetBooleanField(options,
gOptions_preferQualityOverSpeedFieldID);
requireUnpremultiplied = !env->GetBooleanField(options, gOptions_premultipliedFieldID);
//如果設定了inBitmap,則讀取對應Bitmap
javaBitmap = env->GetObjectField(options, gOptions_bitmapFieldID);
//獲取Java層inScaled是否支援縮放
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
//獲取Java層的三個密度
const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) {
//1. 計算縮放比率
scale = (float) targetDensity / density;
}
}
}
//true
const bool willScale = scale != 1.0f;
SkImageDecoder* decoder = SkImageDecoder::Factory(stream);
if (decoder == NULL) {
return nullObjectReturn("SkImageDecoder::Factory returned null");
}
decoder->setSampleSize(sampleSize);
decoder->setDitherImage(doDither);
decoder->setPreferQualityOverSpeed(preferQualityOverSpeed);
decoder->setRequireUnpremultipliedColors(requireUnpremultiplied);
android::Bitmap* reuseBitmap = nullptr;
unsigned int existingBufferSize = 0;
if (javaBitmap != NULL) {
reuseBitmap = GraphicsJNI::getBitmap(env, javaBitmap);
if (reuseBitmap->peekAtPixelRef()->isImmutable()) {
ALOGW("Unable to reuse an immutable bitmap as an image decoder target.");
javaBitmap = NULL;
reuseBitmap = nullptr;
} else {
existingBufferSize = GraphicsJNI::getBitmapAllocationByteCount(env, javaBitmap);
}
}
NinePatchPeeker peeker(decoder);
decoder->setPeeker(&peeker);
JavaPixelAllocator javaAllocator(env);
RecyclingPixelAllocator recyclingAllocator(reuseBitmap, existingBufferSize);
ScaleCheckingAllocator scaleCheckingAllocator(scale, existingBufferSize);
SkBitmap::Allocator* outputAllocator = (javaBitmap != NULL) ?
(SkBitmap::Allocator*)&recyclingAllocator : (SkBitmap::Allocator*)&javaAllocator;
if (decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
if (!willScale) {
// If the java allocator is being used to allocate the pixel memory, the decoder
// need not write zeroes, since the memory is initialized to 0.
decoder->setSkipWritingZeroes(outputAllocator == &javaAllocator);
decoder->setAllocator(outputAllocator);
} else if (javaBitmap != NULL) {
// check for eventual scaled bounds at allocation time, so we don't decode the bitmap
// only to find the scaled result too large to fit in the allocation
decoder->setAllocator(&scaleCheckingAllocator);
}
}
// Only setup the decoder to be deleted after its stack-based, refcounted
// components (allocators, peekers, etc) are declared. This prevents RefCnt
// asserts from firing due to the order objects are deleted from the stack.
SkAutoTDelete<SkImageDecoder> add(decoder);
AutoDecoderCancel adc(options, decoder);
// To fix the race condition in case "requestCancelDecode"
// happens earlier than AutoDecoderCancel object is added
// to the gAutoDecoderCancelMutex linked list.
if (options != NULL && env->GetBooleanField(options, gOptions_mCancelID)) {
return nullObjectReturn("gOptions_mCancelID");
}
SkBitmap decodingBitmap;
//解析Bitmap
if (decoder->decode(stream, &decodingBitmap, prefColorType, decodeMode)
!= SkImageDecoder::kSuccess) {
return nullObjectReturn("decoder->decode returned false");
}
//獲取寬高
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
//這裡加0.5應該是四捨五入的意思
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
// 設定Options的值
if (options != NULL) {
jstring mimeType = getMimeTypeString(env, decoder->getFormat());
if (env->ExceptionCheck()) {
return nullObjectReturn("OOM in getMimeTypeString()");
}
env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
env->SetObjectField(options, gOptions_mimeFieldID, mimeType);
}
//justBounds模式下直接返回空即可
if (decodeMode == SkImageDecoder::kDecodeBounds_Mode) {
return NULL;
}
...
SkBitmap outputBitmap;
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
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
// TODO: avoid copying when scaled size equals decodingBitmap size
SkColorType colorType = 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(SkImageInfo::Make(scaledWidth, scaledHeight,
colorType, decodingBitmap.alphaType()));
if (!outputBitmap.tryAllocPixels(outputAllocator, NULL)) {
return nullObjectReturn("allocation failed for scaled bitmap");
}
// If outputBitmap's pixels are newly allocated by Java, there is no need
// to erase to 0, since the pixels were initialized to 0.
if (outputAllocator != &javaAllocator) {
outputBitmap.eraseColor(0);
}
SkPaint paint;
paint.setFilterQuality(kLow_SkFilterQuality);
//使用畫布的方式進行縮放
SkCanvas canvas(outputBitmap);
canvas.scale(sx, sy);
canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
} else {
outputBitmap.swap(decodingBitmap);
}
if (padding) {
if (peeker.mPatch != NULL) {
GraphicsJNI::set_jrect(env, padding,
peeker.mPatch->paddingLeft, peeker.mPatch->paddingTop,
peeker.mPatch->paddingRight, peeker.mPatch->paddingBottom);
} else {
GraphicsJNI::set_jrect(env, padding, -1, -1, -1, -1);
}
}
// if we get here, we're in kDecodePixels_Mode and will therefore
// already have a pixelref installed.
if (outputBitmap.pixelRef() == NULL) {
return nullObjectReturn("Got null SkPixelRef");
}
if (!isMutable && javaBitmap == NULL) {
// promise we will never change our pixels (great for sharing and pictures)
outputBitmap.setImmutable();
}
//如果進行重用,則更新舊Bitmap
if (javaBitmap != NULL) {
bool isPremultiplied = !requireUnpremultiplied;
GraphicsJNI::reinitBitmap(env, javaBitmap, outputBitmap.info(), isPremultiplied);
outputBitmap.notifyPixelsChanged();
// If a java bitmap was passed in for reuse, pass it back
return javaBitmap;
}
int bitmapCreateFlags = 0x0;
if (isMutable) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Mutable;
if (!requireUnpremultiplied) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Premultiplied;
//建立Bitmap並且返回
return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(),
bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
}
SkColorType實際上是代替4.4w中SkBitmap::kARGB_8888_Config的一個封裝列舉類:
enum SkColorType {
kUnknown_SkColorType,
kAlpha_8_SkColorType,
kRGB_565_SkColorType,
kARGB_4444_SkColorType,
kRGBA_8888_SkColorType,
kBGRA_8888_SkColorType,
kIndex_8_SkColorType,
kGray_8_SkColorType,
kLastEnum_SkColorType = kGray_8_SkColorType,
#if SK_PMCOLOR_BYTE_ORDER(B,G,R,A)
kN32_SkColorType = kBGRA_8888_SkColorType,
#elif SK_PMCOLOR_BYTE_ORDER(R,G,B,A)
kN32_SkColorType = kRGBA_8888_SkColorType,
#else
#error "SK_*32_SHFIT values must correspond to BGRA or RGBA byte order"
#endif
};
上面原始碼可以看見確實少掉了isPurgeable的影子,縮放的核心在於scale = (float) targetDensity / density;
這句話,通過計算目標密度/原圖密度得到一個縮放比率,然後分別用原Bitmap的寬高乘以對應比率得到最終Bitmap的寬高,在xhdpi情況下就是108x108的寬高啦,這也符合我們實驗後的結果,而記憶體在用則是原Bitmap的記憶體x(縮放比率)x(縮放比率)。
Android中Bitmap的OOM
在Android中處理Bitmap免不了遇到OOM的問題,在上面小節中講述了Bitmap的概念以及在Andorid的表現方式和記憶體管理,這裡就對OOM做個總結。推薦檢視官方文件:manage-memory
首先介紹一下OOM的概念,也就是Out-Of-Memory,俗稱記憶體溢位,我們的app在執行時使用的記憶體如果超出了單個程序允許最大的值,那麼這個程序就會報OOM。OOM發生的情況一般由記憶體洩漏或者一次性載入過大的記憶體資料導致(最有可能的就是Bitmap的載入)。那麼如何去避免載入過大的Bitmap導致的OOM呢?在谷歌官方文件中介紹瞭如何有效的去載入一張大的Bitmap,再綜合前輩們的方案得到了大概如下幾個方式去避免載入Bitmap時候OOM的發生:
- 增大系統給我們的記憶體大小,也就是在Manifest中設定`android:largeHeap="true"。
- 對圖片進行合適的壓縮處理,使用RGB_565代替RGBA_8888模式載入Bitmap。
- 如果條件允許下,使用inBitmap進行記憶體重用。
在Manifest中設定android:largeHeap="true"
這種方式需要需要謹慎使用,原因引用胡凱大神的部落格解釋:
在一些特殊的情景下,你可以通過在manifest的application標籤下新增largeHeap=true的屬性來為應用宣告一個更大的heap空間。然後,你可以通過getLargeMemoryClass()來獲取到這個更大的heap size閾值。然而,宣告得到更大Heap閾值的本意是為了一小部分會消耗大量RAM的應用(例如一個大圖片的編輯應用)。不要輕易的因為你需要使用更多的記憶體而去請求一個大的Heap Size。只有當你清楚的知道哪裡會使用大量的記憶體並且知道為什麼這些記憶體必須被保留時才去使用large heap。因此請謹慎使用large heap屬性。使用額外的記憶體空間會影響系統整體的使用者體驗,並且會使得每次gc的執行時間更長。在任務切換時,系統的效能會大打折扣。另外, large heap並不一定能夠獲取到更大的heap。在某些有嚴格限制的機器上,large heap的大小和通常的heap size是一樣的。因此即使你申請了large heap,你還是應該通過執行getMemoryClass()來檢查實際獲取到的heap大小。
對圖片壓縮的方式主要有尺寸壓縮,取樣率壓縮以及質量壓縮三種方式,質量壓縮不改變記憶體佔用,因此這裡說的壓縮主要指使用尺寸壓縮和取樣率壓縮的方式,從程式碼上看,取樣率壓縮是尺寸壓縮的的子集,Native中實現的方式都是通過scale引數決定最後生成Bitmap的寬高。這裡介紹一下采樣率壓縮,這種方式在谷歌官方文件中體現,也是各大圖片庫使用的一種減少記憶體佔用的方式(Glide,Picasso.etc),當我們載入一張實際為1080x1920的圖到一個300x200的ImageView的時候作為縮圖展示時候,沒有必要全載入一張那麼大的圖片,我們可以通過inSampleSize引數配合inJustDecodeBounds 對圖片進行壓縮,谷歌提供的一個關於取樣率的計算方法:
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
更多的內容檢視官方文件吧,這裡不敘述了。
inBitmap引數的使用可以檢視官方Demo。
上述說明的OOM是針對以一張圖片而言,多圖片下的策略基於單圖片,額外添加了快取的操作,最常見的就是LruCache和DiskLruCache策略,官方文件獻上,如果想要學習對於多圖片載入使用的,我覺深入一個圖片庫是一個非常不錯的選擇,如Glide。
Bitmap的壓縮
上面分析中其實或多或少涉獵了Bitmap的壓縮的相關知識,Android我們能接觸真正意義上的Bitmap壓縮其實只有兩種(自主編譯libjpg的不算):尺寸壓縮和質量壓縮。
- 尺寸壓縮:改變Bitmap的大小,寬高以及佔用記憶體隨著改變。
- 質量壓縮:改變Bitmap的質量,它是在保持畫素的前提下改變圖片的位深及透明度等,所以寬高以及佔用記憶體不會改變。
尺寸壓縮的方式可以通過取樣率或者自主設定inDensity和inTargetDensity以及inScreenDensity的方式進行,這裡就不舉例了;質量壓縮方法使用如下:
public static byte[] compressImageToByteArray(Bitmap src, Bitmap.CompressFormat format, int size) {
try {
byte[] byteArray;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
src.compress(format, size, baos);
byteArray = baos.toByteArray();
baos.close();
return byteArray;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
Bitmap的compress(...)
中的CompressFormat引數有三種:
JPEG (0),
PNG (1),
WEBP (2);
需要注意的是能夠進行壓縮的只有JPEG以及WEBP格式的,由於PNG為無失真壓縮格式,所以進行質量壓縮並不會有多大的效果,測試程式碼如下:
private void testCompress() {
try {
File jpgFile = new File(getInnerSDCardPath() + File.separator + "11.jpeg");
Bitmap bitmap = BitmapFactory.decodeFile(jpgFile.getAbsolutePath());
Log.e("test", "初始jpg大小=" + bitmap.getByteCount());
byte[] array = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.JPEG, 50);
Log.e("test", "質量壓縮到50%後的jpg大小=" + array.length);
byte[] pngArray = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.PNG, 50);
Log.e("test", "質量壓縮到50%後的png大小=" + pngArray.length);
byte[] pngArray1 = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.PNG, 80);
Log.e("test", "質量壓縮到80%後的png大小=" + pngArray1.length);
byte[] pngArray2 = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.PNG, 80);
Log.e("test", "質量壓縮到100%後的png大小=" + pngArray2.length);
byte[] webArray = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.WEBP, 50);
Log.e("test", "質量壓縮到50%後的webp大小=" + webArray.length);
} catch (Exception e) {
e.printStackTrace();
}
}
---
test: 初始jpg大小=2691000
test: 質量壓縮到50%後的jpg大小=142720
test: 質量壓縮到50%後的png大小=660135
test: 質量壓縮到80%後的png大小=660135
test: 質量壓縮到100%後的png大小=660135
test: 質量壓縮到50%後的webp大小=112188
可以看到JPG和WEBP都進行了壓縮,而對應PNG則沒有變化。
參考資料
- http://www.10tiao.com/html/227/201705/2650239680/1.html
- https://zh.wikipedia.org/wiki/BMP
- https://www.jianshu.com/p/371028436de7
- http://paulbourke.net/dataformats/bitmaps/
- https://android.googlesource.com/platform/frameworks/base/+/7b2f8b8/core/jni/android/graphics
- http://www.yopai.com/show-1-130317-1.html