開源一個 Android 圖片壓縮框架

在我們的業務場景中,需要使用客戶端采集圖片,上傳服務器,然后對圖片信息進行識別。為了提升程序的性能,我們需要保證圖片上傳服務器的速度的同時,保證用于識別圖片的質量。整個優化包括兩個方面的內容:

  1. 相機拍照的優化:包括相機參數的選擇、預覽、啟動速度和照片質量等;
  2. 圖片壓縮的優化:基于拍攝的圖片和從相冊中選擇的圖片進行壓縮,控制圖片大小和尺寸。

在本文中,我們主要介紹圖片壓縮優化,后續我們會介紹如何對 Android 的相機進行封裝和優化。本項目主要基于 Android 自帶的圖片壓縮 API 進行封裝,結合了 LubanCompressor 的優點,同時提供了用戶自定義壓縮策略的接口。該項目的主要目的在于,統一圖片壓縮框庫的實現,集成常用的兩種圖片壓縮算法,讓你以更低的成本集成圖片壓縮功能到自己的項目中。

1、圖片壓縮的基礎知識

對于一般業務場景,當我們展示圖片的時候,Glide 會幫我們處理加載的圖片的尺寸問題。但在把采集來的圖片上傳到服務器之前,為了節省流量,我們需要對圖片進行壓縮。

在 Android 平臺上,默認提供的壓縮有三種方式:質量壓縮和兩種尺寸壓縮,鄰近采樣以及雙線性采樣。下面我們簡單介紹下者三種壓縮方式都是如何使用的:

1.1 質量壓縮

所謂的質量壓縮就是下面的這行代碼,它是 Bitmap 的方法。當我們得到了 Bitmap 的時候,即可使用這個方法來實現質量壓縮。它一般位于我們所有壓縮方法的最后一步。

// android.graphics。Bitmap
compress(CompressFormat format, int quality, OutputStream stream)

該方法接受三個參數,其含義分別如下:

  1. format:枚舉,有三個選項 JPEG, PNGWEBP,表示圖片的格式;
  2. quality:圖片的質量,取值在 [0,100] 之間,表示圖片質量,越大,圖片的質量越高;
  3. stream:一個輸出流,通常是我們壓縮結果輸出的文件的流

1.2 鄰近采樣

鄰近采樣基于臨近點插值算法,用像素代替周圍的像素。鄰近采樣的核心代碼只有下面三行,

BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 1;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.blue_red, options);

鄰近采樣核心的地方在于 inSampleSize 的計算。它通常是我們使用的壓縮算法的第一步。我們可以通過設置 inSampleSize 來得到原始圖片采樣之后的結果,而不是將原始的圖片全部加載到內存中,以防止 OOM。標準使用姿勢如下:

    // 獲取原始圖片的尺寸
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    options.inSampleSize = 1;
    BitmapFactory.decodeStream(srcImg.open(), null, options);
    this.srcWidth = options.outWidth;
    this.srcHeight = options.outHeight;

    // 進行圖片加載,此時會將圖片加載到內存中
    options.inJustDecodeBounds = false;
    options.inSampleSize = calInSampleSize();
    Bitmap bitmap = BitmapFactory.decodeStream(srcImg.open(), null, options);

這里主要分成兩個步驟,它們各自的含義是:

  1. 先通過設置 Options 的 inJustDecodeBounds 為 true,來加載圖片,以得到圖片的尺寸信息。此時圖片不會被加載到內存中,所以不會造成 OOM,同時我們可以通過 Options 得到原圖的尺寸信息。
  2. 根據上一步中得到的圖片的尺寸信息,計算一個 inSampleSize,然后將 inJustDecodeBounds 設置為 false,以加載采樣之后的圖片到內存中。

關于 inSampleSize 需要簡單說明一下:inSampleSize 代表壓縮后的圖像一個像素點代表了原來的幾個像素點,例如 inSampleSize 為 4,則壓縮后的圖像的寬高是原來的 1/4,像素點數是原來的 1/16,inSampleSize 一般會選擇 2 的指數,如果不是 2 的指數,內部計算的時候也會向 2 的指數靠近。所以,實際使用過程中,我們會通過明確指定 inSampleSize 為 2 的指數,來避免內部計算導致的不確定性。

1.3 雙線性采樣

鄰近采樣可以對圖片的尺寸進行有效的控制,但是它存在幾個問題。比如,當我需要把圖片的寬度壓縮到 1200 左右的時候,如果原始的圖片的寬度壓是 3200,那么我只能通過設置 inSampleSize 將采樣率設置為 2 來將其壓縮到 1600. 此時圖片的尺寸比我們的要求要大。就是說,鄰近采樣無法對圖片的尺寸進行更加精準的控制。如果需要對圖片尺寸進行更加精準的控制,那么就需要使用雙線性壓縮了。

