0x00 背景
作為移動(dòng)領(lǐng)域所力推的圖片格式,WebP圖片在商業(yè)領(lǐng)域證明了其應(yīng)有的價(jià)值。基于其他格式的橫向?qū)Ρ?,其在壓縮性能表現(xiàn),及還原度極為優(yōu)秀,節(jié)省大量的帶寬開銷。基于可觀的效益比,團(tuán)隊(duì)早前已開始磋商將當(dāng)前圖片資源遷移至.webp資源。
然而對于Android而言,加載.webp圖片所消耗的時(shí)間比.jpg及.png要慢數(shù)倍。對于這點(diǎn)而言是無法忍受的。因此解決方案是:
從網(wǎng)絡(luò)拿到.webp數(shù)據(jù)流 -> Bitmap通過.png格式保存到本地
注意,整個(gè)過程必須在子線程執(zhí)行。這樣,在使用了WebP節(jié)省了帶寬的同時(shí),下一次加載圖片的速度也不會(huì)受到影響。
但在客戶端實(shí)現(xiàn)的最后階段,出現(xiàn)了一些問題。

0x01 問題重現(xiàn)
對于上述的解決方案,隱去業(yè)務(wù)復(fù)雜性,我用以下示例來展示:
private void saveImage(String uri, String savePath) throws IOException {
// 創(chuàng)建連接
HttpURLConnection conn = createConnection(uri);
// 拿到輸入流,此流即是圖片資源本身
InputStream imputStream = conn.getInputStream();
// 指使Bitmap通過流獲取數(shù)據(jù)
Bitmap bitmap = BitmapFactory.decodeStream(imputStream);
File file = new File(savePath);
OutputStream out = new BufferedOutputStream(new FileOutputStream(file.getCanonicalPath()), BUFFER_SIZE);
// 指使Bitmap以相應(yīng)的格式,將當(dāng)前Bitmap中的圖片數(shù)據(jù)保存到文件
if (bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)) {
out.flush();
out.close();
}
}
上述代碼意圖明顯:拿到流,將該流通過decodeStream(InputStream)方法傳送到Bitmap,隨后以.png格式存儲(chǔ)到本地。
在很長一段時(shí)間內(nèi),該代碼運(yùn)作良好。直到有一天,在某國產(chǎn)機(jī)型上做測試的時(shí)候,發(fā)現(xiàn)圖片保存到本地后出現(xiàn)了損壞。
那些保存到本地出現(xiàn)損壞的圖片,長這樣:

在這張樣圖中,圖片的下半部分出現(xiàn)了缺失。在隨后的循環(huán)測試中,每張圖片的缺失程度大小不一,從完整到全黑都有。
0x02 分析
對于這種情況,第一猜想可能是網(wǎng)絡(luò)返回的數(shù)據(jù)流有問題。但在隨后的排查中,發(fā)現(xiàn)InputStream數(shù)據(jù)流是完整的。隨后開始對圖片本身進(jìn)行分析。
對文件差異進(jìn)行分析是一種好辦法。在這里,使用Beyond Compare以不同的方式進(jìn)行分析。于是準(zhǔn)備了兩張圖片,一張成功從.webp轉(zhuǎn)為.png,另一張也從.webp轉(zhuǎn)為.png,但是出現(xiàn)缺失黑塊。
現(xiàn)在,通過Picture Compare模式直觀地對比兩張圖片:

在這里,左側(cè)為完整圖片,右側(cè)為存在數(shù)據(jù)缺失的圖片,下方為差異標(biāo)記:紅色區(qū)域?yàn)閮蓮垐D片的差異之處。
可以觀察到,相對于完整圖片而言,存在數(shù)據(jù)缺失的圖片并非零散地缺失數(shù)據(jù),而是從某一刻開始,數(shù)據(jù)便不復(fù)存在了。
為了進(jìn)一步考究導(dǎo)致差異的根本原因,可以通過Hex Compare模式進(jìn)行對比。也就是說,以十六進(jìn)制的方式對比文件。現(xiàn)在,通過Hex Compare模式進(jìn)行文件對比:

