【圖像格式篇】可以從網絡加載點9圖的嗎?

從網絡加載點9圖.png

你拿手機刷著刷著,突然手滑點開一張圖,
這圖向上無限高,向下無限深,向左無限遠,向右無限遠,
這圖是什么?

是點9圖。??

大家好,我是來顛覆你對點9圖固有認知的星際碼仔。

點9圖幾乎在每個Android工程中都或多或少地有用到,而切點9圖也可以說是每個Android開發者必備的傳統藝能了,但今天我們要分享的主題估計各位平時比較少接觸到,就是——從網絡加載點9圖

為了講好這個主題,我們會從點9圖的基礎知識出發,比較網絡加載方式與常規用法的區別,然后分別給出一個次優級和更優級的解決思路,可以根據你們當前項目的實際情況自由選取。

照例,先給出一張思維導圖,方便復習:

從網絡加載點9圖.png

點9圖的基礎知識

點9圖,官方的正式名稱為9-patch,是一種可拉伸的位圖圖像格式,因其必須以.9.png為擴展名進行保存而得名,通常被用作各類視圖控件的背景。

其典型的一個應用就是IM中的聊天氣泡框,氣泡框的寬高會隨著我們輸入文本的長短而自適應拉伸,但氣泡框資源本身并不會因拉伸而失真。

聊天氣泡框.jpg

這么神奇的效果是怎么實現的呢?

答案是:四條黑線。

忽略掉.9.png的擴展名,點9圖的本質其實就是一張標準的PNG格式圖片,而與其他普通PNG格式圖片的不同之處在于,點9圖在其圖片的四周額外包含了1像素寬的黑色邊框,用于定義圖片的可拉伸的區域與可繪制的區域,以實現根據視圖內容自動調整圖片大小的效果

可拉伸區域的定義

可拉伸區域由左側及頂部一條或多條黑線來定義,左側的黑色邊框定義了縱向拉伸的區域,頂部的黑色邊框定義了橫向拉伸的區域,拉伸的效果是通過復制區域內圖片的像素來實現的。

可拉伸區域.png

可以看到,由于可拉伸區域選擇的都是比較平整的區域,而沒有覆蓋到四周的圓角,因此圖片無論怎么縱向或橫向拉伸,四周的圓角都不會因此而變形失真。

可繪制區域的定義

可繪制區域由右側及底部的各一條黑線來定義,稱為內邊距線。如果沒有添加內邊距線,視圖內容將默認填滿整個視圖區域。

沒有添加內邊距線.png

而如果添加了內邊距線,則視圖內容僅會在右側及底部的黑線所定義的區域內顯示,如果視圖內容顯示不下,則圖片會拉伸至合適的尺寸。

添加了內邊距線.png

Glide能處理點9圖嗎

點九圖的常規用法,就是以.9.png為擴展名保存在項目的 res/drawable/ 目錄下,并隨著項目一起打包到 *.apk 文件中,然后跟其他普通的PNG格式圖片一樣正常使用即可。

但這種情況在改成了從網絡加載點9圖之后有所變化。

問題在于,即使強大如Glide,對于從網絡加載點9圖的這種場景,也沒有做很好的適配,以至于我們加載完圖片之后會發現...

完!全!沒!有!拉!伸!效!果!

焯.gif

要理解這背后的原因,我們需要把目光轉移到一個原本在打包過程中常常被我們忽視的角色——AAPT。

AAPT是什么?

AAPT即Android Asset Packaging Tool,是用于構建*.apk文件的Android資源打包工具,默認存放在Android SDK的build-tools目錄下。

盡管我們很少直接使用AAPT工具,但其卻是.apk文件打包流程中不可或缺的重要一環,具體可參照下面的.apk文件詳細構建流程圖。

*.apk文件詳細構建流程圖.png

流程里,AAPT工具最重要的功能,就是獲取并編譯我們應用的資源文件,例如AndroidManifest.xml清單文件和Activity的XML布局文件。 還有就是生成了一個R.java,以便我們從 Java 代碼中根據id索引到對應的資源。