雙線性采樣采用雙線性插值算法,相比鄰近采樣簡單粗暴的選擇一個像素點代替其他像素點,雙線性采樣參考源像素相應位置周圍 2x2 個點的值,根據相對位置取對應的權重,經過計算得到目標圖像。

它在 Android 中的使用也比較簡單,

Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.blue_red);
Matrix matrix = new Matrix();
matrix.setScale(0.5f, 0.5f);
Bitmap sclaedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth()/2, bitmap.getHeight()/2, matrix, true);

也就是對得到的 Bitmap 應用 createBitmap() 進行處理,并傳入 Matrix 指定圖片尺寸放縮的比例。該方法返回的 Bitmap 就是雙線性壓縮之后的結果。

1.4 圖片壓縮算法總結

在實際使用過程中,我們通常會結合三種壓縮方式使用,一般使用的步驟如下,

  1. 使用鄰近采樣對原始的圖片進行采樣,將圖片控制到比目標尺寸稍大的大小,防止 OOM;
  2. 使用雙線性采樣對圖片的尺寸進行壓縮,控制圖片的尺寸為目標的大小;
  3. 對上述兩個步驟之后得到的圖片 Bitmap 進行質量壓縮,并將其輸出到磁盤上。

當然,本質上 Android 圖片的編碼是由 Skia 庫來完成的,所以,除了使用 Android 自帶的庫進行壓縮,我們還可以調用外部的庫進行壓縮。為了追求更高的壓縮效率,通常我們會在 Native 層對圖片進行處理,這將涉及 JNI 的知識。筆者曾在之前的文章 《在 Android 中使用 JNI 的總結》 中介紹過 Android 平臺上 JNI 的調用的常規思路,感興趣的同學可以參考下。

2、Github 上的開源的圖片壓縮庫

現在 Github 上的圖片壓縮框架主要有 Luban 和 Compressor 兩個。Star 的數量也比較高,一個 9K,另一個 4K. 但是,這兩個圖片壓縮的庫有各自的優點和缺點。下面我們通過一個表格總結一下:

框架 優點 缺點
Luban 據說是根據微信圖片壓縮逆推的算法 1.只適用于一般的圖片展示的場景,無法對圖片的尺寸進行精準壓縮;2.內部封裝 AsyncTaks 來進行異步的圖片壓縮,對于 RxJava 支持不好。
Compressor 1.可以對圖片的尺寸進行壓縮;2.支持 RxJava。 1.尺寸壓縮的場景有限,如果有特別的需求,則需要手動修改源代碼;2.圖片壓縮采樣的時候計算有問題,導致采樣后的圖片尺寸總是小于我們指定的尺寸

上面的圖表已經總結得很詳細了。所以,根據上面的兩個庫各自的優缺點,我們打算開發一個新的圖片壓縮框架。它滿足下面的功能:

  1. 支持 RxJava:我們可以像使用 Compressor 的時候那樣,指定圖片壓縮的線程和結果監聽的線程;
  2. 支持 Luban 壓縮算法:Luban 壓縮算法核心的部分只在于 inSampleSize 的計算,因此,我們可以很容易得將其集成到我們的新的庫中。之所以加入 Luban,是為了讓我們的庫可以適用于一般圖片展示的場景。用戶無需指定圖片的尺寸,用起來省心省力。
  3. 支持 Compressor 壓縮算法同時指定更多的參數:Compressor 壓縮算法就是我們上述提到的三種壓縮算法的總和。不過,當要壓縮的寬高比與原始圖片的寬高比不一致的時候,它只提供了一種情景。下文中介紹我們框架的時候會說明進行更詳細的說明。當然,你可以在調用框架的方法之前主動去計算出一個寬高比,但是你需要把圖片壓縮的第一個階段主動走一遍,費心費力。
  4. 提供用戶自定義壓縮算法的接口:我們希望設計的庫可以允許用戶自定義壓縮策略。在想要替換圖片壓縮算法的時候,通過鏈式調用的一個方法直接更換策略即可。即,我們希望能夠讓用戶以最低的成本替換項目中的圖片壓縮算法。

3、項目整體架構

以下是我們的圖片壓縮框架的整體架構,這里我們只列舉除了其中核心的部分代碼。這里的 Compress 是我們的鏈式調用的起點,我們可以用它來指定圖片壓縮的基本參數。然后,當我們使用它的 strategy() 方法之后,方法將進入到圖片壓縮策略中,此時,我們繼續鏈式調用壓縮策略的自定義方法,個性化地設置各壓縮策略自己的參數:

項目整體架構

這里的所有的壓縮策略都繼承自抽線的基類 AbstractStrategy,它提供了兩個默認的實現 Luban 和 Compressor. 接口 CompressListener 和 CacheNameFactory 分別用來監聽圖片壓縮進度和自定義壓縮的圖片的名稱。下面的三個是圖片相關的工具類,用戶可以調用它們來實現自己壓縮策略。

