Android Bitmap轉(zhuǎn)換WebP圖片導(dǎo)致?lián)p壞的分析及解決方案

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)了一些問題。

圖片來自 glennrobinsononline.com

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模式直觀地對比兩張圖片:

通過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)行文件對比:

通過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大法好。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容