Android 點九圖機制講解及在聊天氣泡中的應用

  • 2019 年 10 月 3 日
  • 筆記

點九圖簡介

Android為了使用同一張圖作為不同數量文字的背景,設計了一種可以指定區域拉伸的圖片格式「.9.png」,這種圖片格式就是點九圖。

注意:這種圖片格式只能被使用於Android開發。在ios開發中,可以在程式碼中指定某個點進行拉伸,而在Android中不行,所以在Android中想要達到這個效果,只能使用點九圖(下文會啪啪打臉,其實是可以的,只是很少人這樣使用,兼容性不知道怎麼樣,點擊跳轉)

點九圖實質

點九圖的本質實際上是在圖片的四周各增加了1px的像素,並使用純黑(#FF000000)的線進行標記,其它的與原圖沒有任何區別。可以參考以下圖片:

標記位置 含義
左-黑點 縱向拉伸區域
上-黑點 橫向拉伸區域
右-黑線 縱向顯示區域
下-黑線 橫向顯示區域

點九圖在 Android 中的應用

點九圖在 Android 中主要有三種應用方式

  1. 直接放在 res 目錄中的 drawable 或者 mipmap 目錄中
  2. 放在 assert 目錄中
  3. 從網路下載

第一種方式是我們最常用的,直接調用 setBackgroundResource 或者 setImageResource 方法,這樣的話圖片及可以做到自動拉伸。

而對於第二種或者第三種方式,如果我們直接去載入 .9.png,你會發現圖片或者圖片背景根本無法拉伸。納尼,這是為甚麼呢。下面,且聽老衲慢慢道來。

Android 並不是直接使用點九圖,而是在編譯時將其轉換為另外一種格式,這種格式是將其四周的黑色像素保存至Bitmap類中的一個名為 mNinePatchChunk 的 byte[] 中,並抹除掉四周的這一個像素的寬度;接著在使用時,如果 Bitmap 的這個 mNinePatchChunk 不為空,且為 9patch chunk,則將其構造為 NinePatchDrawable,否則將會被構造為 BitmapDrawable,最終設置給 view。

因此,在 Android 中,我們如果想動態使用網路下載的點九圖,一般需要經過以下步驟:

  1. 使用 sdk 目錄下的 aapt 工具將點九圖轉化為 png 圖片
  2. 解析圖片的時候,判斷是否含有 NinePatchChunk,有的話,轉化為 NinePatchDrawable
public static void setNineImagePatch(View view, File file, String url) {      if (file.exists()) {          Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());          byte[] chunk = bitmap.getNinePatchChunk();          if (NinePatch.isNinePatchChunk(chunk)) {              NinePatchDrawable patchy = new NinePatchDrawable(view.getResources(), bitmap, chunk, new Rect(), null);              view.setBackground(patchy);          }        }  }

點九圖上傳伺服器流程


aapt 轉換命令

單個圖片文件轉換

./aapt s -i xxx.9.png -o xxx.png

批量轉換

# 批量轉換  ./aapt c -S inputDir -C outputDir  # inputDir 為原始.9圖文件夾,outputDir 為輸出文件夾

執行成功實例

jundeMacBook-Pro:一期氣泡 junxu$ ./aapt c -S /Users/junxu/Desktop/一期氣泡/氣泡需求整理 -C /Users/junxu/Desktop/一期氣泡/output  Crunching PNG Files in source dir: /Users/junxu/Desktop/一期氣泡/氣泡需求整理  To destination dir: /Users/junxu/Desktop/一期氣泡/output

注意:

若不是標準的點九圖,在轉換的過程會報錯,這時候請設計重新提供新的點九圖


實際開發當中遇到的問題

小屏手機適配問題

剛開始,我們的切圖是按照 2 倍圖切的,這樣在小螢幕手機上會手機氣泡高度過大的問題。

原因分析:

該現象的本質是點九圖圖片的高度大於單行文本消息的高度。

解決方案一(暫時不可取):

  1. 我嘗試去壓縮點九圖,但最終再部分手機上面顯示錯亂,不知道是不是壓縮點九圖的方法錯了。

解決方案二