4、使用

首先,在項目的 Gradle 中加入我的 Maven 倉庫的地址:

maven { url "https://dl.bintray.com/easymark/Android" }

然后,在你的項目的依賴中,添加該庫的依賴:

implementation 'me.shouheng.compressor:compressor:0.0.1'

然后,就可以在項目中使用了。你可以參考 Sample 項目的使用方式。不過,下面我們還是對它的一些 API 做簡單的說明。

4.1 Luban 的使用

下面是 Luban 壓縮策略的使用示例,它與 Luban 庫的使用類似。只是在 Luban 的庫的基礎上,我們增加了一個 copy 的選項,用來表示當圖片因為小于指定的大小而沒有被壓縮之后,是否將原始的圖片拷貝到指定的目錄。因為,比如當你使用回調獲取圖片壓縮結果的時候,如果按照 Luban 庫的邏輯,你得到的是原始的圖片,所以,此時你需要額外進行判斷。因此,我們增加了這個布爾類型的參數,你可以通過它指定將原始文件進行拷貝,這樣你就不需要在回調中對是否是原始圖片進行判斷了。

    // 在 Compress 的 with() 方法中指定 Context 和 要壓縮文件 File
    val luban = Compress.with(this, file)
        // 這里添加一個回調,如果你不使用 RxJava,那么可以用它來處理壓縮的結果
        .setCompressListener(object : CompressListener{
            override fun onStart() {
                LogUtils.d(Thread.currentThread().toString())
                Toast.makeText(this@MainActivity, "Compress Start", Toast.LENGTH_SHORT).show()
            }

            override fun onSuccess(result: File?) {
                LogUtils.d(Thread.currentThread().toString())
                displayResult(result?.absolutePath)
                Toast.makeText(this@MainActivity, "Compress Success : $result", Toast.LENGTH_SHORT).show()
            }

            override fun onError(throwable: Throwable?) {
                LogUtils.d(Thread.currentThread().toString())
                Toast.makeText(this@MainActivity, "Compress Error :$throwable", Toast.LENGTH_SHORT).show()
            }
        })
        // 壓縮圖片的名稱工廠方法,用來指定壓縮結果的文件名
        .setCacheNameFactory { System.currentTimeMillis().toString() }
        // 圖片的質量
        .setQuality(80)
        // 上面基本的配置完了,下面指定圖片的壓縮策略為 Luban
        .strategy(Strategies.luban())
        // 指定如果圖片小于等于 100K 就不壓縮了,這里的參數 copy 表示,如果不壓縮的話要不要拷貝文件
        .setIgnoreSize(100, copy)

        // 按上面那樣得到了 Luban 實例之后有下面兩種方式啟動圖片壓縮
        // 啟動方式 1:使用 RxJava 進行處理
        val d = luban.asFlowable()
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe { displayResult(it.absolutePath) }
    
        // 啟動方式 2:直接啟動,此時使用內部封裝的 AsyncTask 進行壓縮,壓縮結果只能在上面的回調中進行處理了
        luban.launch()

4.2 Compressor 的使用

下面是 Compressor 壓縮策略的基本的使用,在調用 strategy() 方法指定壓縮策略之前,你的任務與 Luban 一致。所以,如果你需要更換圖片壓縮算法的時候,直接使用 strategy() 方法更換策略即可,前面部分的邏輯無需改動,因此,可以降低你更換壓縮策略的成本。

    val compressor = Compress.with(this, file)
        .setQuality(60)
        .setTargetDir("")
        .setCompressListener(object : CompressListener {
            override fun onStart() {
                LogUtils.d(Thread.currentThread().toString())
                Toast.makeText(this@MainActivity, "Compress Start", Toast.LENGTH_SHORT).show()
            }

            override fun onSuccess(result: File?) {
                LogUtils.d(Thread.currentThread().toString())
                displayResult(result?.absolutePath)
                Toast.makeText(this@MainActivity, "Compress Success : $result", Toast.LENGTH_SHORT).show()
            }

            override fun onError(throwable: Throwable?) {
                LogUtils.d(Thread.currentThread().toString())
                Toast.makeText(this@MainActivity, "Compress Error :$throwable", Toast.LENGTH_SHORT).show()
            }
        })
        .strategy(Strategies.compressor())
        .setMaxHeight(100f)
        .setMaxWidth(100f)
        .setScaleMode(Configuration.SCALE_SMALLER)
        .launch()

