對嘛!這才是從網路載入點9圖的正確姿勢!

語言: CN / TW / HK

theme: channing-cyan

“我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第3篇文章,點選檢視活動詳情

你拿手機刷著刷著,突然手滑點開一張圖,

這圖向上無限高,向下無限深,向左無限遠,向右無限遠,

這圖是什麼?

——是點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 SDKbuild-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(){ override fun onResourceReady(bitmap: Bitmap, transition: Transition?) { 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上已經有一個大神開源出了方案,可以參考下其原始碼實現:http://github.com/Anatolii/NinePatchChunk

這裡只給出使用層的示例程式碼: ``` Glide.with(context).asBitmap().load(url) .into(object : CustomTarget(){ override fun onResourceReady(bitmap: Bitmap, transition: Transition?) { 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圖,RawNinePatch是通過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圖的功能了,對於源型別和已編譯型別的點9圖都能正確展示。

好了,這個就是今天要分享的內容。最後留給大家一個問題,你覺得.9.png的副檔名對於從網路載入點九圖有影響嗎?

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

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

===> 技術號:「星際碼仔」💪

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

參考