而常規用法下的點9圖之所以能正常工作,也離不開打包時,AAPT對于包含點9圖在內的PNG格式圖片的預處理。

那么,AAPT的預處理具體都做了哪些事情呢?

AAPT對點九圖做的預處理

首先,我們要了解的是,在Android的世界里,存在著兩種不同形式的點9圖文件,分別是“源類型(source)”和“已編譯類型(compiled)”。

源類型就是前面所提到的,使用了包括Draw 9-patch在內的點9圖制作工具所創建的、四周帶有1像素寬黑色邊框的PNG圖片。

ic_bubble_right.9.png

而已編譯類型指的是,把之前定義好的點九圖數據(可拉伸區域&可繪制區域等)寫入原先格式的輔助數據塊后,把四周的黑色邊框抹除了的PNG圖片。

ic_bubble_right.png

這里稍微提一下PNG圖片的文件格式。

Png文件結構.png

在文件頭之外,PNG圖片使用了基于“塊(chunk)”的存儲結構,每個塊負責傳達有關圖像的某些信息。

塊有關鍵塊輔助塊兩種類型,關鍵塊包含了讀取和渲染PNG文件所需的信息,必不可少。而輔助數據塊則是可選的,程序在遇到它不理解的輔助塊時,可以安全地忽略它,這種設計可以保持與舊版本的兼容性

點九圖數據所放入的,正是一個tag為“npTc”的輔助數據塊。

AAPT在打包過程中對點9圖的預處理,其實就是將點9圖從源類型轉換為已編譯類型的過程,也只有已編譯類型的點9圖才能被Android系統識別并處理,從而達到根據視圖內容自動調整圖片大小的效果。

而直接從網絡加載的點9圖則缺少這個過程,我們實際拿到的是沒有經過AAPT預處理的源類型,Android系統就只會把它當普通的PNG格式圖片一樣處理,因此展示時會有殘留在四周的黑色邊框,并且當視圖內容過大時,圖片就會因為不合理拉伸而產生明顯的失真。

四周殘留黑線.jpg

明白了這一層的原理之后,我們也就有了一個次優級別的解決思路,也即:

用AAPT命令行還原對點9圖的預處理

AAPT同時也是一個命令行工具,其在打包過程中參與的多項工作都可以通過命令行來實現。

其中就包括對PNG格式圖片的預處理。

于是,具體可操作的步驟也很清晰了:

步驟1:設計組產出源類型的點9圖后,即利用AAPT工具轉換為已編譯類型

這樣做還有一個好處就是,AAPT命令行工具會校驗源類型點9圖的規格,如果不合規就會報錯并給出原因提示,這樣就可以在生產端時就保證產出點9圖的合規性,而不是等到展示的時候才發現有問題。

命令行如下:

 aapt s[ingleCrunch] [-v] -i inputfile -o outputfile

[]表示是可選的完整命令或參數。

步驟2:交付到資源上傳平臺后,后端改由下發這種已編譯類型的點9圖

這個過程還需保證不會因流量壓縮而將圖片轉為Webp格式,或者造成“npTc”的輔助數據塊丟失。

步驟3:客戶端拿到后還需一些額外的處理,以正常識別和展示點9圖

這里主要涉及到2個問題:

  1. 我們怎么知道下發的資源是已編譯類型的點9圖?
  2. 我們怎么告訴系統以點9圖的形式正確處理這張圖?

這2個問題都可以從Android SDK源碼中找到答案。

關于問題1,我們可以從點9圖的常見應用場景,即設為視圖控件背景的API入手,從View#setBackground方法一路深入直至BitmapFactory#setDensityFromOptions方法,就可以看到:

    private static void setDensityFromOptions(Bitmap outputBitmap, Options opts) {
            ...
            byte[] np = outputBitmap.getNinePatchChunk();
            final boolean isNinePatch = np != null && NinePatch.isNinePatchChunk(np);
           ...
    }