這里的 setMaxHeight(100f)setMaxWidth(100f) 用來表示圖片壓縮的目標大小。具體的大小是如何計算的呢?在 Compressor 庫中你是無法確定的,但是在我們的庫中,你可以通過 setScaleMode() 方法來指定。這個方法接收一個整數類型的枚舉,它的取值范圍有 4 個,即 SCALE_LARGER, SCALE_SMALLER, SCALE_WIDTHSCALE_HEIGHT,它們具體的含義我們會進行詳細說明。這里我們默認的壓縮方式是 SCALE_LARGER,也就是 Compressor 庫的壓縮方式。那么這四個參數分別是什么含義呢?

這里我們以一個例子來說明,假設有一個圖片的寬度是 1000,高度是 500,簡寫作 (W:1000, H:500),通過 setMaxHeight()setMaxWidth() 指定的參數均為 100,那么,就稱目標圖片的尺寸,寬度是 100,高度是 100,簡寫作 (W:100, H:100)。那么按照上面的四種壓縮方式,最終的結果將是:

  • SCALE_LARGER:對高度和長度中較大的一個進行壓縮,另一個自適應,因此壓縮結果是 (W:100, H:50). 也就是說,因為原始圖片寬高比 2:1,我們需要保持這個寬高比之后再壓縮。而目標寬高比是 1:1. 而原圖的寬度比較大,所以,我們選擇將寬度作為壓縮的基準,寬度縮小 10 倍,高度也縮小 10 倍。這是 Compressor 庫的默認壓縮策略,顯然它只是優先使得到的圖片更小。這在一般情景中沒有問題,但是當你想把短邊控制在 100 就無計可施了(需要計算之后再傳參),此時可以使用 SCALE_SMALLER。
  • SCALE_SMALLER:對高度和長度中較大的一個進行壓縮,另一個自適應,因此壓縮結果是 (W:200, H:100). 也就是,高度縮小 5 倍之后,達到目標 100,然后寬度縮小 5 倍,達到 200.
  • SCALE_WIDTH:對寬度進行壓縮,高度自適應。因此得到的結果與 SCALE_LARGER 一致。
  • SCALE_HEIGHT:對高度進行壓縮,寬度自適應,因此得到的結果與 SCALE_HEIGHT 一致。

4.3 自定義策略

自定義一個圖片壓縮策略也是很簡單的,你可以通過繼承 SimpleStrategy 或者直接繼承 AbstractStrategy 來實現:

class MySimpleStrategy: SimpleStrategy() {

    override fun calInSampleSize(): Int {
        return 2
    }

    fun myLogic(): MySimpleStrategy {
        return this
    }

}

注意下,如果想要實現鏈式的調用,自定義壓縮策略的方法需要返回自身。

5、最后

因為我們的項目中,需要把圖片的短邊控制到 1200,長變只適應,只通過改變 Luban 來改變采樣率只能把邊長控制到一個范圍中,無法精準壓縮。所以,我們想到了 Compressor,并提出了 SCALE_SMALLER 的壓縮模式. 但是 Luban 也不是用不到,一般用來展示的圖片的壓縮,它用起來更加方便。因此,我們在庫中綜合了兩個框架,其實代碼量并不大。當然,為了讓我們的庫功能更加豐富,因此我們提出了自定義壓縮策略的接口,也是用來降低壓縮策略的更換成本吧。

最后項目開源在 Github,地址是:https://github.com/Shouheng88/Compressor. 歡迎 Star 和 Fork,為該項目貢獻代碼或者提出 issue :)

后續,筆者會對 Android 端的相機優化和 JNI 操作 OpenCV 進行圖片處理進行講解,感興趣的關注作者呦 :)

獲取更多技術文章可以直接關注我的公眾號「Hello 開發者」,另外感興趣的可以加入技術 QQ 交流群:1018235573.

以上,感謝閱讀~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容

  • "錦繡你這狠苦的女人,我怨你不得好死。我怨你死不暝目"茶靡嘟囔了好幾遍。聲音越來越大。 "姐姐,你干什么!你為什么...
    南玙茶靡閱讀 232評論 0 2
  • 站在大四這個臨界點,總會充斥著迷茫與不安,最近這些天,思考了很多,有關人性,有關生活,有關親情,友情,愛情,更...
    Round吳閱讀 165評論 0 0
  • 今兒整理物件,發現紅色的戒指盒,結婚戒指成雙成對的躺在哪里,原來已有好多年了,我幾乎將它遺忘,由于時間變化,粗糙雙...
    毛毛蟲的蝴蝶夢閱讀 332評論 0 5
  • 1. 同學聚會,多年不見,敘舊之后,大家免不了你一言我一語的說起自己的孩子,婚姻,不亦樂乎。 我注意到只有寧寧沉默...
    北方的橙子閱讀 781評論 0 7
  • 花了一兩小時看了看薛之謙的八卦,覺得自己已經被娛樂時代淘汰了。初中的時候對明星八卦如數家珍,甚至臺灣十八線小藝人的...
    魚粥田辛閱讀 679評論 5 3