需求是這樣的

1. 點9圖簡單介紹
Android為了使用同一張圖作為大小區(qū)域背景,設(shè)計了一種可以指定區(qū)域拉伸的圖片格式“.9.png”,這種圖片格式就是點九圖。Android 特有的一種格式,在ios開發(fā)中,可以在代碼中指定某個點進(jìn)行拉伸。

如上圖,點九圖的本質(zhì)實際上是在圖片的四周各增加了1px的像素,并使用純黑(#FF000000)的線進(jìn)行標(biāo)記,其它的與原圖沒有任何區(qū)別
標(biāo)記位置 含義
左--> 縱向拉伸區(qū)域
上--> 橫向拉伸區(qū)域
右--> 縱向顯示區(qū)域
下--> 橫向顯示區(qū)域
點9圖可以通過ps等p圖工具手動畫黑線、Draw9patch工具、AndroidStudio、或者
在線工具點擊進(jìn)入
2. 點9圖的使用
Android中使用點九圖,主要有三種形式,使用res文件夾中的點九圖,使用assets文件夾中的點九圖以及使用網(wǎng)上拉取的點九圖。
注意
Android并不是直接使用點九圖,而是在編譯時將其轉(zhuǎn)換為另外一種格式,這種格式是將其四周的黑色像素保存至Bitmap類中的一個名為mNinePatchChunk的byte[]中,并抹除掉四周的這一個像素的寬度;接著在使用時,如果Bitmap的這個mNinePatchChunk不為空,且為9patch chunk,則將其構(gòu)造為NinePatchDrawable,否則將會被構(gòu)造為BitmapDrawable,最終設(shè)置給view,NinePatchDrawable的拉伸主要是通過其draw方法實現(xiàn)的。
總而言之,最后打出的包中的點九圖,已經(jīng)不是原來的帶黑線的點九圖了。而是通過appt工具,把點9圖的黑線信息編碼到png的圖片字節(jié)中。
3. 把點9圖應(yīng)用到氣泡中
點9圖放到res文件夾, 在編譯期已經(jīng)已經(jīng)把點9信息合成到png中, 我們使用的時候,系統(tǒng)自動解析信息,所以使用起來基本無感知,但是放到文件和網(wǎng)絡(luò)中的點9圖片不能直接使用。如果直接加載就會出現(xiàn)如下效果

黑線仍然存在,且沒有固定區(qū)域拉伸的效果
4. 解決思路
既然無法直接使用點9圖,就要尋求系統(tǒng)是如何使用點9圖的,包括他的編譯過程,也就是點9信息存儲和使用方式。
關(guān)于點9圖的源碼分析,參考騰訊音樂團(tuán)隊的blog,詳見文末的參考文獻(xiàn)
根據(jù)之前的討論我們知道,畫黑線的點九圖與普通圖片的區(qū)別主要在于四周多了1px的黑線,而轉(zhuǎn)換后的點九圖則沒有這1px的黑線,但是它卻包含了用于拉伸的信息。所以我們要從這個信息里面入手。
第一種方案:直接上傳點9圖片,下載之后再做處理。UI生成端比較方便,但是處理過程比較繁瑣, 而且資源圖片要和iOS進(jìn)行區(qū)分,后臺配置比較繁瑣。
第二種方案:設(shè)計生成點9圖之后,通過appt命令,把點9信息編碼到在圖片資源中,Android 端獲取圖片后直接解析點9信息。優(yōu)點是可以和iOS端共用一個資源圖,解析也比較方便,缺點是上傳前需要提前進(jìn)行appt命令合成,而且有合成錯誤的風(fēng)險,。
第三種方案:直接和iOS使用同一套資源,Android手動添加patch點,便于適配不使用padding。優(yōu)點是配置端比較方便,缺點是解析和生成比較繁瑣。
第一種方案就不說了,三端都繁瑣的事情,就沒必要做了。
第二種方案處理流程
a> UI端生成點9圖片
b> 通過命令 aapt s -i xx.9.png -o xx.png 生成包含點9信息的圖片,上傳服務(wù)器
c> Android下載之后,解析點9信息
var bmp = Bitmap.createBitmap(bmpTemp)
var chunk = bmp?.ninePatchChunk
d> 判斷點9信息正確之后,生成NinePatchDrawable,設(shè)置對應(yīng)view backgroud
if (NinePatch.isNinePatchChunk(chunk)) {
var ninePatchDrawable = NinePatchDrawable(context?.resources, bmp, chunk, NinePatchChunk.getPaddingRect(chunk), null)
view.post {
view.background = ninePatchDrawable
}
} else {
var bitmapDrawable = BitmapDrawable(context?.resources, bmp)
view.post {
view.background = bitmapDrawable
}
}
中間遇到的一個坑,就是這種方式加載的點9圖無法使用padding效果,就是點9圖的右下內(nèi)容區(qū)域控制,查資料找到實現(xiàn)方案NinePatchDrawable的第三個參數(shù)padding內(nèi)容, 如下類中的getPaddingRect方法。
import android.graphics.Rect;
import com.richard.base.BaseApplication;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* **************************************
* 項目名稱:Giggle
*
* @Author wuzhiguo
* 創(chuàng)建時間: 2020/8/14 11:25 AM
* 用途:
* **************************************
*/
public class NinePatchChunk {
private static final String TAG = "NinePatchChunk";
public final Rect mPaddings = new Rect();
public int mDivX[];
public int mDivY[];
public int mColor[];
private static float density = BaseApplication.Companion.getInstance().getResources().getDisplayMetrics().density;
private static void readIntArray(final int[] data, final ByteBuffer buffer) {
for (int i = 0, n = data.length; i < n; ++i)
data[i] = buffer.getInt();
}
private static void checkDivCount(final int length) {
if (length == 0 || (length & 0x01) != 0)
throw new IllegalStateException("invalid nine-patch: " + length);
}
public static Rect getPaddingRect(final byte[] data) {
NinePatchChunk deserialize = deserialize(data);
if (deserialize == null) {
return new Rect();
}
return deserialize.mPaddings;
}
public static NinePatchChunk deserialize(final byte[] data) {
final ByteBuffer byteBuffer =
ByteBuffer.wrap(data).order(ByteOrder.nativeOrder());
if (byteBuffer.get() == 0) {
return null; // is not serialized
}
final NinePatchChunk chunk = new NinePatchChunk();
chunk.mDivX = new int[byteBuffer.get()];
chunk.mDivY = new int[byteBuffer.get()];
chunk.mColor = new int[byteBuffer.get()];
try {
checkDivCount(chunk.mDivX.length);
checkDivCount(chunk.mDivY.length);
} catch (Exception e) {
return null;
}
// skip 8 bytes
byteBuffer.getInt();
byteBuffer.getInt();
chunk.mPaddings.left = byteBuffer.getInt();
chunk.mPaddings.right = byteBuffer.getInt();
chunk.mPaddings.top = byteBuffer.getInt();
chunk.mPaddings.bottom = byteBuffer.getInt();
// skip 4 bytes
byteBuffer.getInt();
readIntArray(chunk.mDivX, byteBuffer);
readIntArray(chunk.mDivY, byteBuffer);
readIntArray(chunk.mColor, byteBuffer);
return chunk;
}
}
另一個坑就是fresco圖片加載框架通過ImagePipeline、BaseBitmapDataSubscriber獲取的bitmap對象中,NinePatchChunk信息是空的,只能通過DataSource方式獲取字節(jié)流轉(zhuǎn)換成bitmap才可以。
第三種方案,手動添加拉伸信息到bitmap
要實現(xiàn)這種方案,首先要對Bitmap和NinePatch以及png的tunk信息有一定的了解,詳情可參閱QQ音樂的blog講解
實現(xiàn)方案如下
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.NinePatch
import android.graphics.drawable.NinePatchDrawable
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* **************************************
* 項目名稱:Giggle
*
* @Author wuzhiguo
* 創(chuàng)建時間: 2020/8/14 11:25 AM
* 用途: 手動構(gòu)建NinePatch
* **************************************
*/
class NinePatchBuilder {
var width: Int
var height: Int
var bitmap: Bitmap? = null
var resources: Resources? = null
private val xRegions = mutableListOf<Int>()
private val yRegions = mutableListOf<Int>()
constructor(resources: Resources?, bitmap: Bitmap) {
width = bitmap.width
height = bitmap.height
this.bitmap = bitmap
this.resources = resources
}
constructor(width: Int, height: Int) {
this.width = width
this.height = height
}
fun addXRegion(x: Int, width: Int): NinePatchBuilder {
xRegions.add(x)
xRegions.add(x + width)
return this
}
fun addXRegionPoints(x1: Int, x2: Int): NinePatchBuilder {
xRegions.add(x1)
xRegions.add(x2)
return this
}
fun addXRegion(xPercent: Float, widthPercent: Float): NinePatchBuilder {
val xtmp = (xPercent * width).toInt()
xRegions.add(xtmp)
xRegions.add(xtmp + (widthPercent * width).toInt())
return this
}
fun addXRegionPoints(x1Percent: Float, x2Percent: Float): NinePatchBuilder {
xRegions.add((x1Percent * width).toInt())
xRegions.add((x2Percent * width).toInt())
return this
}
fun addXCenteredRegion(width: Int): NinePatchBuilder {
val x = ((this.width - width) / 2)
xRegions.add(x)
xRegions.add(x + width)
return this
}
fun addXCenteredRegion(widthPercent: Float): NinePatchBuilder {
val width = (widthPercent * width).toInt()
val x = ((this.width - width) / 2)
xRegions.add(x)
xRegions.add(x + width)
return this
}
fun addYRegion(y: Int, height: Int): NinePatchBuilder {
yRegions.add(y)
yRegions.add(y + height)
return this
}
fun addYRegionPoints(y1: Int, y2: Int): NinePatchBuilder {
yRegions.add(y1)
yRegions.add(y2)
return this
}
fun addYRegion(yPercent: Float, heightPercent: Float): NinePatchBuilder {
val ytmp = (yPercent * height).toInt()
yRegions.add(ytmp)
yRegions.add(ytmp + (heightPercent * height).toInt())
return this
}
fun addYRegionPoints(y1Percent: Float, y2Percent: Float): NinePatchBuilder {
yRegions.add((y1Percent * height).toInt())
yRegions.add((y2Percent * height).toInt())
return this
}
fun addYCenteredRegion(height: Int): NinePatchBuilder {
val y = ((this.height - height) / 2)
yRegions.add(y)
yRegions.add(y + height)
return this
}
fun addYCenteredRegion(heightPercent: Float): NinePatchBuilder {
val height = (heightPercent * height).toInt()
val y = ((this.height - height) / 2)
yRegions.add(y)
yRegions.add(y + height)
return this
}
fun buildChunk(): ByteArray {
if (xRegions.size == 0) {
xRegions.add(0)
xRegions.add(width)
}
if (yRegions.size == 0) {
yRegions.add(0)
yRegions.add(height)
}
/* example code from a anwser above
// The 9 patch segment is not a solid color.
private static final int NO_COLOR = 0x00000001;
ByteBuffer buffer = ByteBuffer.allocate(56).order(ByteOrder.nativeOrder());
//was translated
buffer.put((byte)0x01);
//divx size
buffer.put((byte)0x02);
//divy size
buffer.put((byte)0x02);
//color size
buffer.put(( byte)0x02);
//skip
buffer.putInt(0);
buffer.putInt(0);
//padding
buffer.putInt(0);
buffer.putInt(0);
buffer.putInt(0);
buffer.putInt(0);
//skip 4 bytes
buffer.putInt(0);
buffer.putInt(left);
buffer.putInt(right);
buffer.putInt(top);
buffer.putInt(bottom);
buffer.putInt(NO_COLOR);
buffer.putInt(NO_COLOR);
return buffer;*/
val NO_COLOR = 1 //0x00000001;
val COLOR_SIZE = 9 //could change, may be 2 or 6 or 15 - but has no effect on output
val arraySize: Int = 1 + 2 + 4 + 1 + xRegions.size + yRegions.size + COLOR_SIZE
val byteBuffer: ByteBuffer =
ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder())
byteBuffer.put(1.toByte()) //was translated
byteBuffer.put(xRegions.size.toByte()) //divisions x
byteBuffer.put(yRegions.size.toByte()) //divisions y
byteBuffer.put(COLOR_SIZE.toByte()) //color size
//skip
byteBuffer.putInt(0)
byteBuffer.putInt(0)
//padding -- always 0 -- left right top bottom
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)
byteBuffer.putInt(0)
//skip
byteBuffer.putInt(0)
for (rx in xRegions) byteBuffer.putInt(rx) // regions left right left right ...
for (ry in yRegions) byteBuffer.putInt(ry) // regions top bottom top bottom ...
for (i in 0 until COLOR_SIZE) byteBuffer.putInt(NO_COLOR)
return byteBuffer.array()
}
fun buildNinePatch(): NinePatch? {
val chunk = buildChunk()
return if (bitmap != null) NinePatch(bitmap, chunk, null) else null
}
fun build(): NinePatchDrawable? {
val ninePatch = buildNinePatch()
return ninePatch?.let { NinePatchDrawable(resources, it) }
}
}
簡單使用方式如下
val builder = NinePatchBuilder(resources, bmpTemp)
builder.addXCenteredRegion(10)
builder.addYCenteredRegion(10)
val drawable = builder.build()
view.background = drawable
這種方式會保存原來的點9圖padding信息,如果原圖沒有的話, 可以手動在view上做padding控制。
5. 注意事項和遇見的坑
一定要使用緩存,不然異步加載的過程中,在list中顯示會有問題,跳變很嚴(yán)重,尤其是在快速滑動的時候, 最好能復(fù)用圖片緩存框架,內(nèi)存+磁盤兩種緩存都要做。
代碼操作bitmap一定要及時釋放回收,最好能有一定的保障機制,避免bitmap大量占用內(nèi)存。比如發(fā)現(xiàn)圖片過大,可以走default處理方式。
給view設(shè)置Drawable背景的時候,會把view本身的padding刪除,解決方案是提前把view的padding獲取到,設(shè)置完drawable背景之后,再把padding設(shè)置上
public static void setBackgroundAndKeepPadding(View view, Drawable backgroundDrawable) {
Rect drawablePadding = new Rect();
backgroundDrawable.getPadding(drawablePadding);
int top = view.getPaddingTop() + drawablePadding.top;
int left = view.getPaddingLeft() + drawablePadding.left;
int right = view.getPaddingRight() + drawablePadding.right;
int bottom = view.getPaddingBottom() + drawablePadding.bottom;
view.setBackgroundDrawable(backgroundDrawable);
view.setPadding(left, top, right, bottom);
}
- 屏幕適配問題,網(wǎng)絡(luò)下發(fā)的圖可能尺寸對不上,要先適配屏幕,再做點9,再設(shè)置背景
/**
* 指定大小縮放, 為了屏幕適配
* @param bmpTemp Bitmap
* @param bubblePicHeight Float
* @return Bitmap
*/
private fun decodeBitmap(bmpTemp: Bitmap, bubblePicHeight: Float): Bitmap {
val width = bmpTemp.width
val height = bmpTemp.height
//計算壓縮的比率
val scaleHeight = bubblePicHeight / height
//獲取想要縮放的matrix
val matrix = Matrix()
matrix.postScale(scaleHeight, scaleHeight)
//獲取新的bitmap
return Bitmap.createBitmap(bmpTemp, 0, 0, width, height, matrix, false)
}
View設(shè)置Drawable背景,再去掉背景之后,原來的padding并不會去掉,導(dǎo)致控件無法還原,所以要動態(tài)設(shè)置padding
更新列表數(shù)據(jù)閃動問題, 因為設(shè)置drawable背景比較耗時,且有可能設(shè)置不同padding的drawable,所以會導(dǎo)致列表view閃動, 解決方案是局部更新item,或者更新到具體的view
具體方案二和方案三哪種方案更合適,預(yù)研階段進(jìn)行了測試,看不出來有什么性能的差異,后續(xù)實際開發(fā)會持續(xù)進(jìn)行測試驗證。
參考文章
QQ音樂- Android點九圖總結(jié)以及在聊天氣泡中的使用
stackoverflow 社區(qū)