Bitmap#getNinePatchChunk方法返回的是一個byte數組類型的數據,從方法名就可以看出其正是關于點九圖規格的輔助塊數據:

    public byte[] getNinePatchChunk() {
        return mNinePatchChunk;
    }

NinePatch#isNinePatchChunk方法是一個Native函數,我們等到后面深入點九圖Native層結構體時再展開講:

    public native static boolean isNinePatchChunk(byte[] chunk);

而關于問題2,我們可以通過查找對Bitmap#getNinePatchChunk方法的引用,在Drawable#createFromResourceStream方法中找到一個參考例子:

    public static Drawable createFromResourceStream(@Nullable Resources res,
            @Nullable TypedValue value, @Nullable InputStream is, @Nullable String srcName,
            @Nullable BitmapFactory.Options opts) {
        ...
        Rect pad = new Rect();
        ...
        Bitmap  bm = BitmapFactory.decodeResourceStream(res, value, is, pad, opts);
        if (bm != null) {
            byte[] np = bm.getNinePatchChunk();
            if (np == null || !NinePatch.isNinePatchChunk(np)) {
                np = null;
                pad = null;
            }

            final Rect opticalInsets = new Rect();
            bm.getOpticalInsets(opticalInsets);
            return drawableFromBitmap(res, bm, np, pad, opticalInsets, srcName);
        }
        return null;
    }
    private static Drawable drawableFromBitmap(Resources res, Bitmap bm, byte[] np,
            Rect pad, Rect layoutBounds, String srcName) {

        if (np != null) {
            return new NinePatchDrawable(res, bm, np, pad, layoutBounds, srcName);
        }

        return new BitmapDrawable(res, bm);
    }

可以看到,它是通過在判斷NinePatchChunk數據不為空后,構建了一個NinePatchDrawable來告訴系統以點9圖的形式正確處理這張圖的。

于是我們可以得出結論,客戶端要做的額外處理,就是在拿到已編譯類型的點9圖并構建為Bitmap后:

  1. 先調用Bitmap#getNinePatchChunk方法嘗試獲取點9圖數據

  2. 再通過NinePatch#isNinePatchChunk方法判斷是不是點9圖數據。

  3. 如果是點9圖數據,則利用這個點9圖數據構建一個NinePatchDrawable

  4. 如果不是,則構建一個BitmapDrawable。

示例代碼如下:

        Glide.with(context).asBitmap().load(url)
            .into(object : CustomTarget<Bitmap>(){
                override fun onResourceReady(bitmap: Bitmap, transition: Transition<in Bitmap>?) {
                    try {
                        val chunk = bitmap.ninePatchChunk
                        val drawable = if (NinePatch.isNinePatchChunk(chunk)) {
                            NinePatchDrawable(context.resources, bitmap, chunk, Rect(), null)
                        } else {
                            BitmapDrawable(context.resources, bitmap);
                        }
                        view.background = drawable;
                    } catch (e: Exception) {
                        e.printStackTrace();
                    }
                }
    
                override fun onLoadCleared(placeholder: Drawable?) {
                }
    
            })

這樣就滿足了嗎?并沒有。方案本身雖然可行,但讓一向習慣可視化界面操作的設計組同事執行命令行,實在是有點太為難他們了,并且每次產出資源后都要用AAPT工具處理一遍,也確實有點麻煩。

話說回來,命令行工具的底層肯定還是依賴代碼來實現的,那有沒有可能在客戶端側實現一套與AAPT工具一樣的邏輯呢?這就引出了我們一個更次優級別的解決思路,也即:

在客戶端側還原對點9圖的預處理

透過上一個方案我們可以了解到,最關鍵的地方還是那個byte數組類型的點九圖數據塊(NineChunk),如果我們能知道這個數據塊里面實際包含什么內容,就有機會在在客戶端側構造出一份類似的數據。

上一個方案中提到的NinePatch#isNinePatchChunk方法就是我們的突破點。

接下來,就讓我們進入Native層查看isNinePatchChunk方法的源碼實現吧:

    static jboolean isNinePatchChunk(JNIEnv* env, jobject, jbyteArray obj) {
        if (NULL == obj) {
            return JNI_FALSE;
        }
        if (env->GetArrayLength(obj) < (int)sizeof(Res_png_9patch)) {
            return JNI_FALSE;
        }
        const jbyte* array = env->GetByteArrayElements(obj, 0);
        if (array != NULL) {
            const Res_png_9patch* chunk = reinterpret_cast<const Res_png_9patch*>(array);
            int8_t wasDeserialized = chunk->wasDeserialized;
            env->ReleaseByteArrayElements(obj, const_cast<jbyte*>(array), JNI_ABORT);
            return (wasDeserialized != -1) ? JNI_TRUE : JNI_FALSE;
        }
        return JNI_FALSE;
    }

可以看到,在isNinePatchChunk方法內部實際是將傳入的byte數組類型的點9圖數據轉為一個Res_png_9patch類型的結構體,再通過一個wasDeserialized的結構變量來判斷是不是點9圖數據的。

這個Res_png_9patch類型的結構體內部是這樣的:

 * This chunk specifies how to split an image into segments for
 * scaling.
 *
 * There are J horizontal and K vertical segments.  These segments divide
 * the image into J*K regions as follows (where J=4 and K=3):
 *
 *      F0   S0    F1     S1
 *   +-----+----+------+-------+
 * S2|  0  |  1 |  2   |   3   |
 *   +-----+----+------+-------+
 *   |     |    |      |       |
 *   |     |    |      |       |
 * F2|  4  |  5 |  6   |   7   |
 *   |     |    |      |       |
 *   |     |    |      |       |
 *   +-----+----+------+-------+
 * S3|  8  |  9 |  10  |   11  |
 *   +-----+----+------+-------+
 *
 * Each horizontal and vertical segment is considered to by either
 * stretchable (marked by the Sx labels) or fixed (marked by the Fy
 * labels), in the horizontal or vertical axis, respectively. In the
 * above example, the first is horizontal segment (F0) is fixed, the
 * next is stretchable and then they continue to alternate. Note that
 * the segment list for each axis can begin or end with a stretchable
 * or fixed segment.
 * /
struct alignas(uintptr_t) Res_png_9patch
{
    Res_png_9patch() : wasDeserialized(false), xDivsOffset(0),
                       yDivsOffset(0), colorsOffset(0) { }

    int8_t wasDeserialized;
    uint8_t numXDivs;
    uint8_t numYDivs;
    uint8_t numColors;

    // The offset (from the start of this structure) to the xDivs & yDivs
    // array for this 9patch. To get a pointer to this array, call
    // getXDivs or getYDivs. Note that the serialized form for 9patches places
    // the xDivs, yDivs and colors arrays immediately after the location
    // of the Res_png_9patch struct.
    uint32_t xDivsOffset;
    uint32_t yDivsOffset;

    int32_t paddingLeft, paddingRight;
    int32_t paddingTop, paddingBottom;

    enum {
        // The 9 patch segment is not a solid color.
        NO_COLOR = 0x00000001,

        // The 9 patch segment is completely transparent.
        TRANSPARENT_COLOR = 0x00000000
    };

    // The offset (from the start of this structure) to the colors array
    // for this 9patch.
    uint32_t colorsOffset;
    ...

    inline int32_t* getXDivs() const {
        return reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(this) + xDivsOffset);
    }
    inline int32_t* getYDivs() const {
        return reinterpret_cast<int32_t*>(reinterpret_cast<uintptr_t>(this) + yDivsOffset);
    }
    inline uint32_t* getColors() const {
        return reinterpret_cast<uint32_t*>(reinterpret_cast<uintptr_t>(this) + colorsOffset);
    }
} __attribute__((packed));

很明顯,這個結構體就是用來存儲點9圖規格數據的,我們可以根據該結構體的源碼和注釋梳理出每個變量的含義:

每個變量的含義.png