對於低解析度的手機和高分辨的手機分別下發不同的圖片 url,我們嘗試過得方案是當 density < 2 的時候,採用一倍圖圖片,density >= 2 採用二倍圖圖片。

解決方案三

可能有人會有這樣的疑問呢,為什麼要採用一倍圖,兩倍圖的解決方案呢?直接讓 UI 設計師給一套圖,點九圖圖片的高度適中不就解決了。是啊,我們也是這樣想得,但他們說對於有一些裝飾的點九圖,如果縮小高度,一些裝飾圖案他們不太好切。比如下面圖片中的星星。

小結

說到底,方案二,方案三其實都是折中的一種方案,如果直接能夠做到點九圖縮放,那就完美解決了。而 Android 中 res 目錄中的 drawable 或者 mipmap 的點九圖確實能做到,去看了相關的程式碼,目前也沒有發現什麼好的解決方案,如果你有好的解決方案話,歡迎留言交流。

點九圖的 padding 在部分手機上面失效

這個是部分 Android 手機的 bug,解決方法見:https://stackoverflow.com/questions/11065996/ninepatchdrawable-does-not-get-padding-from-chunk

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 = IMO.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();          }      }        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;      }  }    NinePatchDrawable patchy = new NinePatchDrawable(view.getResources(), bitmap, chunk, NinePatchChunk.getPaddingRect(chunk), null);  view.setBackground(patchy);    

動態下載點九圖會導致聊天氣泡閃爍

  1. 這裡我們採取的方案是預下載(預下載 10 個)
  2. 聊天氣泡採用記憶體快取,磁碟快取,確保 RecyclerView 快速滑動的時候不會閃爍

理解點九圖

以下內容參考騰訊音樂的 Android動態布局入門及NinePatchChunk解密

回顧NinePatchDrawable的構造方法第三個參數bitmap.getNinePatchChunk(),作者猜想,aapt命令其實就是在bitmap圖片中,加入了NinePatchChunk的資訊,那麼我們是不是只要能自己構造出這個東西,就可以讓任何圖片按照我們想要的方式拉升了呢?

可是查了一堆官方文檔,似乎並找不到相應的方法來獲得這個byte[]類型的chunk參數。

既然無法知道這個chunk如何生成,那麼能不能從解析的角度逆向得出這個NinePatchChunk的生成方法呢?

下面就需要從源碼入手了。

NinePatchChunk.java