左側(cè)的紅條表示兩個(gè)文件中二進(jìn)制數(shù)據(jù)不一致的地方。
其中,左側(cè)為完整的.png文件,右側(cè)為存在缺失黑塊的.png文件。觀察缺失文件的十六進(jìn)制數(shù)據(jù),存在著大量的空值塊(0x00000000),并且數(shù)據(jù)長度是短于完整文件的。同時(shí),此現(xiàn)象與早前出現(xiàn)黑塊的規(guī)律相似:大塊的數(shù)據(jù)丟失,并非零散的缺失。
但是,文件的分析尚未結(jié)束。有一個(gè)非常重要的問題不要忽略了:
我們是打開了一張數(shù)據(jù)損壞的圖像嗎?
我們知道,如果一個(gè)圖像文件的關(guān)鍵數(shù)據(jù)塊出現(xiàn)損壞,該圖像是無法被打開的。也就是說,如果一個(gè)圖像文件能夠被打開,說明該圖像文件結(jié)構(gòu)完整。
那么,如何分析一張圖像的數(shù)據(jù)塊是否完整?在這里,我們關(guān)心的是:那張缺失的圖像,文件末尾寫入成功了嗎?
在這里有必要解釋一下PNG文件末尾的數(shù)據(jù)塊是個(gè)什么東西。引用PNG格式標(biāo)準(zhǔn)的官方說法(PNG格式塊簡述:w3.org):
Chunks can appear in any order, subject to the restrictions placed on each chunk type. (One notable restriction is that IHDR must appear first and IEND must appear last; thus the IEND chunk serves as an end-of-file marker.) Multiple chunks of the same type can appear, but only if specifically permitted for that type.
解釋:在整個(gè)PNG文件中,用以標(biāo)記文件開始的IHDR標(biāo)記必須在文件的最開始,標(biāo)記文件結(jié)束的IEND標(biāo)記必須在文件的最末端。對于其他數(shù)據(jù)塊則沒有順序要求。
也就是說,如果一張PNG圖片能夠被打開,那么它在文件的最后,必定存在IEND標(biāo)記。
回到剛才的Hex Compare,拉到最底部,于是發(fā)現(xiàn):

沒錯(cuò)。兩張圖片的末端都有IEND標(biāo)記。
也就是說,那張存在黑塊的.png文件,IO寫入并沒有問題。隨后與手機(jī)廠商溝通,問題也近乎塵埃落定:該手機(jī)ROM在處理BitmapFactory的底層出現(xiàn)問題。
0x03 解決方案
現(xiàn)在的問題很明確,BitmapFactory中某些native方法存在bug。那是不是所有的native方法都有問題呢?
BitmapFactory.decodeStream(InputStream)方法最終調(diào)用的是native方法nativeDecodeStream(InputStream, byte[], Rect, Options)。嘗試?yán)@開它試試看。
可否嘗試將網(wǎng)絡(luò)數(shù)據(jù)流保存到內(nèi)存,隨后再將其指向BitmapFactory?答案是肯定的。我們嘗試替換一部分代碼。將此部分代碼:
// 拿到輸入流,此流即是圖片資源本身
InputStream imputStream = conn.getInputStream();
// 指使Bitmap通過流獲取數(shù)據(jù)
Bitmap bitmap = BitmapFactory.decodeStream(imputStream);
替換成:
// 拿到輸入流,此流即是圖片資源本身
InputStream imputStream = conn.getInputStream();
// 將所有InputStream寫到byte數(shù)組當(dāng)中
byte[] targetData = null;
byte[] bytePart = new byte[4096];
while (true) {
int readLength = imputStream.read(bytePart);
if (readLength == -1) {
break;
} else {
byte[] temp = new byte[readLength + (targetData == null ? 0 : targetData.length)];
if (targetData != null) {
System.arraycopy(targetData, 0, temp, 0, targetData.length);
System.arraycopy(bytePart, 0, temp, targetData.length, readLength);
} else {
System.arraycopy(bytePart, 0, temp, 0, readLength);
}
targetData = temp;
}
}
// 指使Bitmap通過byte數(shù)組獲取數(shù)據(jù)
Bitmap bitmap = BitmapFactory.decodeByteArray(targetData, 0, targetData.length);
BitmapFactory.decodeByteArray(byte[], int, int)方法最終調(diào)用了native方法nativeDecodeByteArray(byte[], int, int, Options),與通過InputStream處理所指向的native方法不同。
經(jīng)過測試,使用這種方法所保存的.png文件不存在黑塊問題。我們無法得知廠商ROM中對于這兩種方法有什么差異對待,但至少可以明確:上文中提到的那臺(tái)國產(chǎn)機(jī)子,通過InputStream傳遞WebP數(shù)據(jù)并存儲(chǔ)為.png圖像這一過程存在可預(yù)知的bug。
至此,問題分析及解決方案闡述完畢。
0x04 后記
對于這種結(jié)論我是跪了一地的。。。
畢竟不是第一次遇到這種問題。每當(dāng)廠商ROM出現(xiàn)bug,這種鍋就得開發(fā)者來背。
你總不能等廠商去修復(fù)吧?你的App新版還要不要上線?再說了,廠商修復(fù)了,用戶也未必會(huì)去升級。除了少數(shù)幾個(gè)廠商把ROM品牌玩的飛起,其他廠商即使更新ROM版本,能夠主動(dòng)升級的用戶也并非多數(shù)。
所以,我很討厭那種所謂“深度定制”的系統(tǒng)。
你說優(yōu)化系統(tǒng)好不好,我當(dāng)然支持。但是拜托,要有把握才去改。埋的坑,以后填都填不上,何必呢。老大哥Google寫的代碼你說不好要去改,改完搞不好就沒人維護(hù)了。
機(jī)友們說得好:Nexus大法好。