根據該結構體注釋中的描述,這個結構體是用于指定如何將圖像分割成多個部分以進行縮放的,其中:

  • Sx標簽標記的是拉伸區域(stretchable),Fx標簽標記的是固定區域(fixed)
  • mDivX描述了所有S區域水平方向的起始位置和結束位置
  • mDivY描述了所有S區域垂直方向的起始位置和結束位置
  • mColor描述了每個小區域的顏色

以該結構體注釋中的例子來說,mDivX,mDivY,mColor分別如下:

 *      F0   S0    F1     S1
 *   +-----+----+------+-------+
 * S2|  0  |  1 |  2   |   3   |
 *   +-----+----+------+-------+
 *   |     |    |      |       |
 *   |     |    |      |       |
 * F2|  4  |  5 |  6   |   7   |
 *   |     |    |      |       |
 *   |     |    |      |       |
 *   +-----+----+------+-------+
 * S3|  8  |  9 |  10  |   11  |
 *   +-----+----+------+-------+
mDivX = [ S0.start, S0.end, S1.start, S1.end];
mDivY = [ S2.start, S2.end, S3.start, S3.end];
mColor = [c[0],c[1],...,c[11]]

我畫了一張示意圖,應該會更方便理解一點:

注釋例子示意圖.png

這幾個結構體變量所描述的,不正是我們源類型的點9圖四周所對應的那些黑色邊框的位置嗎?

那么,現在我們只需要在Java層定義一個與Res_png_9patch結構體的數據結構一模一樣的類,并在填充關鍵的變量數據后序列化為byte數組類型的數據,就可以作為NinePatchDrawable構造函數的參數了。

怎么做呢?這部分有點復雜,Github上已經有一個大神開源出了方案,可以參考下其源碼實現:https://github.com/Anatolii/NinePatchChunk

這里只給出使用層的示例代碼:

     Glide.with(context).asBitmap().load(url)
            .into(object : CustomTarget<Bitmap>(){
                override fun onResourceReady(bitmap: Bitmap, transition: Transition<in Bitmap>?) {
                    try {
                        val drawable = NinePatchChunk.create9PatchDrawable(textBackground.context, resource, null)
                        view.background = drawable;
                    } catch (e: Exception) {
                        e.printStackTrace();
                    }
                }

                override fun onLoadCleared(placeholder: Drawable?) {
                }

            })

NinePatchChunk類即為前面說的在Java層定義的類,并提供了幾個靜態方法用于創建NinePatchDrawable,其在內部會去檢測傳入的Bitmap實例屬于哪種類型:

    public static BitmapType determineBitmapType(Bitmap bitmap) {
        if (bitmap == null) return NULL;
        byte[] ninePatchChunk = bitmap.getNinePatchChunk();
        if (ninePatchChunk != null && android.graphics.NinePatch.isNinePatchChunk(ninePatchChunk))
            return NinePatch;
        if (NinePatchChunk.isRawNinePatchBitmap(bitmap))
            return RawNinePatch;
        return PlainImage;
    }

NinePatch即為已編譯類型的點9圖,RawNinePatch即為源類型的點9圖,源類型是通過PNG圖片4個角像素是否為透明且是否包含黑色邊框判斷的。

    public static boolean isRawNinePatchBitmap(Bitmap bitmap) {
        if (bitmap == null) return false;
        if (bitmap.getWidth() < 3 || bitmap.getHeight() < 3)
            return false;
        if (!isCornerPixelsAreTrasperent(bitmap))
            return false;
        if (!hasNinePatchBorder(bitmap))
            return false;
        return true;
    }

好了,這個就是今天要分享的內容。最后留給大家一個問題,你覺得.9.png的擴展名對于從網絡加載點九圖有影響嗎?

少俠,請留步!若本文對你有所幫助或啟發,還請:

  1. 點贊,讓更多的人能看到!
  2. 收藏?,好文值得反復品味!
  3. 關注?,不錯過每一次更文!

你的支持是我繼續創作的動力,感謝!

參考

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

推薦閱讀更多精彩內容