public static NinePatchChunk deserialize(byte[] data) {      ByteBuffer byteBuffer =              ByteBuffer.wrap(data).order(ByteOrder.nativeOrder());      byte wasSerialized = byteBuffer.get();      if (wasSerialized == 0) return null;      NinePatchChunk chunk = new NinePatchChunk();      chunk.mDivX = new int[byteBuffer.get()];      chunk.mDivY = new int[byteBuffer.get()];      chunk.mColor = new int[byteBuffer.get()];      checkDivCount(chunk.mDivX.length);      checkDivCount(chunk.mDivY.length);      // 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;  }

其實從這部分解析byte[] chunk的源碼,我們已經可以反推出來大概的結構了。如下圖,

按照上圖中的猜想以及對.9.png的認識,直覺感受到,mDivX,mDivY,mColor這三個數組是最關鍵的,但是具體是什麼,就要繼續看源碼了。

ResourceTypes.h

/**   * 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.   * /

正如源碼中,注釋的一樣,這個NinePatch Chunk把圖片從x軸和y軸分成若干個區域,F區域代表了固定,S區域代表了拉伸。mDivX,mDivY描述了所有S區域的位置起始,而mColor描述了,各個Segment的顏色,通常情況下,賦值為源碼中定義的NO_COLOR = 0x00000001就行了。就以源碼注釋中的例子來說,mDivX,mDivY,mColor如下:

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]]

對於mColor這個數組,長度等於劃分的區域數,是用來描述各個區域的顏色的,而如果我們這個只是描述了一個bitmap的拉伸方式的話,是不需要顏色的,即源碼中NO_COLOR = 0x00000001

說了這麼多,我們還是通過一個簡單例子來說明如何構造一個按中心點拉伸的 NinePatchDrawable 吧,

Bitmap bitmap = BitmapFactory.decodeFile(filepath);  int[] xRegions = new int[]{bitmap.getWidth() / 2, bitmap.getWidth() / 2 + 1};  int[] yRegions = new int[]{bitmap.getWidth() / 2, bitmap.getWidth() / 2 + 1};  int NO_COLOR = 0x00000001;  int colorSize = 9;  int bufferSize = xRegions.length * 4 + yRegions.length * 4 + colorSize * 4 + 32;    ByteBuffer byteBuffer = ByteBuffer.allocate(bufferSize).order(ByteOrder.nativeOrder());  // 第一個byte,要不等於0  byteBuffer.put((byte) 1);    //mDivX length  byteBuffer.put((byte) 2);  //mDivY length  byteBuffer.put((byte) 2);  //mColors length  byteBuffer.put((byte) colorSize);    //skip  byteBuffer.putInt(0);  byteBuffer.putInt(0);    //padding 先設為0  byteBuffer.putInt(0);  byteBuffer.putInt(0);  byteBuffer.putInt(0);  byteBuffer.putInt(0);    //skip  byteBuffer.putInt(0);    // mDivX  byteBuffer.putInt(xRegions[0]);  byteBuffer.putInt(xRegions[1]);    // mDivY  byteBuffer.putInt(yRegions[0]);  byteBuffer.putInt(yRegions[1]);    // mColors  for (int i = 0; i < colorSize; i++) {      byteBuffer.putInt(NO_COLOR);  }    return byteBuffer.array();

create-a-ninepatch-ninepatchdrawable-in-runtime

在 stackoverflow 上面也找到牛逼的類,可以動態創建點九圖,並拉伸圖片,啪啪打臉,剛開始說到 android 中無法想 ios 一樣動態指定圖片拉伸區域。

public class NinePatchBuilder {      int width, height;      Bitmap bitmap;      Resources resources;      private ArrayList<Integer> xRegions = new ArrayList<Integer>();      private ArrayList<Integer> yRegions = new ArrayList<Integer>();        public NinePatchBuilder(Resources resources, Bitmap bitmap) {          width = bitmap.getWidth();          height = bitmap.getHeight();          this.bitmap = bitmap;          this.resources = resources;      }        public NinePatchBuilder(int width, int height) {          this.width = width;          this.height = height;      }        public NinePatchBuilder addXRegion(int x, int width) {          xRegions.add(x);          xRegions.add(x + width);          return this;      }        public NinePatchBuilder addXRegionPoints(int x1, int x2) {          xRegions.add(x1);          xRegions.add(x2);          return this;      }        public NinePatchBuilder addXRegion(float xPercent, float widthPercent) {          int xtmp = (int) (xPercent * this.width);          xRegions.add(xtmp);          xRegions.add(xtmp + (int) (widthPercent * this.width));          return this;      }        public NinePatchBuilder addXRegionPoints(float x1Percent, float x2Percent) {          xRegions.add((int) (x1Percent * this.width));          xRegions.add((int) (x2Percent * this.width));          return this;      }        public NinePatchBuilder addXCenteredRegion(int width) {          int x = (int) ((this.width - width) / 2);          xRegions.add(x);          xRegions.add(x + width);          return this;      }        public NinePatchBuilder addXCenteredRegion(float widthPercent) {          int width = (int) (widthPercent * this.width);          int x = (int) ((this.width - width) / 2);          xRegions.add(x);          xRegions.add(x + width);          return this;      }        public NinePatchBuilder addYRegion(int y, int height) {          yRegions.add(y);          yRegions.add(y + height);          return this;      }        public NinePatchBuilder addYRegionPoints(int y1, int y2) {          yRegions.add(y1);          yRegions.add(y2);          return this;      }        public NinePatchBuilder addYRegion(float yPercent, float heightPercent) {          int ytmp = (int) (yPercent * this.height);          yRegions.add(ytmp);          yRegions.add(ytmp + (int) (heightPercent * this.height));          return this;      }        public NinePatchBuilder addYRegionPoints(float y1Percent, float y2Percent) {          yRegions.add((int) (y1Percent * this.height));          yRegions.add((int) (y2Percent * this.height));          return this;      }        public NinePatchBuilder addYCenteredRegion(int height) {          int y = (int) ((this.height - height) / 2);          yRegions.add(y);          yRegions.add(y + height);          return this;      }        public NinePatchBuilder addYCenteredRegion(float heightPercent) {          int height = (int) (heightPercent * this.height);          int y = (int) ((this.height - height) / 2);          yRegions.add(y);          yRegions.add(y + height);          return this;      }        public byte[] buildChunk() {          if (xRegions.size() == 0) {              xRegions.add(0);              xRegions.add(width);          }          if (yRegions.size() == 0) {              yRegions.add(0);              yRegions.add(height);          }            int NO_COLOR = 1;//0x00000001;          int COLOR_SIZE = 9;//could change, may be 2 or 6 or 15 - but has no effect on output          int arraySize = 1 + 2 + 4 + 1 + xRegions.size() + yRegions.size() + COLOR_SIZE;          ByteBuffer byteBuffer = ByteBuffer.allocate(arraySize * 4).order(ByteOrder.nativeOrder());          byteBuffer.put((byte) 1);//was translated          byteBuffer.put((byte) xRegions.size());//divisions x          byteBuffer.put((byte) yRegions.size());//divisions y          byteBuffer.put((byte) COLOR_SIZE);//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 (int rx : xRegions)              byteBuffer.putInt(rx); // regions left right left right ...          for (int ry : yRegions)              byteBuffer.putInt(ry);// regions top bottom top bottom ...            for (int i = 0; i < COLOR_SIZE; i++)              byteBuffer.putInt(NO_COLOR);            return byteBuffer.array();      }        public NinePatch buildNinePatch() {          byte[] chunk = buildChunk();          if (bitmap != null)              return new NinePatch(bitmap, chunk, null);          return null;      }        public NinePatchDrawable build() {          NinePatch ninePatch = buildNinePatch();          if (ninePatch != null)              return new NinePatchDrawable(resources, ninePatch);          return null;      }  }  

運行一下測試程式碼

mLlRoot = findViewById(R.id.ll_root);  try {      InputStream is = getAssets().open("sea.png");      Bitmap bitmap = BitmapFactory.decodeStream(is);      for (int i = 0; i < 5; i++) {          NinePatchDrawable ninePatchDrawable = NinePatchHelper.buildMulti(this, bitmap);          TextView textView = new TextView(this);          textView.setTextSize(25);          textView.setPadding(20, 10, 20, 10);          textView.setText(strArray[i]);          textView.setGravity(Gravity.CENTER_VERTICAL);          LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);          layoutParams.leftMargin = 20;          layoutParams.rightMargin = 20;          textView.setLayoutParams(layoutParams);          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {              textView.setBackground(ninePatchDrawable);          }          mLlRoot.addView(textView);      }  } catch (IOException e) {      e.printStackTrace();  }    

可以看到,我們的圖片完美拉伸


參考文章

  1. https://cloud.tencent.com/developer/article/1168755?
  2. https://mp.weixin.qq.com/s?__biz=MzI1NjEwMTM4OA==&mid=2651232105&idx=1&sn=fcc4fa956f329f839f2a04793e7dd3b9&mpshare=1&scene=21&srcid=0719Nyt7J8hsr4iYwOjVPXQE#wechat_redirect

推薦閱讀

責任鏈模式以及在 Android 中的應用

觀察者設計模式 Vs 事件委託(java)

裝飾者模式及其應用

建造者模式(Builder)及其應用

二次封裝圖片第三方框架——簡單工廠模式的運用

Android 二次封裝網路載入框架

java 代理模式詳解

Rxjava 2.x 源碼系列 - 基礎框架分析

Rxjava 2.x 源碼系列 - 執行緒切換 (上)

Rxjava 2.x 源碼系列 - 執行緒切換 (下)

Rxjava 2.x 源碼系列 - 變換操作符 Map(上)

butterknife 源碼分析

一步步拆解 LeakCanary

java 源碼系列 - 帶你讀懂 Reference 和 ReferenceQueue

掃一掃,歡迎關注我的微信公眾號 stormjun94(徐公碼字), 目前是一名程式設計師,不僅分享 Android開發相關知識,同時還分享技術人成長曆程,包括個人總結,職場經驗,面試經驗等,希望能讓你少走一點彎路。

Tags: